Home Java Spring Data JDBC: Simplified Database Access Without JPA
Intermediate 7 min · July 14, 2026

Spring Data JDBC: Simplified Database Access Without JPA

Discover Spring Data JDBC: a simpler, more direct alternative to JPA for database access.

N
Naren Founder & Principal Engineer

20+ years shipping production Java in banking & fintech. Written from production experience, not tutorials.

Follow
Production
production tested
July 15, 2026
last updated
2,398
articles · all by Naren
Before you start⏱ 15-20 min read
  • Java 17 or later
  • Spring Boot 3.x
  • Basic knowledge of SQL and relational databases
  • Familiarity with Spring Boot basics (auto-configuration, dependency injection)
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer
  • Spring Data JDBC provides a clean, lightweight approach to database access without the complexity of JPA.
  • It maps aggregates to tables using a convention-over-configuration approach, reducing boilerplate.
  • Unlike JPA, it does not manage a persistence context, leading to more predictable behavior.
  • Best suited for projects where you want explicit control over SQL and don't need lazy loading or caching.
  • Production experience shows it simplifies debugging and reduces unexpected behaviors common with JPA.
✦ Definition~90s read
What is Introduction to Spring Data JDBC?

Spring Data JDBC is a lightweight data access framework that provides a simple, aggregate-oriented approach to database operations without the complexity of JPA.

Think of Spring Data JDBC as a reliable bicycle compared to JPA's sports car.
Plain-English First

Think of Spring Data JDBC as a reliable bicycle compared to JPA's sports car. The bicycle is simpler, easier to fix when something breaks, and you always know exactly how it works. You don't get fancy features like automatic transmission or cruise control, but you'll never be stranded because of a mysterious engine failure.

If you've spent years wrestling with JPA's persistence context, lazy loading exceptions, and mysterious N+1 queries, you're not alone. I've debugged countless production incidents where a seemingly innocent findById triggered 50 SQL queries because of uninitialized proxies. The problem isn't ORMs in general—it's that JPA tries to be too clever. Spring Data JDBC takes a different path: it treats the database as a database, not an object store. It doesn't manage a first-level cache, doesn't proxy your entities, and doesn't auto-flush changes. You write a repository interface, define your aggregate root, and get back clean SQL execution. No magic. No surprises. This approach is perfect for microservices where each service owns its data and you want minimal framework overhead. In this article, I'll walk you through setting up Spring Data JDBC, show you how to model aggregates, and share hard-earned lessons from production deployments. By the end, you'll know exactly when to reach for Spring Data JDBC and when to stick with JPA.

Getting Started with Spring Data JDBC

Let's cut the fluff. To start with Spring Data JDBC, add the dependency to your pom.xml:

``xml <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jdbc</artifactId> </dependency> ``

Yes, that's it. No EntityManager, no PersistenceContext, no XML configuration. Spring Boot will auto-configure a DataSource and a JdbcTemplate for you. You'll also need a database driver, like mysql-connector-j or postgresql.

Now define a simple aggregate root. In Spring Data JDBC, an aggregate root is the only entity that has a repository. Everything else is part of that aggregate and is stored in the same table or as embedded columns.

```java import org.springframework.data.annotation.Id; import org.springframework.data.relational.core.mapping.Table;

@Table("payments") public class Payment { @Id private Long id; private String transactionId; private BigDecimal amount; private LocalDateTime createdAt;

// Constructors, getters, setters } ```

```java import org.springframework.data.repository.CrudRepository;

public interface PaymentRepository extends CrudRepository<Payment, Long> { } ```

That's it. You now have save, findById, findAll, delete methods. No @Query needed for basic operations. The framework generates SQL based on the table name and column names derived from the class name and field names.

But here's the catch: Spring Data JDBC is opinionated about aggregates. It expects that within an aggregate, all references are part of the same table or are embedded. If you try to model a one-to-many relationship as a separate table, you'll need to use @MappedCollection and ensure the child table has a foreign key back to the root. This is not JPA; you can't have lazy loading across aggregates.

Payment.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import org.springframework.data.annotation.Id;
import org.springframework.data.relational.core.mapping.Table;
import java.math.BigDecimal;
import java.time.LocalDateTime;

@Table("payments")
public class Payment {
    @Id
    private Long id;
    private String transactionId;
    private BigDecimal amount;
    private LocalDateTime createdAt;

    public Payment() {}

