Introduction

Spring Framework 6.1 introduced JdbcClient, a modern and fluent API for database access that simplifies working with JDBC. If you’ve been using JdbcTemplate for years, JdbcClient will feel familiar yet refreshingly easier to work with.

This guide will walk you through everything you need to know about JdbcClient, from basic queries to advanced features.

All code examples in this guide are available in the accompanying GitHub repository: https://github.com/theautonomy/jdbcclient-example.git

Why JdbcClient?

Traditional JdbcTemplate has served us well, but JdbcClient offers several improvements:

  • Fluent API: Chain method calls for more readable code

  • Simplified parameter binding: Named parameters without the verbose MapSqlParameterSource

  • Better type inference: More intuitive result mapping

  • Modern Java support: Takes advantage of recent Java features

Getting Started

Dependencies

First, add Spring JDBC to your project. For Maven:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-jdbc</artifactId>
    <version>3.5.6</version>
</dependency>

For Gradle:

implementation 'org.springframework.boot:spring-boot-starter-jdbc:3.5.6'

Basic Configuration

Spring Boot auto-configures JdbcClient for you. Simply inject it into your components:

import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;

@Repository
public class CustomerRepository {

    private final JdbcClient jdbcClient;

    public CustomerRepository(JdbcClient jdbcClient) {
        this.jdbcClient = jdbcClient;
    }
}
If you’re not using Spring Boot, create a JdbcClient bean manually using JdbcClient.create(dataSource).

Basic Operations

Simple Queries

Let’s start with the most common operation: querying data.

public class CustomerRepository {

    private final JdbcClient jdbcClient;

    public CustomerRepository(JdbcClient jdbcClient) {
        this.jdbcClient = jdbcClient;
    }

    public List<Customer> findAll() {
        return jdbcClient.sql("SELECT id, name, email FROM customers")
                .query(Customer.class)
                .list();
    }

    public Optional<Customer> findById(Long id) {
        return jdbcClient.sql("SELECT id, name, email FROM customers WHERE id = :id")
                .param("id", id)
                .query(Customer.class)
                .optional();
    }
}

Notice how clean this is compared to JdbcTemplate. No RowMapper boilerplate needed for simple cases!

Parameter Binding

JdbcClient makes parameter binding straightforward with named parameters:

public List<Customer> findByNameAndStatus(String name, String status) {
    return jdbcClient.sql("""
            SELECT id, name, email, status
            FROM customers
            WHERE name LIKE :name AND status = :status
            """)
            .param("name", "%" + name + "%")
            .param("status", status)
            .query(Customer.class)
            .list();
}

You can also use a Map for multiple parameters:

public List<Customer> findByMultipleCriteria(Map<String, Object> params) {
    return jdbcClient.sql("""
            SELECT id, name, email
            FROM customers
            WHERE status = :status
            AND created_date > :createdAfter
            """)
            .params(params)
            .query(Customer.class)
            .list();
}

Insert Operations

Inserting data is just as elegant:

public void createCustomer(Customer customer) {
    jdbcClient.sql("""
            INSERT INTO customers (name, email, status)
            VALUES (:name, :email, :status)
            """)
            .param("name", customer.getName())
            .param("email", customer.getEmail())
            .param("status", customer.getStatus())
            .update();
}
The update() method returns the number of rows affected.

Retrieving Generated Keys

When inserting records with auto-generated IDs:

public Long createCustomerAndReturnId(Customer customer) {
    KeyHolder keyHolder = new GeneratedKeyHolder();

    jdbcClient.sql("""
            INSERT INTO customers (name, email, status)
            VALUES (:name, :email, :status)
            """)
            .param("name", customer.getName())
            .param("email", customer.getEmail())
            .param("status", customer.getStatus())
            .update(keyHolder);

    Number key = (Number)keyHolder.getKeys().get("ID");
    return key.longValue();
}

Update and Delete

Updates and deletes follow the same pattern:

public int updateCustomerEmail(Long id, String newEmail) {
    return jdbcClient.sql("""
            UPDATE customers
            SET email = :email
            WHERE id = :id
            """)
            .param("email", newEmail)
            .param("id", id)
            .update();
}

