Spring Data JDBC vs Spring Data JPA in 2026: When Dropping the ORM Is the Right Call
Spring Data JDBC and Spring Data JPA, modelled against the identical Order/Customer domain and measured through the same SQL-statement counter, so every claim about N+1 queries, lazy loading, and the aggregate boundary is a number you can reproduce.
Somewhere in a Spring Boot service that has been running fine for a year, a method that loads an order and reads its line items throws LazyInitializationException: could not initialize proxy — no session. Nobody touched that method. What changed is that a batch job now calls it from outside a web request, where there is no Hibernate session left open to satisfy the lazy collection it was quietly relying on the whole time.
That failure, and four others like it, are why this article exists. Spring Data JDBC is not a smaller, worse version of Spring Data JPA. It is a different bet about what an ORM should automate for you — and for a specific, common shape of domain, it is the better bet. This is the walk through exactly which shape that is, with both stacks built against the identical domain, running against the identical database, so every claim below is a number you can reproduce rather than an opinion. The companion project is asmhatre/sdjpa4-demo/jdbc-vs-jpa, where an 11-test suite produces the 11 transcripts under docs/output/ that every figure below is quoted from. If a claim here stops being true, that build goes red.
Versions. Spring Boot 4.1.1, Spring Framework 7.0.9, Spring Data JDBC 4.1.1 and Spring Data JPA 4.1.1 (both via the Spring Data BOM 2026.0.1, itself pulled in by the Boot 4.1.1 BOM — verified against Maven Central’s spring-data-bom-2026.0.1.pom, not the release-notes prose), H2 2.x, JDK 25 (Temurin 25.0.4.1+1). Statement counts were captured below the ORM, by a JDBC-level proxy — see the SQL log section — not by reading each framework’s own debug logging.
The same domain, told twice
Picture the smallest domain that still has something worth arguing about: a Customer who places Orders, and each order has a list of OrderItems. That is it — one parent-child collection, one reference to a second, unrelated thing a customer owns. Almost every Spring Boot codebase has a shape like it somewhere: an order and its lines, an invoice and its charges, a form and its answers.
Under Spring Data JPA, an Order is an @Entity that Hibernate manages inside a persistence context: touch a field, and Hibernate remembers to write it back; ask for order.getItems(), and by default you get a proxy that fetches from the database the first time something actually iterates it. Under Spring Data JDBC, an Order is a plain object with no persistence context watching it, no proxies, and one rule that overrides everything else: the Order and its OrderItems are one aggregate, loaded and saved as a single unit, always, with nothing lazy and nothing partial.
That difference — implicit boundary versus enforced boundary — is the entire article. Everything below is a consequence of it.
The smallest working example, on both sides
The JPA entity looks exactly like the tutorials:
@Entity
@Table(name = "jpa_order")
public class Order {
@Id @GeneratedValue(strategy = IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "customer_id")
private Customer customer;
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
private List<OrderItem> items = new ArrayList<>();
@Version
private Long version;
}
Full source: jpa/Order.java. The JDBC version has no proxies to configure and nothing to make lazy, because nothing can be:
@Table("JDBC_ORDER")
public class Order {
@Id
private Long id;
@Column("CUSTOMER_ID")
private AggregateReference<Customer, Long> customer;
@MappedCollection(idColumn = "ORDER_ID", keyColumn = "ORDER_KEY")
private List<OrderItem> items = new ArrayList<>();
@Version
private Long version;
}
Full source: jdbc/Order.java. Two details worth noticing before anything even runs: customer is not a Customer, it is an AggregateReference<Customer, Long> — a typed foreign key, not an object the framework might decide to fetch for you — and there is no @OneToMany/mappedBy pairing to get wrong, because OrderItem does not know its parent exists. It cannot: it has no field for it.
Plant this, it pays off twice below. Because OrderItem has no back-reference and no repository of its own, Spring Data JDBC has exactly one way to persist a change to it: save the whole Order again. There is no partial update path to reach for by accident. Keep that in mind for the collection-replace section further down — it is the direct cause of the behaviour there, not a separate quirk.
Both models run against the same H2 database through the same statement-logging DataSource — see docs/03-the-sql-log.md for the one paragraph of mechanism worth knowing: it is a JDK dynamic proxy sitting below both frameworks, so it counts what actually reached the driver, not what each ORM’s own logging chose to print — which turns out to matter later, when JDBC batches three inserts into what the proxy correctly reports as a single round trip.
What the JPA side does by default — and what it costs to know
None of what follows is a Hibernate bug. Every one of these five behaviours is exactly what the JPA specification promises. The argument is narrower: each one requires the reader to already know it is coming.
A lazy collection needs a session that might not be there
Load an Order inside a transaction, return it, then read order.getItems().size() after that transaction — and the Hibernate session it owned — has closed:
order.getItems().size() -> org.hibernate.LazyInitializationException: Cannot lazily
initialize collection of role 'com.ankurm.jdbcvsjpa.jpa.Order.items' with key '34' (no session)
Quoted verbatim from docs/output/01-lazy-outside-session.txt. This module sets spring.jpa.open-in-view: false deliberately — Boot’s own reference documentation recommends it — which is precisely why the session closes at the transaction boundary instead of silently staying open for the rest of an HTTP request. Leave open-in-view at its (very common) default of true in a typical web app and this exception simply moves to whichever code path is the first to run outside a request: a scheduled job, an async method, a message listener.
The ordinary-looking loop that is secretly N+1
Three orders, two items each, loaded with a plain findAll(), then read in a loop — the shape of code nobody would flag in review:
List<Order> orders = orderRepository.findAll();
long total = orders.stream().mapToLong(o -> o.getItems().size()).sum();
1. select o1_0.id,o1_0.customer_id,o1_0.version from jpa_order o1_0
2. select i1_0.order_id,i1_0.id,i1_0.quantity,i1_0.sku from jpa_order_item i1_0 where i1_0.order_id=?
3. select i1_0.order_id,i1_0.id,i1_0.quantity,i1_0.sku from jpa_order_item i1_0 where i1_0.order_id=?
4. select i1_0.order_id,i1_0.id,i1_0.quantity,i1_0.sku from jpa_order_item i1_0 where i1_0.order_id=?
total statements: 4
From docs/output/02-n-plus-one.txt: one query for the orders, one more per order for its lazily-loaded items. The fix is an @EntityGraph, and it does work — the same lookup for a 1-item order and a 5-item order both cost exactly 1 statement once it is applied (docs/output/03-entity-graph-fixed-cost.txt) — but it is an annotation you have to already know to add. The JDBC side gets that fixed-cost shape with no annotation at all; see below.
An UPDATE with no save() call anywhere
Inside one transaction: load an order, set item.setQuantity(99) directly on a managed entity — no save() — then run one unrelated query:
1. select o1_0.id,c1_0.id,c1_0.name,i1_0.order_id,i1_0.id,i1_0.quantity,i1_0.sku,o1_0.version from jpa_order o1_0 left join jpa_customer c1_0 on c1_0.id=o1_0.customer_id left join jpa_order_item i1_0 on o1_0.id=i1_0.order_id where o1_0.id=?
2. select count(*) from jpa_customer c1_0
3. update jpa_order_item set order_id=?,quantity=?,sku=? where id=?
From docs/output/04-dirty-checking-autoflush.txt. The UPDATE runs before the unrelated query, and before the transaction ever commits, because Hibernate’s dirty checking flushes pending changes ahead of anything that could otherwise observe stale data. Correct behaviour — and also the reason “I never called save()” is not evidence that nothing was written.
Removing from a list becomes a DELETE only with one extra word
order.removeItem(item) followed by save(order) deletes that row — but only because the mapping carries orphanRemoval = true:
1. select o1_0.id,c1_0.id,c1_0.name,i1_0.order_id,i1_0.id,i1_0.quantity,i1_0.sku,o1_0.version from jpa_order o1_0 left join jpa_customer c1_0 on c1_0.id=o1_0.customer_id left join jpa_order_item i1_0 on o1_0.id=i1_0.order_id where o1_0.id=?
2. delete from jpa_order_item where id=?
From docs/output/05-orphan-removal-delete.txt. Drop that one attribute and the row survives with a dangling order_id — which, in the codebases I’ve seen this in, is a more common bug report than the lazy-loading exception above.
None of this is JPA being wrong. It is JPA trusting you to already know four separate facts — open-in-view‘s effect on session lifetime, when a fetch join is needed, when dirty checking flushes, and what orphanRemoval does — before the obvious code does the obvious thing. The next section is what happens when a framework has no mechanism that could produce any of the four.
How the statement counter works, and why it counts JDBC batching correctly: docs/03-the-sql-log.md
What the JDBC side does instead
findById always returns the whole aggregate, at a fixed cost
The same lookup, no annotation, for a 1-item order and a 5-item order:
statements for 1-item order: 2 (items loaded: 1)
statements for 5-item order: 2 (items loaded: 5)
From docs/output/06-fixed-cost-load.txt. There is no lazy-versus-eager decision to make, because there is no lazy loading to choose between. This is not an optimisation Spring Data JDBC performs on your behalf — it is the only thing it knows how to do, because an aggregate, by definition, is loaded as one unit.
The table below re-runs the identical logical operation — load every order for a customer, read every item on every order — against both stacks, for a few order/item counts, all counted by the same proxy:
orders
items each
JPA (plain findAll + lazy access)
Spring Data JDBC
1
1
2
2
1
5
2
2
5
1
6
6
5
5
6
6
20
3
21
21
Full transcript: docs/output/00-statement-count-comparison.txt. Read that table carefully: the two columns match, order for order. That is not JDBC winning a benchmark — it is JPA’s naive path costing the same as JDBC’s only path, once you fetch through findByCustomerId rather than a bare findAll plus a loop. The honest comparison is scenario 02 above (plain JPA, N+1) against this table: JDBC’s default gives you the flattened cost for free, JPA’s default gives you the linear one, and getting JPA to the flattened shape is an annotation someone has to remember to add.
Saving replaces the whole collection — as one batch, not one round trip per row
Change the quantity on exactly one of three OrderItems, save the aggregate:
1. UPDATE "JDBC_ORDER" SET "VERSION" = ?, "CUSTOMER_ID" = ? WHERE "JDBC_ORDER"."ID" = ? AND "JDBC_ORDER"."VERSION" = ?
2. DELETE FROM "JDBC_ORDER_ITEM" WHERE "JDBC_ORDER_ITEM"."ORDER_ID" = ?
3. INSERT INTO "JDBC_ORDER_ITEM" ("ORDER_ID", "ORDER_KEY", "ID", "QUANTITY", "SKU") VALUES (?, ?, ?, ?, ?)
DELETE statements: 1
INSERT statements: 1
From docs/output/07-delete-then-insert.txt, where the test also reloads the order afterward and confirms all three rows come back with the third one now at quantity 999 — the replace really did carry every row, not just the changed one. This is the fact the “plant this” callout above was pointing at: because OrderItem has no identity of its own to the framework, Spring Data JDBC does not diff the collection — it deletes every existing child row for that parent and reinserts the current list, every time, even though only one of three rows actually changed. What it does not do is pay for that with three separate round trips: the three inserts are one JDBC batch, addBatch() three times and executeBatch() once, which is exactly why the log above shows a single INSERT line rather than three — the statement-logging proxy counts execute* invocations, and a batch is one invocation regardless of its row count.
For a three-row collection this is invisible. For a collection with thousands of rows it is a real, measurable cost, because every row is retransmitted whether it changed or not — see the production checklist linked below before moving a large collection onto this model.
A typed reference costs nothing until you ask for it
Load an order, read order.getCustomer().getId() — never call a CustomerRepository:
1. SELECT "JDBC_ORDER"."ID" AS "ID", "JDBC_ORDER"."VERSION" AS "VERSION", "JDBC_ORDER"."CUSTOMER_ID" AS "CUSTOMER_ID" FROM "JDBC_ORDER" WHERE "JDBC_ORDER"."ID" = ?
2. SELECT "JDBC_ORDER_ITEM"."ID" AS "ID", "JDBC_ORDER_ITEM"."SKU" AS "SKU", "JDBC_ORDER_ITEM"."QUANTITY" AS "QUANTITY", "JDBC_ORDER_ITEM"."ORDER_KEY" AS "ORDER_KEY" FROM "JDBC_ORDER_ITEM" WHERE "JDBC_ORDER_ITEM"."ORDER_ID" = ? ORDER BY "ORDER_KEY"
statements touching jdbc_customer: 0
customer id obtained without a lookup: 3
From docs/output/08-aggregate-reference-no-join.txt. AggregateReference<Customer, Long> is a typed foreign key: getId() is free because the value is already sitting in a column read while loading the order. Nothing about Customer is fetched unless you explicitly ask a CustomerRepository for it — there is no mechanism in Spring Data JDBC that would ever load a second aggregate as a side effect of loading the first, which is what keeps the fixed-cost table above fixed even as your domain grows past one aggregate.
Two more that came up in testing, briefly
@Version behaves the same way it does under JPA — a stale write loses with OptimisticLockingFailureException, not a silent no-op (docs/output/09-optimistic-locking.txt). And list order across a reload is not guaranteed unless you add keyColumn to @MappedCollection — without it, Spring Data JDBC still stores the list correctly, it just does not promise the order back, which is the kind of thing that works by accident on a fresh table and breaks quietly later (docs/output/10-list-order-key-column.txt).
Reach for Spring Data JDBC when your aggregates are genuinely small (single digits to low tens of child rows, since the replace-on-save cost is proportional to aggregate size on every write), when you’ve been bitten more than once by a lazy-loading exception or an N+1 query you didn’t predict, or when you want the shape of “load this thing” to be readable from the entity class with no session state and no annotation to remember.
Stay on Spring Data JPA when collections are large or updated incrementally at scale (the full delete-and-reinsert becomes real write amplification), when the domain genuinely benefits from a persistence-context-managed graph — complex bidirectional relationships, inheritance, second-level caching — or when the team already has deep Hibernate operational experience to lean on.
Both can live in one application. This module runs both stacks in a single Spring context (@EnableJpaRepositories and @EnableJdbcRepositories, each scoped to its own package) precisely to show that migrating is a per-aggregate decision, not an all-or-nothing rewrite.
Further reading
The companion project — both stacks, one shared statement-logging DataSource, 11 tests, 11 transcripts, 6 documentation chapters
No Comments yet!