    public Payment(String transactionId, BigDecimal amount) {
        this.transactionId = transactionId;
        this.amount = amount;
        this.createdAt = LocalDateTime.now();
    }

    // getters and setters omitted for brevity
}
💡Naming Convention
📊 Production Insight
Never assume that a nested entity will be saved automatically if it's not part of the aggregate. I've seen teams lose data because they expected cascade behavior like JPA.
🎯 Key Takeaway
Setting up Spring Data JDBC is trivial, but understanding aggregate boundaries is crucial to avoid design headaches later.

Aggregate Design: The Heart of Spring Data JDBC

In Spring Data JDBC, the aggregate is king. An aggregate is a cluster of domain objects that can be treated as a single unit. The aggregate root is the only entry point; all external references should go through it. This is not just a theoretical DDD concept—it directly maps to how the framework persists data.

Consider a payment with line items. In JPA, you'd have a Payment entity with a @OneToMany to LineItem. In Spring Data JDBC, you have two choices:

  1. Embedded: If line items are few and always loaded with the payment, store them as JSON in a column or as separate columns. But that's not relational.
  2. Separate table with foreign key: Use @MappedCollection to indicate that LineItem is part of the Payment aggregate.

```java @Table("payments") public class Payment { @Id private Long id; private String transactionId; private BigDecimal amount; private LocalDateTime createdAt;

@MappedCollection(idColumn = "payment_id") private List<LineItem> lineItems = new ArrayList<>();

// constructors, getters, setters }

public class LineItem { @Id private Long id; private String productCode; private int quantity; private BigDecimal price; } ```

When you save a Payment, Spring Data JDBC will insert into the payments table and also insert/update/delete rows in the line_items table based on the lineItems list. The idColumn attribute tells the framework that line_items has a column payment_id referencing the payment's ID.

Crucial: The LineItem does not have its own repository. It is only accessible through Payment. If you need to query line items independently, you've designed your aggregate wrong.

Production tip: Be explicit about cascade operations. Spring Data JDBC will delete all children and re-insert them when you update the aggregate. This is fine for small collections but can be a performance disaster for large ones. I once saw a system that stored hundreds of line items per payment; the delete-insert pattern caused excessive database load. We had to switch to a manual approach using JdbcTemplate for those cases.

PaymentWithLineItems.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
@Table("payments")
public class Payment {
    @Id
    private Long id;
    private String transactionId;
    private BigDecimal amount;
    private LocalDateTime createdAt;

    @MappedCollection(idColumn = "payment_id")
    private List<LineItem> lineItems = new ArrayList<>();

    public Payment(String transactionId, BigDecimal amount) {
        this.transactionId = transactionId;
        this.amount = amount;
        this.createdAt = LocalDateTime.now();
    }

    public void addLineItem(String productCode, int quantity, BigDecimal price) {
        this.lineItems.add(new LineItem(productCode, quantity, price));
    }

    // getters
}

class LineItem {
    @Id
    private Long id;
    private String productCode;
    private int quantity;
    private BigDecimal price;

    public LineItem(String productCode, int quantity, BigDecimal price) {
        this.productCode = productCode;
        this.quantity = quantity;
        this.price = price;
    }
}
⚠ Performance Trap
📊 Production Insight
I've debugged issues where a developer added a @ManyToOne annotation from a child to parent, expecting JPA-like lazy loading. Spring Data JDBC does not support that. If you need bidirectional navigation, you're likely dealing with separate aggregates.
🎯 Key Takeaway
Aggregate design dictates database schema and performance. Keep aggregates small and cohesive.

Custom Queries with @Query

While CrudRepository provides basic CRUD, real applications need custom queries. Spring Data JDBC supports @Query annotation on repository methods, just like Spring Data JPA. But there's a critical difference: you must use plain SQL, not JPQL.

```java public interface PaymentRepository extends CrudRepository<Payment, Long> {

@Query("SELECT * FROM payments WHERE transaction_id = :transactionId") Optional<Payment> findByTransactionId(@Param("transactionId") String transactionId);

@Query("SELECT * FROM payments WHERE amount > :minAmount") List<Payment> findLargePayments(@Param("minAmount") BigDecimal minAmount); } ```

Spring Data JDBC also supports @Modifying for update/delete operations:

``java @Modifying @Query("UPDATE payments SET amount = :amount WHERE id = :id") int updateAmount(@Param("id") Long id, @Param("amount") BigDecimal amount); ``

Notice that @Modifying returns int (number of affected rows). This is straightforward and predictable.