public int deleteCustomer(Long id) {
    return jdbcClient.sql("DELETE FROM customers WHERE id = :id")
            .param("id", id)
            .update();
}

Advanced Features

Custom Row Mapping

For complex mapping scenarios, use a custom RowMapper:

public List<CustomerDTO> findCustomersWithOrders() {
    return jdbcClient.sql("""
            SELECT c.id, c.name, c.email, COUNT(o.id) as order_count
            FROM customers c
            LEFT JOIN orders o ON c.id = o.customer_id
            GROUP BY c.id, c.name, c.email
            """)
            .query((rs, rowNum) -> new CustomerDTO(
                    rs.getLong("id"),
                    rs.getString("name"),
                    rs.getString("email"),
                    rs.getInt("order_count")
            ))
            .list();
}

Working with Single Values

Querying for a single scalar value:

public Long countActiveCustomers() {
    return jdbcClient.sql("SELECT COUNT(*) FROM customers WHERE status = :status")
            .param("status", "ACTIVE")
            .query(Long.class)
            .single();
}

public Optional<String> findCustomerEmail(Long id) {
    return jdbcClient.sql("SELECT email FROM customers WHERE id = :id")
            .param("id", id)
            .query(String.class)
            .optional();
}

Transaction Management

JdbcClient works seamlessly with Spring’s transaction management:

import org.springframework.transaction.annotation.Transactional;

@Service
public class OrderService {

    private final JdbcClient jdbcClient;

    public OrderService(JdbcClient jdbcClient) {
        this.jdbcClient = jdbcClient;
    }

    @Transactional
    public void processOrder(Order order) {
        // Insert order
        KeyHolder keyHolder = new GeneratedKeyHolder();

        jdbcClient.sql("""
            INSERT INTO orders (customer_id, status, total_amount)
            VALUES (:customerId, :status, :totalAmount)
            """)
            .param("customerId", order.getCustomerId())
            .param("status", order.getStatus())
            .param("totalAmount", order.getTotalAmount())
            .update(keyHolder);

        Number key = (Number) keyHolder.getKeys().get("ID");
        Long orderId = key.longValue();

        // Insert order items
        for (OrderItem item : order.getItems()) {
            jdbcClient.sql("""
                    INSERT INTO order_items (order_id, product_id, quantity, price)
                    VALUES (:orderId, :productId, :quantity, :price)
                    """)
                    .param("orderId", orderId)
                    .param("productId", item.getProductId())
                    .param("quantity", item.getQuantity())
                    .param("price", item.getPrice())
                    .update();
        }

        // Update inventory
        jdbcClient.sql("""
                UPDATE products
                SET stock = stock - :quantity
                WHERE id = :productId
                """)
                .param("quantity", order.getTotalQuantity())
                .param("productId", order.getProductId())
                .update();
    }
}

Best Practices

Use Text Blocks for SQL

Java text blocks make SQL queries more readable:

// Good - using text blocks
public List<Customer> searchCustomers(String criteria) {
    return jdbcClient.sql("""
            SELECT c.id, c.name, c.email, c.status
            FROM customers c
            WHERE c.name LIKE :criteria
               OR c.email LIKE :criteria
            ORDER BY c.created_at DESC
            """)
            .param("criteria", "%" + criteria + "%")
            .query(Customer.class)
            .list();
}

// Avoid - concatenated strings
public List<Customer> searchCustomersOldWay(String criteria) {
    return jdbcClient.sql("SELECT c.id, c.name, c.email, c.status " +
            "FROM customers c " +
            "WHERE c.name LIKE :criteria OR c.email LIKE :criteria " +
            "ORDER BY c.created_at DESC")
            .param("criteria", "%" + criteria + "%")
            .query(Customer.class)
            .list();
}

Handle Empty Results Gracefully

Always consider that queries might return no results:

public Customer getCustomerById(Long id) {
    return jdbcClient.sql("SELECT id, name, email FROM customers WHERE id = :id")
            .param("id", id)
            .query(Customer.class)
            .optional()
            .orElseThrow(() -> new CustomerNotFoundException(id));
}

Use Records for DTOs

Java records are perfect for query results:

