skip to content

How does a custom RowMapper work with @Query in Spring Data JDBC, and when would you use one instead of the default mapping?

level: middleimportance: should knowfreq 50%

answer

  1. RowMapper.mapRow(rs, rowNum) -> one object per row
  2. rowMapperClass (no-arg) vs rowMapperRef (bean)
  3. mutually exclusive attributes
  4. default = EntityRowMapper via mapping context
  5. QueryMappingConfiguration = global per-type default

basics

~20 s

A RowMapper turns one ResultSet row into an object via mapRow(rs, rowNum). You attach it with @Query(rowMapperClass = X.class) or rowMapperRef="beanName". Use one when the default entity mapping cannot produce your shape, like a custom DTO from a join.

solid answer

~40 s

By default Spring Data JDBC maps a query's ResultSet columns onto your aggregate using its internal EntityRowMapper. When a hand-written @Query returns a shape that does not correspond to a mapped aggregate, for example a projection joining several tables or a computed DTO, you supply a Spring org.springframework.jdbc.core.RowMapper. Its mapRow(ResultSet rs, int rowNum) method reads columns and builds one object per row. You wire it via @Query(rowMapperClass = MyRowMapper.class), which must have a no-arg constructor, or @Query(rowMapperRef = "myRowMapperBean") to reference a Spring bean (useful when the mapper needs dependencies). rowMapperClass and rowMapperRef are mutually exclusive. You can also register a default RowMapper per return type globally by implementing QueryMappingConfiguration. Use a custom mapper for reporting rows, aggregations, or DTOs; rely on the default for plain aggregate loads.

code

java · 19 lines
java
public record OrderSummary(Long orderId, BigDecimal total, String customerName) {}

public class OrderSummaryRowMapper implements RowMapper<OrderSummary> {
    @Override
    public OrderSummary mapRow(ResultSet rs, int rowNum) throws SQLException {
        return new OrderSummary(
            rs.getLong("id"),
            rs.getBigDecimal("total"),
            rs.getString("last_name"));
    }
}

public interface OrderRepository extends CrudRepository<Order, Long> {
    @Query(value = "SELECT o.id, o.total, c.last_name " +
                   "FROM orders o JOIN customer c ON c.id = o.customer_id " +
                   "WHERE o.total > :min",
           rowMapperClass = OrderSummaryRowMapper.class)
    List<OrderSummary> findLargeOrders(@Param("min") BigDecimal min);
}

go deeper

for a junior

Know that a RowMapper turns a row into an object via mapRow.

for a middle

Wire it with rowMapperClass vs rowMapperRef and know why you would use each; know the default EntityRowMapper exists.

for a senior

Explain QueryMappingConfiguration for global defaults, ResultSetExtractor vs RowMapper, and null/label pitfalls.

for a principal

Weigh custom mapping vs projection support, maintainability of hand SQL + mappers, and the loss of aggregate reconstruction guarantees.

## What a RowMapper is `org.springframework.jdbc.core.RowMapper<T>` is a core Spring JDBC functional interface: ```java @FunctionalInterface public interface RowMapper<T> { T mapRow(ResultSet rs, int rowNum) throws SQLException; } ``` It converts **one row** of a JDBC `ResultSet` into **one object** of type `T`. Spring calls it once per row as it iterates the result set; you read columns by label or index and construct your object. You do **not** call `rs.next()` yourself, Spring drives the cursor. ## Default mapping in Spring Data JDBC When you do not specify a mapper, Spring Data JDBC uses its own `EntityRowMapper` (built from the `RelationalMappingContext`). It matches result columns to the properties of the return-type aggregate using the configured `NamingStrategy` (e.g. `firstName` <-> `first_name`) and reconstructs the aggregate, including reading owned/referenced entities when it loads a full aggregate root. This is what powers derived queries and `CrudRepository` finders. ## When the default is not enough You need a custom `RowMapper` when the query result is **not** a straightforward aggregate load: - A **projection / DTO** combining columns from a join. - **Aggregated / computed** rows (`SUM`, `COUNT`, GROUP BY) that map to a report object, not an entity. - A shape whose columns do not align with any mapped aggregate's properties. ## Wiring a RowMapper to @Query The `@Query` annotation exposes two mutually exclusive attributes: - **`rowMapperClass`** — a `Class<? extends RowMapper>`. Spring instantiates it, so it needs an accessible **no-arg constructor**. - **`rowMapperRef`** — a **bean name** (String) resolved from the application context. Use this when the mapper needs injected collaborators, or when you want a single shared instance. ```java @Query(value = "SELECT o.id, o.total, c.last_name FROM orders o " + "JOIN customer c ON c.id = o.customer_id WHERE o.total > :min", rowMapperClass = OrderSummaryRowMapper.class) List<OrderSummary> findLargeOrders(@Param("min") BigDecimal min); ``` Setting **both** attributes is a configuration error. There is a parallel `resultSetExtractorClass`/`resultSetExtractorRef` pair when you need to map the **entire** ResultSet to one object (a `ResultSetExtractor`), rather than row-by-row. ## Global default mappers: QueryMappingConfiguration Instead of naming a mapper on every method, you can register default `RowMapper`s per return type by providing a `org.springframework.data.jdbc.repository.QueryMappingConfiguration` bean. Spring Data JDBC consults it to find a mapper for a given type when a method does not specify one. ## Gotchas - **No-arg constructor** is required for `rowMapperClass`; if your mapper needs dependencies, use `rowMapperRef` with a registered bean. - **Column labels**: read by the label your SQL projects (use `AS` aliases to make them stable); mismatches throw `SQLException` / `InvalidResultSetAccessException`. - **Null handling**: `rs.getLong` returns 0 for SQL NULL; use `rs.getObject(col, Long.class)` or check `rs.wasNull()` when nullability matters. - **One row = one object**: for whole-result mapping (e.g. building a single Map from many rows) use a `ResultSetExtractor`, not a `RowMapper`. - A custom mapper **bypasses** aggregate reconstruction, so referenced child entities are not auto-loaded; you map exactly what the SQL returns.

  • Your RowMapper needs an injected Spring bean. Which @Query attribute do you use and why?
    rowMapperRef with the mapper's bean name. rowMapperClass is instantiated reflectively via a no-arg constructor and cannot receive dependencies, whereas rowMapperRef resolves an existing, dependency-injected bean from the context.
  • What is the difference between a RowMapper and a ResultSetExtractor?
    RowMapper maps one row to one object and is invoked per row (Spring drives the cursor). ResultSetExtractor is handed the whole ResultSet and returns a single result, letting you aggregate across rows (e.g. build one Map or a single object from many rows). @Query supports resultSetExtractorClass/Ref for the latter.

saying these in an interview costs you the question

  • Calling rs.next() inside mapRow (Spring already advances the cursor)
  • Setting both rowMapperClass and rowMapperRef
  • Expecting rowMapperClass with a constructor that needs dependencies to work
  • Assuming a custom RowMapper still auto-loads referenced child entities

context