What the docs don't tell you: When you use @Query, the framework does NOT automatically handle aggregate persistence. If your query returns a Payment with line items, you must ensure the SQL joins the necessary tables and that the result set contains all columns required to construct the aggregate. Spring Data JDBC uses a ResultSetExtractor to map rows to entities, and it expects the columns to match the field names (or @Column mappings). If your query omits columns, those fields will be null.

Also, @Query methods bypass the aggregate root's lifecycle. This means no optimistic locking checks are performed. If you need versioning, you must include the version column in your SQL and handle it manually.

Production tip: Always test custom queries with a database that has realistic data. I've seen queries that worked in dev with 10 rows but caused full table scans in production with millions.

CustomQueries.javaJAVA
1
2
3
4
5
6
7
8
9
public interface PaymentRepository extends CrudRepository<Payment, Long> {

    @Query("SELECT * FROM payments WHERE transaction_id = :transactionId")
    Optional<Payment> findByTransactionId(@Param("transactionId") String transactionId);

    @Modifying
    @Query("UPDATE payments SET amount = :amount WHERE id = :id")
    int updateAmount(@Param("id") Long id, @Param("amount") BigDecimal amount);
}
🔥SQL Injection?
📊 Production Insight
A common mistake is to use @Query for a simple findById expecting caching behavior. Spring Data JDBC has no cache, so each call hits the database. If you need caching, add a separate caching layer (e.g., Spring Cache with Redis).
🎯 Key Takeaway
Custom queries give you full control but require careful SQL writing. Always validate that the query returns the correct aggregate structure.

Transactions and Concurrency Control

Spring Data JDBC works with Spring's transaction management seamlessly. Use @Transactional on service methods to ensure atomicity. However, because there's no persistence context, you won't encounter issues like detached entities or auto-flush surprises.

```java @Table("payments") public class Payment { @Id private Long id; private String transactionId; private BigDecimal amount; private LocalDateTime createdAt;

@Version private Integer version; } ```

When you save an entity with a version, Spring Data JDBC includes the version in the UPDATE statement's WHERE clause. If the version in the database differs, an OptimisticLockingFailureException is thrown. This is straightforward and doesn't require a persistence context.

But here's the gotcha: The version check only applies to the aggregate root. If you modify a child entity (like a line item), the version of the parent is not automatically incremented. This means concurrent modifications to different line items of the same payment could overwrite each other. To protect against that, you must either:

  • Include the parent's version column in the child table and check it manually.
  • Or redesign your aggregate so that line items are not independent (e.g., store them as JSON).

I've seen a production incident where two concurrent requests added line items to the same payment. Because only the parent's version was checked, the second request overwrote the first's changes. The fix was to use SELECT ... FOR UPDATE on the parent row before modifying children.

For pessimistic locking, you can use @Lock annotation on repository methods, similar to JPA:

``java @Lock(LockMode.PESSIMISTIC_WRITE) Optional<Payment> findById(Long id); ``

This translates to SELECT ... FOR UPDATE. But note: this only locks the root table, not child tables. If you need to lock children, you must write a custom query.

Production tip: Use optimistic locking for most cases, but be aware of the child modification issue. For high-contention scenarios, consider using a database-level locking mechanism or moving to a different data model.

OptimisticLocking.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Table("payments")
public class Payment {
    @Id
    private Long id;
    private String transactionId;
    private BigDecimal amount;
    private LocalDateTime createdAt;

    @Version
    private Integer version;

    // getters and setters
}

@Service
public class PaymentService {
    @Transactional
    public void updateAmount(Long paymentId, BigDecimal newAmount) {
        Payment payment = paymentRepository.findById(paymentId)
            .orElseThrow(() -> new IllegalArgumentException("Payment not found"));
        payment.setAmount(newAmount);
        paymentRepository.save(payment);
    }
}
⚠ Child Modification Pitfall
📊 Production Insight
Always test optimistic locking with concurrent requests. I've seen teams assume it works like JPA, only to discover that child modifications are not protected.
🎯 Key Takeaway
Transactions and locking work, but you must understand that aggregate boundaries affect concurrency control. Test concurrent scenarios thoroughly.

What the Official Docs Won't Tell You

After years of using Spring Data JDBC in production, here are the hard truths that the official documentation glosses over:

1. No N+1 Protection Spring Data JDBC doesn't have a N+1 problem because it doesn't support lazy loading. But it can still generate many queries if you're not careful. For example, if you load a list of aggregates and then access a child collection for each, you'll get a separate query per aggregate. The solution is to use @Query with a JOIN FETCH equivalent to load everything in one query.

2. The Delete/Re-Insert Pattern As mentioned, updating an aggregate with a @MappedCollection deletes all children and re-inserts them. This is not just inefficient; it can cause issues with database triggers or foreign keys. I once had a system where a trigger on the child table fired on every delete, causing massive slowdowns. We had to switch to manual SQL.

3. Auditing Is Not Built-In Spring Data JDBC does not support @CreatedDate, @LastModifiedDate out of the box like Spring Data JPA does. You need to implement Persistable and handle auditing yourself, or use @PrePersist / @PreUpdate annotations from JPA if you're willing to add the JPA dependency. But that defeats the purpose.

4. No Entity Graphs You cannot define fetch plans. If you need different levels of detail for different use cases, you must write separate queries. This is a design choice, but it means you'll often write multiple repository methods.

5. Limited Support for Inheritance Inheritance mapping is not supported. You cannot have a table-per-class or joined inheritance. If you need polymorphism, you'll have to use a different approach (e.g., single table with a discriminator column and manual mapping).

6. @Query Methods Are Not Aggregate-Aware When you write a custom @Query, the framework doesn't apply any aggregate lifecycle logic. This means no version checks, no cascade operations, and no automatic population of audit fields. You're on your own.

These limitations are not bugs; they're trade-offs. Spring Data JDBC prioritizes simplicity and predictability over magic. If you accept that, you'll be fine. If you try to fight it, you'll end up with a mess.

🎯 Key Takeaway
Spring Data JDBC's simplicity comes with trade-offs. Understand them before committing to the framework.

When to Use Spring Data JDBC vs JPA

Use Spring Data JDBC when: - You want explicit control over SQL. - Your aggregates are small and well-defined. - You don't need lazy loading, caching, or complex inheritance. - You're building microservices where each service owns its data. - Performance predictability is more important than developer convenience.

Use JPA when: - You have complex object graphs with deep relationships. - You need lazy loading to avoid loading entire graphs. - You rely on a second-level cache for performance. - Your team is already proficient with JPA and its quirks. - You need advanced features like entity graphs, inheritance, or auditing.

Use plain JdbcTemplate when: - You need maximum performance and control. - Your queries are highly dynamic or involve complex joins. - You don't want any ORM overhead.

I've seen teams adopt Spring Data JDBC for a new microservice and later regret it because they needed JPA's features. Conversely, I've seen teams use JPA for a simple CRUD service and suffer from complexity. Choose based on your actual requirements, not hype.

Production tip: Start with Spring Data JDBC for new services. If you find yourself fighting it, switch to JPA or JdbcTemplate. It's easier to add complexity than to remove it.

🔥Hybrid Approach
📊 Production Insight
I've seen a team waste months trying to force Spring Data JDBC to do JPA-like things. Don't be that team.
🎯 Key Takeaway
Choose the right tool for the job. Spring Data JDBC is not a JPA replacement; it's an alternative for simpler data access.
● Production incidentPOST-MORTEMseverity: high

The Midnight Outage That Taught Me to Avoid JPA's Auto-Flush

Symptom
Users saw 'Transaction rolled back' errors during end-of-day batch processing. The service became unresponsive for 15 minutes.
Assumption
The team assumed the issue was a deadlock due to concurrent transactions.
Root cause
JPA's auto-flush before queries caused a massive flush of uncommitted entities, leading to a long-running transaction that held locks too long.
Fix
Switched to Spring Data JDBC, which doesn't auto-flush. The batch operation became predictable and no longer caused timeouts.
Key lesson
  • JPA's auto-flush can cause unexpected performance degradation in batch operations.
  • Spring Data JDBC's explicit flush model makes database interactions predictable.
  • Always test batch operations under production-like concurrency.
  • Consider using Spring Data JDBC for write-heavy or batch-oriented services.
  • Don't assume a framework's magic will work for all use cases.