public record CustomerSummary(
    Long id,
    String name,
    String email,
    int orderCount,
    BigDecimal totalSpent
) {}

public List<CustomerSummary> getCustomerSummaries() {
    return jdbcClient.sql("""
            SELECT
                c.id,
                c.name,
                c.email,
                COUNT(o.id) as orderCount,
                COALESCE(SUM(o.total_amount), 0) as totalSpent
            FROM customers c
            LEFT JOIN orders o ON c.id = o.customer_id
            GROUP BY c.id, c.name, c.email
            """)
            .query(CustomerSummary.class)
            .list();
}

Keep SQL in Repository Layer

Encapsulate database access in repository classes:

@Repository
public class CustomerRepository {

    private final JdbcClient jdbcClient;

    public CustomerRepository(JdbcClient jdbcClient) {
        this.jdbcClient = jdbcClient;
    }

    public List<Customer> findAll() { /* ... */ }
    public Optional<Customer> findById(Long id) { /* ... */ }
    public void save(Customer customer) { /* ... */ }
    public void update(Customer customer) { /* ... */ }
    public void delete(Long id) { /* ... */ }
}

Migration from JdbcTemplate

If you’re migrating from JdbcTemplate, here’s a comparison:

Table 1. JdbcTemplate Implementation
JdbcTemplate
List<Customer> customers =
    jdbcTemplate.query(
        "SELECT * FROM customers WHERE status = ?",
        new Object[]{"ACTIVE"},
        new BeanPropertyRowMapper<>(Customer.class)
    );
Customer customer =
    jdbcTemplate.queryForObject(
        "SELECT * FROM customers WHERE id = ?",
        new Object[]{id},
        new BeanPropertyRowMapper<>(Customer.class)
    );
Table 2. JdbcClient Implementation
JdbcClient
List<Customer> customers =
    jdbcClient.sql("SELECT * FROM customers WHERE status = :status")
        .param("status", "ACTIVE")
        .query(Customer.class)
        .list();
Customer customer =
    jdbcClient.sql("SELECT * FROM customers WHERE id = :id")
        .param("id", id)
        .query(Customer.class)
        .single();
JdbcClient doesn’t replace JdbcTemplate. Both APIs coexist in Spring Framework, and you can use whichever fits your needs better.

Common Pitfalls

Forgetting to Call Terminal Operations

// Wrong - query never executes!
jdbcClient.sql("SELECT * FROM customers")
    .param("status", "ACTIVE");

// Correct - must call a terminal operation
List<Customer> customers = jdbcClient.sql("SELECT * FROM customers")
    .param("status", "ACTIVE")
    .query(Customer.class)
    .list();

Mixing Positional and Named Parameters

// Wrong - can't mix ? and :name
jdbcClient.sql("SELECT * FROM customers WHERE id = ? AND status = :status")
    .param("status", "ACTIVE")
    .query(Customer.class)
    .list();

// Correct - use named parameters consistently
jdbcClient.sql("SELECT * FROM customers WHERE id = :id AND status = :status")
    .param("id", 123L)
    .param("status", "ACTIVE")
    .query(Customer.class)
    .list();

Not Handling Null Values

// Good - handle potential nulls
public void updateCustomerPhone(Long id, String phone) {
    jdbcClient.sql("UPDATE customers SET phone = :phone WHERE id = :id")
            .param("id", id)
            .param("phone", phone) // JdbcClient handles null properly
            .update();
}

Conclusion

JdbcClient brings a modern, fluent API to Spring JDBC that makes database access code cleaner and more maintainable. Its main advantages are:

  • Simplified syntax with method chaining

  • Better handling of named parameters

  • Reduced boilerplate code

  • Seamless integration with existing Spring features

Whether you’re starting a new project or maintaining existing code, JdbcClient is worth considering for your database access layer. It provides all the power of JdbcTemplate with a more developer-friendly API.

Further Reading

About the Author

Wei Li

Wei Li is a passionate software developer and technical writer specializing in Java and Spring Framework. With years of experience in building enterprise applications, Wei Li enjoys sharing knowledge through detailed tutorials and practical guides.

For questions or feedback, feel free to reach out at weili.mail@gmail.com.