Production debug guideSymptom to Action4 entries
Symptom · 01
Queries returning stale data
Fix
Check if you're using @Version for optimistic locking; Spring Data JDBC does not cache, so stale data usually indicates a missing version check or incorrect aggregate design.
Symptom · 02
Unexpected null for a one-to-many relationship
Fix
Verify that the aggregate root properly includes the collection. Spring Data JDBC requires that all references are part of the aggregate, not separate tables.
Symptom · 03
Slow inserts due to multiple SQL statements
Fix
Enable SQL logging (logging.level.org.springframework.jdbc=DEBUG) to see each statement. Batch inserts are not automatic; consider using JdbcTemplate directly for bulk operations.
Symptom · 04
LazyInitializationException-like errors
Fix
This does not occur in Spring Data JDBC because there's no lazy loading. If you see a null reference, your aggregate boundary is likely wrong—you're accessing a separate aggregate that wasn't loaded.
★ Quick Debug Cheat SheetCommon Spring Data JDBC issues and immediate actions
Query returns fewer rows than expected
Immediate action
Check aggregate root mapping: Spring Data JDBC flattens aggregates into a single table join.
Commands
SELECT * FROM your_aggregate_table WHERE ...
EXPLAIN ANALYZE your_query
Fix now
Ensure your aggregate root includes all columns needed.
Insert fails with key constraint+
Immediate action
Verify that ID generation strategy matches your database (e.g., sequence vs auto-increment).
Commands
SHOW CREATE TABLE your_table;
SELECT last_insert_id();
Fix now
Use @Id with @GeneratedValue(strategy = GenerationType.IDENTITY) for auto-increment.
Nested entity not saved+
Immediate action
Spring Data JDBC only saves aggregate roots. Non-root entities must be persisted separately.
Commands
Check your repository: only root aggregates have repositories.
Verify that nested entities are part of the aggregate (i.e., same table or embedded).
Fix now
Redesign aggregates so that nested entities are either embedded or part of the root's table.
FeatureSpring Data JDBCSpring Data JPA
Lazy LoadingNot supportedSupported via proxies
Persistence ContextNoneFirst-level cache, auto-flush
CachingNoneSecond-level cache available
Aggregate DesignEnforcedOptional (can be ignored)
Custom QueriesPlain SQLJPQL or native SQL
AuditingManualBuilt-in with @CreatedDate etc.
InheritanceNot supportedSupported (single, joined, etc.)
Performance PredictabilityHighVariable (due to caching and lazy loading)
⚙ Quick Reference
4 commands from this guide
FileCommand / CodePurpose
Payment.java@Table("payments")Getting Started with Spring Data JDBC
PaymentWithLineItems.java@Table("payments")Aggregate Design
CustomQueries.javapublic interface PaymentRepository extends CrudRepository {Custom Queries with @Query
OptimisticLocking.java@Table("payments")Transactions and Concurrency Control

Key takeaways

1
Spring Data JDBC offers a simpler, more predictable alternative to JPA for database access, ideal for microservices and CRUD-heavy applications.
2
Aggregate design is crucial; ensure that your aggregates are small and that all child entities are accessed through the root.
3
Be aware of the delete/re-insert pattern for collections and the lack of built-in auditing and lazy loading.
4
Use custom queries carefully, as they bypass aggregate lifecycle management.
5
Choose Spring Data JDBC when you want explicit control and predictability; use JPA for complex object graphs.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
Explain the concept of aggregate root in Spring Data JDBC and how it aff...
Q02SENIOR
How does optimistic locking work in Spring Data JDBC, and what is a comm...
Q03SENIOR
Compare Spring Data JDBC and Spring Data JPA for a microservices archite...
Q01 of 03SENIOR

Explain the concept of aggregate root in Spring Data JDBC and how it affects database operations.

ANSWER
The aggregate root is the only entity that has a repository. All other entities in the aggregate are accessed through it. When you save the root, Spring Data JDBC persists the entire aggregate, deleting and re-inserting child rows. This design ensures consistency but can be inefficient for large collections.
FAQ · 3 QUESTIONS

Frequently Asked Questions

01
Can I use Spring Data JDBC with an existing database schema?
02
Does Spring Data JDBC support pagination?
03
How do I handle many-to-many relationships in Spring Data JDBC?
N
Naren Founder & Principal Engineer

20+ years shipping production Java in banking & fintech. Written from production experience, not tutorials.

Follow
Verified
production tested
July 15, 2026
last updated
2,398
articles · all by Naren
🔥

That's Hibernate & JPA. Mark it forged?

7 min read · try the examples if you haven't

Previous
Spring JDBC with JdbcTemplate: Complete Guide to Database Access
9 / 28 · Hibernate & JPA
Next
Spring JDBC Batch Inserts: Performance Optimization for Bulk Operations