Spring JDBC and Database Access - Complete Notes
A complete guide to JDBC foundations, DataSource and HikariCP, JdbcTemplate, named parameters, row mapping, batching, generated keys, exception translation, transactions, PostgreSQL, security, testing, performance, production operations, and choosing JDBC instead of an ORM.
00. The Spring JDBC mental model
Spring JDBC keeps SQL visible while removing repetitive JDBC plumbing. The application still chooses the SQL, parameters, and object mapping. Spring obtains and releases resources, executes the statement, participates in transactions, and translates low-level failures.
Think of raw JDBC as cooking in a professional kitchen while also opening the building, allocating every tool, washing every pan, and translating every supplier's paperwork. Spring JDBC supplies the managed kitchen. You still write the recipe, which is the SQL, and decide how ingredients become the final object.
Spring JDBC is not an ORM. It does not track entities, generate domain queries, perform dirty checking, or maintain a persistence context. It is a thin, predictable abstraction over JDBC that gives application code direct control over relational operations.
A useful request-to-database path is:
- A controller or message consumer invokes an application service.
- The service establishes the business and transaction boundary.
- A repository expresses one database operation with SQL and parameters.
- A Spring JDBC template obtains the transaction-bound or pooled connection.
- The PostgreSQL JDBC driver sends the protocol messages to PostgreSQL.
- PostgreSQL plans and executes the statement.
- A mapper converts each result row into an application value.
- Spring closes JDBC resources and returns the connection to the pool.
01. JDBC foundations
JDBC is the standard Java API for connecting to tabular data sources, executing SQL, binding parameters, reading results, controlling transactions, and inspecting database metadata.
| JDBC type | Responsibility | Important fact |
|---|---|---|
DataSource |
Supplies connections | Preferred application-level connection factory |
Connection |
Represents a database session | Also owns transaction state |
PreparedStatement |
Represents parameterized SQL | Separates SQL structure from values |
ResultSet |
Cursor over returned rows | Starts before the first row |
SQLException |
Reports JDBC or database failure | Contains SQL state, vendor code, and chained causes |
Raw JDBC usually repeats this workflow for every operation:
String sql = "select id, email from customer where id = ?";
try (Connection connection = dataSource.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setLong(1, customerId);
try (ResultSet result = statement.executeQuery()) {
if (!result.next()) {
return Optional.empty();
}
return Optional.of(
new Customer(result.getLong("id"), result.getString("email"))
);
}
} catch (SQLException failure) {
throw translate(failure);
}
The code is valid, but the business-specific parts are only the SQL, parameter, expected cardinality, and mapping. The rest is infrastructure that must be correct every time.
02. Problems Spring JDBC solves
Spring uses the template and callback pattern. The template controls the stable workflow, while application callbacks supply only the parts that vary.
- Obtains a connection from a configured
DataSource. - Reuses the connection already bound to the current Spring transaction.
- Creates, executes, and closes statements.
- Iterates and closes result sets.
- Binds ordinary Java values to prepared statements.
- Converts result rows through
RowMapperand related callbacks. - Translates checked
SQLExceptionfailures into Spring's exception hierarchy. - Supports updates, generated keys, batches, stored procedures, and streaming callbacks.
Spring cannot make an inefficient query efficient, choose the correct index, infer the intended row cardinality, secure a dynamically concatenated identifier, or decide the right transaction boundary. Those remain design responsibilities.
03. Dependencies and Boot auto-configuration
A Spring Boot JDBC application normally uses the JDBC starter and a database-specific driver.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
The starter brings Spring JDBC and HikariCP under Spring Boot's dependency management. With a
suitable driver and configuration, Boot can create a pooled DataSource,
JdbcTemplate, NamedParameterJdbcTemplate, and JDBC transaction manager.
Defining an application DataSource bean makes Boot's default back off.
Use a supported Spring Boot release line and its dependency management. Override the PostgreSQL driver, HikariCP, or Spring Framework version only for a verified compatibility or security reason, with tests that cover the application.
04. DataSource and connection ownership
javax.sql.DataSource is a connection factory. In a production Boot application, its
implementation is usually a connection pool rather than a factory that creates a new physical
database connection for every call.
A physical connection includes a network socket, authentication, PostgreSQL backend process, and session state. Creating one per query is expensive. A pool maintains a bounded set and lends a logical connection handle to a caller.
getConnection()borrows an available pool entry.- JDBC work uses the underlying physical session.
close()on the proxy returns it to the pool.- The pool resets important connection state before later reuse.
Closing a pooled connection usually means returning it, not closing its network socket. Failing to close it leaks a pool slot. Under load, all callers eventually wait and time out.
Keep the DataSource as a singleton infrastructure bean. Do not cache a
Connection in a repository field. Connections are mutable session resources and may
be bound to a particular thread and transaction.
How a transaction gets the same connection
A Spring JDBC transaction manager obtains a connection, disables auto-commit as needed, and binds
a holder to the current execution thread. JdbcTemplate uses Spring's data-source
utilities, so repository calls inside that transaction obtain the same connection. At completion,
the manager commits or rolls back, resets state, unbinds, and releases the connection.
Code that directly calls dataSource.getConnection() can bypass this coordination.
Prefer the template or DataSourceUtils in infrastructure code that must participate
in Spring-managed transactions.
05. HikariCP connection pooling
HikariCP is Spring Boot's preferred JDBC pool when it is available. Its purpose is bounded, efficient connection reuse, not making the database capable of unlimited concurrency.
| Setting | Meaning | Production reasoning |
|---|---|---|
maximumPoolSize |
Maximum total idle plus in-use connections | Budget against database capacity across every instance |
connectionTimeout |
Maximum wait to borrow a connection | Fail within the request's overall time budget |
maxLifetime |
Maximum lifetime before an idle returned connection retires | Set below infrastructure connection limits |
keepaliveTime |
Interval for validating an idle connection | Useful when infrastructure silently drops idle sessions |
minimumIdle |
Target minimum idle connections | Leaving it unset gives fixed-size behavior recommended by HikariCP |
leakDetectionThreshold |
Logs connections held longer than a threshold | Diagnostic aid, not a permanent substitute for tracing |
spring:
datasource:
url: jdbc:postgresql://db.internal:5432/orders
username: ${DB_USERNAME}
password: ${DB_PASSWORD}
hikari:
pool-name: orders-pool
maximum-pool-size: 12
connection-timeout: 2000
max-lifetime: 1740000
keepalive-time: 120000
jdbc:
template:
query-timeout: 5
These are examples, not universal values. The pool size must be load-tested. Twelve application instances with a maximum of 20 can request 240 database connections, before migrations, administration, background jobs, and other applications are counted.
Pool sizing principles
- A larger pool can increase database contention and context switching.
- Start small, measure connection wait time, database CPU, locks, and query latency.
- Count every replica, worker process, read pool, and deployment overlap.
- Keep application request concurrency bounded relative to downstream capacity.
- A saturated pool is often a symptom of slow SQL or long transactions.
- Do not use pool growth to hide leaked connections or missing query timeouts.
Session state and pool safety
PostgreSQL settings, temporary tables, advisory locks, and session variables may outlive one
statement. A pool resets standard JDBC state, but cannot understand every custom session change.
Prefer transaction-local settings such as SET LOCAL, release advisory locks, and do
not assume the next borrow uses a fresh server session.
06. PostgreSQL configuration and credentials
Boot reads ordinary data-source settings under spring.datasource and Hikari-specific
settings under spring.datasource.hikari.
- The URL commonly has the form
jdbc:postgresql://host:5432/database. - The pgJDBC driver is discovered through Java's service-provider mechanism.
- Boot can infer the driver class from the JDBC URL in normal cases.
- Credentials should come from a secret manager or protected environment injection.
- TLS identity verification is required across untrusted networks.
- Use a least-privilege role specific to the application and environment.
spring.datasource.url=jdbc:postgresql://db.example.net:5432/orders?sslmode=verify-full
spring.datasource.username=${DB_USERNAME}
spring.datasource.password=${DB_PASSWORD}
Property placeholders do not make a value secret by themselves. Prevent credentials from entering source control, command history, exception messages, build artifacts, and logs. Plan rotation and revoke leaked credentials immediately.
Multiple data sources
Multiple databases require explicit bean names, qualifiers, templates, and transaction managers. A transaction managed for one data source does not automatically make work on another atomic. Distributed atomicity needs a suitable distributed transaction system, or more commonly an application pattern such as an outbox with idempotent consumers.
07. Schema ownership and migrations
The database schema is a versioned production contract. Use a migration tool such as Flyway or Liquibase rather than relying on repository code to create tables at runtime.
create table customer (
id bigint generated always as identity primary key,
email text not null,
display_name text not null,
status text not null,
version integer not null default 0,
created_at timestamptz not null default current_timestamp,
constraint customer_email_unique unique (email),
constraint customer_status_check
check (status in ('ACTIVE', 'SUSPENDED'))
);
create index customer_created_at_id_idx
on customer (created_at desc, id desc);
- Let constraints enforce invariants even if Java validates them too.
- Use expand-and-contract migrations for zero-downtime compatibility.
- Test application version N against the schema states seen during rollout.
- Run migrations once per deployment under controlled credentials.
- Give the runtime role only the DML and sequence privileges it needs.
08. JdbcTemplate fundamentals
JdbcTemplate is the central classic Spring JDBC class. Once configured, it is
thread-safe and should be shared. Repository methods provide SQL, bind values, and map results.
@Repository
final class CustomerRepository {
private final JdbcTemplate jdbc;
CustomerRepository(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
List<CustomerSummary> findActive(int limit) {
return jdbc.query("""
select id, email, display_name
from customer
where status = ?
order by id
limit ?
""",
customerSummaryMapper(),
"ACTIVE",
limit
);
}
private static RowMapper<CustomerSummary> customerSummaryMapper() {
return (rs, rowNumber) -> new CustomerSummary(
rs.getLong("id"),
rs.getString("email"),
rs.getString("display_name")
);
}
}
The repository owns persistence language. The service owns business orchestration. Avoid a generic
repository with methods like runSql(String), because it spreads SQL and persistence
semantics throughout the application.
The newer JdbcClient facade
Current Spring Framework versions also provide JdbcClient, a fluent facade that
delegates to JdbcTemplate and NamedParameterJdbcTemplate. It can make
ordinary query and update code more readable. The classic templates remain important because they
expose the complete callback, batch, and lower-level feature set. Choose one consistent repository
style rather than wrapping every Spring JDBC API in another generic abstraction.
Common operations
| Operation | Use | Return shape |
|---|---|---|
query |
Zero to many result rows | List or callback result |
queryForObject |
Exactly one row or scalar | Single mapped value |
queryForList |
Simple scalar list or map rows | List |
update |
Insert, update, or delete | Affected row count |
batchUpdate |
Repeated DML | Update counts |
execute |
Lower-level statement callback | Callback-defined |
09. Query cardinality and null handling
Choose an API whose contract matches the number of rows the query is allowed to return.
Optional<Customer> findById(long id) {
List<Customer> rows = jdbc.query("""
select id, email, display_name, status, version, created_at
from customer
where id = ?
""", CUSTOMER_MAPPER, id);
return DataAccessUtils.optionalResult(rows);
}
queryForObject expects exactly one row. Zero rows result in an
EmptyResultDataAccessException, and multiple rows result in an incorrect-result-size
exception. That is useful when absence means a data invariant was violated. For a normal optional
lookup, express optionality intentionally.
A scalar query can return one row whose value is SQL NULL, or no row at all. Java
primitive getters such as getLong return zero for SQL NULL, so call
wasNull() or use typed getObject for nullable numeric values.
Long managerId = rs.getObject("manager_id", Long.class);
Instant deletedAt = rs.getObject("deleted_at", OffsetDateTime.class) == null
? null
: rs.getObject("deleted_at", OffsetDateTime.class).toInstant();
10. RowMapper and result extraction
A RowMapper<T> maps the current row to one object. Spring advances the result
set and calls it once per row.
- Read columns by stable alias instead of fragile numeric position.
- List columns explicitly instead of using
select *. - Keep mapper code deterministic and free of database calls.
- Use immutable records or values when they match the result shape.
- Create purpose-specific projections instead of partially populated domain objects.
private static final RowMapper<OrderView> ORDER_VIEW_MAPPER = (rs, rowNum) ->
new OrderView(
rs.getLong("order_id"),
rs.getString("customer_email"),
rs.getBigDecimal("total_amount"),
rs.getObject("placed_at", OffsetDateTime.class).toInstant()
);
One-to-many results
A join can return one order row per item. A RowMapper<Order> would then produce
duplicate orders. Use a ResultSetExtractor to aggregate ordered rows, query parent
and children separately in a bounded way, or return a flat reporting projection.
return jdbc.query(sql, rs -> {
Map<Long, MutableOrder> grouped = new LinkedHashMap<>();
while (rs.next()) {
long orderId = rs.getLong("order_id");
MutableOrder order = grouped.computeIfAbsent(orderId, ignored ->
mapOrder(rs)
);
order.addItem(mapItem(rs));
}
return grouped.values().stream().map(MutableOrder::freeze).toList();
});
The SQL should order by the grouping key when the extractor depends on contiguous rows. Bound the result set because aggregation retains objects in memory.
Automatic property mapping
BeanPropertyRowMapper and DataClassRowMapper reduce mapping code for
simple projections. Explicit mappers are safer when conversions, required columns, domain types,
or schema evolution matter. Reflection convenience should not hide an unclear data contract.
11. NamedParameterJdbcTemplate
NamedParameterJdbcTemplate replaces positional question marks with descriptive
parameter names and delegates to a wrapped JdbcTemplate.
List<CustomerSummary> search(Set<String> statuses, Instant createdAfter) {
String sql = """
select id, email, display_name
from customer
where status in (:statuses)
and created_at >= :createdAfter
order by created_at, id
""";
var parameters = new MapSqlParameterSource()
.addValue("statuses", statuses)
.addValue("createdAfter", Timestamp.from(createdAfter));
return namedJdbc.query(sql, parameters, CUSTOMER_SUMMARY_MAPPER);
}
Named parameters reduce swapped-parameter errors and support expanding a collection for an
IN predicate. An empty collection may produce invalid SQL or unwanted semantics, so
define the empty case in Java. Very large lists should use another design, such as a temporary
table, array parameter, staged data, or a joined relation.
Spring parses named SQL into JDBC positional placeholders and prepares the resulting statement. A value still becomes a bound parameter, with the same separation between SQL and data.
12. Updates, row counts, and generated keys
An update's affected-row count is part of its correctness contract. Check it when the operation expects exactly one row.
boolean rename(long id, int expectedVersion, String newName) {
int changed = jdbc.update("""
update customer
set display_name = ?, version = version + 1
where id = ? and version = ?
""", newName, id, expectedVersion);
if (changed > 1) {
throw new IncorrectUpdateSemanticsDataAccessException(
"Customer identity was not unique"
);
}
return changed == 1;
}
Zero rows can mean not found, stale version, or a predicate that no longer matches. Decide whether the application needs to distinguish those outcomes. A second query can distinguish them but introduces another concurrency observation, so design the API carefully.
Generated keys
long insert(CustomerDraft draft) {
var keys = new GeneratedKeyHolder();
jdbc.update(connection -> {
PreparedStatement statement = connection.prepareStatement("""
insert into customer (email, display_name, status)
values (?, ?, ?)
""", new String[] {"id"});
statement.setString(1, draft.email());
statement.setString(2, draft.displayName());
statement.setString(3, "ACTIVE");
return statement;
}, keys);
Number id = Objects.requireNonNull(keys.getKey(), "Database returned no id");
return id.longValue();
}
PostgreSQL also supports INSERT ... RETURNING, which can return one or several
generated or defaulted columns and map them like a query:
Customer insert(CustomerDraft draft) {
return jdbc.queryForObject("""
insert into customer (email, display_name, status)
values (?, ?, 'ACTIVE')
returning id, email, display_name, status, version, created_at
""", CUSTOMER_MAPPER, draft.email(), draft.displayName());
}
RETURNING avoids a follow-up select and captures server defaults from the same
statement. It is PostgreSQL-specific, which is acceptable when the system intentionally targets
PostgreSQL.
13. Batch operations
Batching sends repeated DML work with fewer client-server round trips. It does not automatically make a large operation atomic and it does not remove database work.
int[] insertAll(List<CustomerDraft> drafts) {
String sql = """
insert into customer (email, display_name, status)
values (:email, :displayName, 'ACTIVE')
""";
SqlParameterSource[] batch = drafts.stream()
.map(draft -> new MapSqlParameterSource()
.addValue("email", draft.email())
.addValue("displayName", draft.displayName()))
.toArray(SqlParameterSource[]::new);
return namedJdbc.batchUpdate(sql, batch);
}
- Choose a bounded chunk size through measurement.
- Wrap related batches in one intentional transaction if all-or-nothing is required.
- Inspect returned update counts when individual outcomes matter.
- Specify SQL types for ambiguous null batch values if driver inference is unreliable.
- Do not include
SELECTstatements in a JDBC update batch. - For very large PostgreSQL ingestion, benchmark
COPYagainst batches.
pgJDBC batch rewrite
The pgJDBC reWriteBatchedInserts=true connection property can rewrite compatible
batches into multi-value inserts and reduce round trips. Treat it as a measured driver-level
optimization. Verify generated-key behavior, statement compatibility, failure reporting, and
memory use for the driver version managed by the application.
Huge batches retain parameter data, hold locks and a connection longer, create large transactions and WAL bursts, and make retries expensive. Bounded chunks provide predictable memory and failure recovery.
14. Exception translation
Spring translates checked SQLException failures into the unchecked
DataAccessException hierarchy, which expresses failure categories independently of a
particular database API.
| Exception family | Meaning | Typical response |
|---|---|---|
DuplicateKeyException |
Unique or primary-key conflict | Map known constraint to a domain conflict |
DataIntegrityViolationException |
Broader integrity violation | Inspect known operation context, do not expose raw SQL |
BadSqlGrammarException |
Invalid SQL or missing object | Programming or deployment defect |
QueryTimeoutException |
Statement exceeded timeout | Abort work, observe, retry only if safe |
CannotAcquireLockException |
Lock acquisition failure | Bounded retry for an idempotent transaction |
PessimisticLockingFailureException |
Broader lock or serialization failure | Roll back, diagnose contention, and consider bounded retry |
TransientDataAccessException |
Potentially temporary category | Retry only with policy, budget, and idempotency |
Current Spring Framework versions use JDBC 4 exception subclasses with SQL-state fallback by default. Translation allows repository code to avoid driver-specific catch blocks. It does not mean every database error can be handled generically.
A retry repeats the whole transaction after rollback, not merely the failed SQL line. Use a small attempt limit, exponential backoff with jitter, an overall deadline, and idempotent business semantics. Connection failures can be ambiguous if the server committed but the acknowledgment was lost.
15. Transaction boundaries
A transaction boundary should surround one business operation that must preserve a database invariant. It normally belongs on a public application-service method, not around each repository statement.
@Service
final class TransferService {
private final AccountRepository accounts;
private final OutboxRepository outbox;
TransferService(AccountRepository accounts, OutboxRepository outbox) {
this.accounts = accounts;
this.outbox = outbox;
}
@Transactional
public void transfer(long fromId, long toId, BigDecimal amount) {
int debited = accounts.debitIfEnough(fromId, amount);
if (debited != 1) {
throw new InsufficientFundsException(fromId);
}
accounts.credit(toId, amount);
outbox.append(new TransferCompleted(fromId, toId, amount));
}
}
Spring's default transaction propagation is REQUIRED. The method joins an existing
transaction or creates one. By default, unchecked exceptions and Error trigger
rollback, while checked exceptions do not. Configure rollback rules when the business exception
model requires different behavior.
Proxy behavior
Declarative transactions usually run through a Spring AOP proxy. A call from another bean enters
the proxy and receives transaction advice. Self-invocation, where one method calls another method
on this, bypasses the proxy, so an annotation on the inner method does not create new
transactional behavior in default proxy mode.
Keep transactions short
- Do validation that needs no database access before opening the transaction.
- Do not hold a database transaction while waiting for a user.
- Avoid remote HTTP calls inside a transaction.
- Use database statement and lock timeouts.
- Access rows in a consistent order to reduce deadlocks.
- Move reliable external side effects through an outbox or equivalent pattern.
A transaction occupies a connection and may retain locks, row versions, and database resources. Long transactions reduce pool capacity and can interfere with PostgreSQL vacuum progress.
Programmatic boundaries
TransactionTemplate is useful when code needs an explicit small scope, sequential
transactions, or a commit before a later non-transactional action. Keep the boundary readable and
do not mix manual Connection.commit() with Spring-managed transactions.
16. Query safety and SQL injection
Bind every untrusted value through a prepared-statement parameter. Parameters represent data, while the SQL string defines executable structure.
// Unsafe: attacker input becomes SQL syntax.
String unsafe = "select * from customer where email = '" + email + "'";
// Safe: the driver binds email as a value.
return jdbc.query(
"select id, email from customer where email = ?",
CUSTOMER_MAPPER,
email
);
A placeholder cannot safely represent a table name, column name, direction keyword, or arbitrary
SQL fragment. Map a client value to a closed server-side allowlist, such as
"created" -> "created_at", then concatenate only the trusted constant.
private static final Map<String, String> SORT_COLUMNS = Map.of(
"created", "created_at",
"email", "email"
);
String column = Optional.ofNullable(SORT_COLUMNS.get(requestedSort))
.orElseThrow(InvalidSortException::new);
String direction = ascending ? "asc" : "desc";
String sql = """
select id, email, display_name
from customer
order by %s %s, id %s
limit ?
""".formatted(column, direction, direction);
Prepared statements prevent value injection, but authorization remains separate. A safe query can still disclose another tenant's data if it omits the tenant predicate. Carry trusted tenant identity from authentication and include it in every relevant read and write.
17. Resource management and streaming
Templates close ordinary JDBC statements and result sets, and release connections correctly. Results that escape as a stream or callback-driven cursor have a longer resource lifetime.
- Consume a JDBC-backed
Streamin try-with-resources. - Keep the transaction and connection open until streaming is complete.
- Set an appropriate fetch size and test driver behavior.
- Do not return a lazy stream through a web layer without explicit lifecycle control.
- Prefer bounded pagination for ordinary APIs.
- Cancel or time out abandoned work so the connection returns to the pool.
@Transactional(readOnly = true)
public void exportActiveCustomers(Consumer<CustomerSummary> sink) {
try (Stream<CustomerSummary> rows = jdbc.queryForStream(
"""
select id, email, display_name
from customer
where status = 'ACTIVE'
order by id
""",
CUSTOMER_SUMMARY_MAPPER
)) {
rows.forEach(sink);
}
}
It reduces application materialization but holds a connection and transaction. Slow output can create backpressure all the way to the pool. For large exports, consider an asynchronous job, snapshot strategy, object storage, and bounded chunks.
18. PostgreSQL-specific integration
JDBC covers common relational types, while PostgreSQL offers useful types and SQL features that sometimes require deliberate mapping.
| PostgreSQL type | Java direction | Guidance |
|---|---|---|
bigint |
long or Long |
Use wrapper for nullable values |
numeric |
BigDecimal |
Do not use binary floating point for money |
uuid |
UUID |
Use typed getObject and setObject where supported |
date |
LocalDate |
No time zone or time of day |
timestamptz |
OffsetDateTime or normalized Instant |
Make conversion policy explicit |
jsonb |
JSON string, driver object, or custom mapper | Validate shape and use JSON operators intentionally |
| array | java.sql.Array or supported binding |
Free manually created JDBC arrays when responsible |
namedJdbc.update("""
update customer
set preferences = cast(:preferences as jsonb)
where id = :id
""",
new MapSqlParameterSource()
.addValue("preferences", objectMapper.writeValueAsString(preferences))
.addValue("id", id)
);
Avoid storing data as JSON only to escape schema design. Use jsonb when document-like
attributes genuinely vary, and add expression or GIN indexes for measured access paths.
Atomic upsert
insert into idempotency_record (scope, request_key, request_hash, status)
values (:scope, :key, :hash, 'STARTED')
on conflict (scope, request_key) do nothing
Let a unique constraint arbitrate concurrency. A preceding "does it exist?" query has a race because another transaction can insert between the check and write.
Large values
JDBC supports binary and character streams, but storing large user files directly in frequently accessed tables can inflate backups, replication, and query I/O. Object storage plus database metadata is often a better architecture. When the database is appropriate, stream with explicit size limits and do not materialize unbounded content.
19. Performance engineering
Database performance comes primarily from correct data modeling, selective SQL, useful indexes, bounded work, short transactions, and measured capacity. A template call is rarely the main cost.
Query shape
- Select only needed columns.
- Filter and aggregate in PostgreSQL when it reduces transferred data correctly.
- Use deterministic ordering for pages.
- Avoid one query per row by fetching the required relation in a set-oriented query.
- Use
EXPLAIN (ANALYZE, BUFFERS)in a safe representative environment. - Keep table statistics current and inspect actual versus estimated row counts.
Pagination
Offset pagination is convenient but deep offsets still make PostgreSQL find and discard earlier rows. Keyset pagination continues after the ordered key and is usually more stable for large, changing datasets.
select id, email, created_at
from customer
where (created_at, id) < (:cursorCreatedAt, :cursorId)
order by created_at desc, id desc
limit :limit
The supporting index should follow the filtering and ordering access path. Encode and validate the cursor as an opaque API token. Request one extra row to determine whether a next cursor exists.
Timeout hierarchy
- The incoming request deadline is the outer budget.
- Connection acquisition timeout must leave time for query execution and response work.
- Statement timeout stops unexpectedly expensive SQL.
- Lock timeout limits waiting for contested locks.
- Transaction timeout bounds the complete transactional unit.
A JDBC timeout may rely on driver cancellation and is not a guarantee of instantaneous server-side
termination. PostgreSQL statement_timeout provides a server-enforced safeguard. Set
it per role, transaction, or connection with clear ownership and verify interaction with pooling.
Prepared statement behavior
pgJDBC can switch repeated prepared statements to server-prepared execution based on its
prepareThreshold. This may reduce parse work and enable binary transfer, but generic
versus custom planning can affect data-skewed queries. Do not tune it without measurements from
representative parameters and driver documentation.
20. Production operations and observability
Operate the pool, driver, SQL, and PostgreSQL as one dependency chain. Observe saturation and latency before users experience timeouts.
- Measure active, idle, maximum, pending, and timed-out pool acquisitions.
- Measure query latency by operation name, not raw SQL containing values.
- Track errors by translated category and PostgreSQL SQL state.
- Watch database connections, CPU, I/O, locks, deadlocks, and long transactions.
-
Use slow-query tooling and
pg_stat_statementswhere operational policy permits. - Correlate a request trace with repository operation and database session metadata.
- Alert on trends and exhausted budgets, not only process health.
SQL debug logging and proxy drivers can expose passwords, tokens, personal data, and financial values. Prefer named operation metrics, normalized SQL fingerprints, parameter counts, and carefully redacted diagnostics.
Health checks
Liveness should answer whether the process must restart. It should not fail only because the database is briefly unavailable. Readiness may reflect whether the instance can serve database-dependent traffic, but frequent probes must not create a connection storm or make a dependency outage worse.
Graceful shutdown
Stop accepting new work, allow bounded in-flight requests to finish, stop workers, and close the application context so HikariCP closes physical connections. Deployment grace periods must exceed the intended request drain window but remain bounded.
Connection timeout diagnostic order
- Check pool pending, active, idle, and maximum counts.
- Find long requests, transactions, streams, and leaked handles.
- Inspect slow queries, blocked sessions, deadlocks, and database resource pressure.
- Check network, TLS, authentication, and database connection limits.
- Compare total connection budgets across application replicas.
- Change pool size only after identifying the actual bottleneck.
21. Testing database code
Test at several levels. Pure unit tests cover mapping and service decisions quickly. Integration tests against PostgreSQL verify the SQL, schema, driver, constraints, transactions, and type behavior that mocks cannot prove.
Unit tests
- Keep complex SQL result calculations in testable domain functions when possible.
- Test service rollback behavior through integration tests, not only mock verification.
- Avoid mocking fluent JDBC internals or
ResultSetextensively. - If a mapper has meaningful conversion logic, test it with a small supported fixture.
@JdbcTest slice
Spring Boot's @JdbcTest loads a focused JDBC test context and is transactional by
default. Depending on test configuration, Boot may replace the application data source with an
embedded database. That is fast, but an embedded database does not prove PostgreSQL SQL or type
behavior.
PostgreSQL integration with Testcontainers
@JdbcTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Testcontainers
class CustomerRepositoryTest {
@Container
@ServiceConnection
static PostgreSQLContainer postgres =
new PostgreSQLContainer("postgres:18");
@Autowired JdbcTemplate jdbc;
@Test
void insertReturnsDatabaseGeneratedValues() {
CustomerRepository repository = new CustomerRepository(jdbc);
Customer saved = repository.insert(
new CustomerDraft("reader@example.com", "Reader")
);
assertThat(saved.id()).isPositive();
assertThat(saved.status()).isEqualTo("ACTIVE");
assertThat(saved.createdAt()).isNotNull();
}
}
Use the PostgreSQL major version deployed in production, apply real migrations, and let Boot's
service-connection support provide dynamic connection details when available. Otherwise,
@DynamicPropertySource can publish the container URL, username, and password.
@ServiceConnection requires Spring Boot's spring-boot-testcontainers
test module. The test also needs the current Testcontainers JUnit and PostgreSQL modules plus the
PostgreSQL JDBC driver, preferably under the Boot-managed dependency set.
Isolation strategies
- Transactional rollback is fast for tests running on the test thread.
- Work committed by another thread or server request is not rolled back by the test transaction.
- Truncate owned tables between tests when commit behavior must be tested.
- Use unique business keys to allow safe parallel execution.
- Reset sequences only when exact identifiers are part of the intended contract.
- Never point automated integration tests at a shared production-like database.
High-value database test cases
- Zero, one, and multiple-row cardinality.
- SQL NULL mapping for every nullable type.
- Unique, foreign-key, check, and not-null constraint translation.
- Generated defaults and PostgreSQL
RETURNING. - Rollback after a later statement fails.
- Optimistic update conflict under concurrent versions.
- Batch partial failure and retry semantics.
- Time zone, decimal precision, UUID, JSONB, and array mappings.
- Pagination stability with duplicate sort values.
- Migration compatibility with the application build.
22. When JDBC is preferable to an ORM
Choose the abstraction that makes the important behavior clearest. JDBC favors explicit SQL and result shapes. An ORM favors entity state, relationships, and persistence-context behavior.
| Prefer Spring JDBC when | Consider JPA or another ORM when |
|---|---|
| SQL control is central to performance or correctness | The domain has rich entity relationships and aggregate updates |
| Queries return reporting or API-specific projections | Most work is ordinary entity create, read, update, and delete |
| The application uses PostgreSQL-specific SQL deliberately | Database portability is an actual supported requirement |
| Bulk updates and set-oriented operations dominate | Dirty checking simplifies a stateful transactional use case |
| Predictable queries and no persistence context are desired | Identity mapping and managed entity lifecycle provide value |
| The team is strong in SQL and relational modeling | The team accepts ORM internals and query-plan responsibility |
Both approaches can coexist. A system might use JPA for an aggregate write model and Spring JDBC for reporting projections. Keep transaction ownership and data-source use coherent, and do not hide one abstraction behind misleading repository contracts.
You still decide how rows become values, where aggregate boundaries live, how concurrency is controlled, and how schema evolution affects code. JDBC makes these choices explicit.
23. Production repository design
A good repository exposes domain-relevant persistence operations and keeps SQL close to its mapping and cardinality rules.
@Repository
final class AccountRepository {
private static final String FIND_FOR_UPDATE = """
select id, balance, version
from account
where id = :id
for update
""";
private final NamedParameterJdbcTemplate jdbc;
AccountRepository(NamedParameterJdbcTemplate jdbc) {
this.jdbc = jdbc;
}
Optional<AccountBalance> findForUpdate(long id) {
List<AccountBalance> rows = jdbc.query(
FIND_FOR_UPDATE,
Map.of("id", id),
(rs, rowNum) -> new AccountBalance(
rs.getLong("id"),
rs.getBigDecimal("balance"),
rs.getInt("version")
)
);
return DataAccessUtils.optionalResult(rows);
}
int debitIfEnough(long id, BigDecimal amount) {
return jdbc.update("""
update account
set balance = balance - :amount,
version = version + 1
where id = :id and balance >= :amount
""",
Map.of("id", id, "amount", amount)
);
}
}
Prefer atomic SQL when it expresses the invariant
The conditional debit combines check and update in one statement, so concurrent transactions cannot both pass a stale Java-side balance check. Database constraints should still protect invariants such as non-negative balance where the business rule is universal.
Organizing SQL
- Keep short SQL next to the repository operation.
- Give long statements descriptive constants or dedicated resource files.
- Format and review SQL as first-class code.
- Version SQL changes with application and migration changes.
- Use stable operation names for metrics.
- Do not create a homemade string-concatenation query language.
24. Edge cases to understand
DDL and transactions
PostgreSQL supports transactional DDL for many operations, but migration tools, locks, concurrent index operations, and external effects have special behavior. Keep schema migration ownership separate from request-time repository transactions.
Cancellation and ambiguous outcomes
If a network connection fails after a write was sent, the client may not know whether PostgreSQL committed it. Use idempotency keys, unique constraints, and an operation-status lookup for retryable commands. A transport error does not always mean "nothing happened."
Time zones
Define what every timestamp means. Store instants for events, use timestamptz, and
convert at presentation boundaries. A calendar date or local appointment time is a different
domain type and should not be forced into an instant.
Numeric precision
Use BigDecimal with deliberate scale and rounding for decimal business values.
PostgreSQL numeric can exceed Java decimal expectations, so validate ranges and
preserve the database constraint.
Lost updates
A read-modify-write sequence can overwrite a concurrent change. Use a version predicate, a row lock for a short transaction, or an atomic update expression. Choose based on contention, conflict handling, and the invariant.
Read-only transactions
@Transactional(readOnly = true) communicates intent and may allow optimizations, but
do not treat it as the sole authorization control. Enforce write restrictions with architecture,
database roles, routing, and tests where required.
Dynamic predicates
Build optional filters from a controlled set of SQL fragments and keep values bound. If filter combinations become complex, use a deliberate query builder or specification layer that still preserves parameterization and inspectable SQL.
25. Common mistakes and fixes
| Mistake | Why it fails | Better approach |
|---|---|---|
| Concatenating request values into SQL | Creates injection and quoting defects | Bind values and allowlist identifiers |
Using queryForObject for optional data |
Normal absence becomes an exception path | Express zero-or-one cardinality |
| Ignoring update counts | Not-found and concurrency conflicts look successful | Assert the expected count |
| Caching a connection in a field | Shares mutable session and transaction state | Let the template acquire it per operation |
| Remote calls inside a transaction | Holds locks and pool capacity during network waits | Use short database work plus an outbox |
| Making the pool very large | Moves contention into PostgreSQL | Budget and load-test the whole system |
| Testing PostgreSQL SQL only on H2 | Dialect, types, constraints, and locking differ | Use real PostgreSQL integration tests |
| Mapping joined rows one by one | Duplicates parent objects | Aggregate with a result extractor or projection |
Using select * |
Couples mapping to unintended schema changes | Select and alias the contract explicitly |
| Retrying every data-access exception | Repeats permanent or non-idempotent failures | Retry a narrow transient category with a budget |
Assuming self-invoked @Transactional works |
The default proxy is bypassed | Move the boundary to an externally invoked service |
| No statement timeout | Runaway work can monopolize resources | Set layered, tested time budgets |
26. Security checklist
- Use prepared parameters for every untrusted value.
- Allowlist dynamic identifiers, operators, and directions.
- Authorize at the service boundary and include tenant scope in SQL.
- Use separate least-privilege runtime and migration roles.
- Require verified TLS across untrusted networks.
- Keep credentials out of source control, logs, and diagnostics.
- Rotate secrets and use short-lived credentials when the platform supports them.
- Limit returned columns and redact sensitive values.
- Enforce critical invariants with constraints.
- Bound query cost, page size, batch size, and timeout.
- Patch the JDK, Spring, HikariCP, and pgJDBC through supported release lines.
- Audit privileged operations with identities and safe metadata.
27. Interview questions and answers
What problems does JdbcTemplate solve?
It controls repetitive JDBC resource acquisition, statement execution, result-set iteration, cleanup, transaction participation, and exception translation. Application code retains control of SQL, binding, cardinality, and result mapping.
Is JdbcTemplate thread-safe?
A fully configured JdbcTemplate is thread-safe and normally shared as a singleton. It
does not store one mutable connection per caller. It obtains the appropriate connection for each
operation, including a transaction-bound connection.
DataSource versus DriverManager?
Both can obtain JDBC connections, but DataSource is the preferred application
abstraction. It supports container configuration, pooling, and indirection without embedding
driver connection logic in repository code.
What happens when a pooled connection is closed?
Application code closes a proxy handle. The pool resets important state and makes the underlying physical connection available for reuse. Failing to close the handle leaks pool capacity.
JdbcTemplate versus NamedParameterJdbcTemplate?
JdbcTemplate uses positional JDBC placeholders.
NamedParameterJdbcTemplate rewrites descriptive named placeholders into positional
placeholders and is clearer for many parameters or collections.
What does RowMapper do?
It maps the current result-set row into one object. Spring moves the cursor and calls the mapper
for every row. One-to-many aggregation usually needs a ResultSetExtractor or a
different query shape.
How does Spring translate SQLExceptions?
Spring's SQLExceptionTranslator examines JDBC exception subclasses, SQL state, and
configured vendor details to produce an unchecked DataAccessException subtype. This
gives callers useful categories without depending directly on a driver.
Where should @Transactional be placed?
Usually on a public application-service method that represents one business operation. Putting it on every repository method creates statement-sized transactions and cannot protect a multi-step invariant.
Why can self-invocation break @Transactional?
In default proxy mode, transaction advice runs when a call enters through the proxy. A method calling another annotated method on the same instance does not cross that proxy.
How do prepared statements prevent SQL injection?
The SQL structure and parameter values travel through separate APIs. The database interprets the value as data, not executable SQL syntax. Identifiers cannot be parameters and must come from trusted allowlists.
How do you size HikariCP?
Start with a small bounded pool, account for every application replica and database consumer, then load-test while observing acquisition wait, query latency, database CPU, I/O, and locks. More connections can reduce performance by increasing database contention.
How do you retrieve generated keys?
Use a KeyHolder with a prepared-statement creator for JDBC-generated keys, or use
PostgreSQL INSERT ... RETURNING and map the returned row. Verify exactly which
columns and cardinality the operation guarantees.
What is the benefit of batching?
Batching reduces round trips for repeated DML. It does not remove server work, guarantee atomicity, or justify unbounded input. Use bounded chunks and an explicit transaction policy.
Why test with PostgreSQL instead of only an embedded database?
SQL dialect, type mapping, constraints, query planning, isolation, locking, generated values, and error states differ. A real PostgreSQL integration test proves the production-relevant contract.
When is Spring JDBC better than JPA?
It is often better when explicit SQL, predictable query shape, reporting projections, bulk operations, or PostgreSQL-specific features dominate. JPA can be stronger when managed entity lifecycle and relationship persistence provide real value.
Why is a huge connection pool dangerous?
It permits more concurrent database work, which can increase CPU scheduling, memory use, lock contention, and latency. It can also exceed PostgreSQL's total connection budget during scaling or rolling deployments.
How should deadlocks be handled?
Keep transactions short, access resources in consistent order, inspect the conflicting SQL, and fix the design where possible. A bounded retry of the complete idempotent transaction can handle unavoidable transient deadlock victims.
What is the N+1 query problem in JDBC?
It occurs when code runs one query for a collection and then one additional query per row. JDBC does not create it automatically, but repository loops can. Replace it with a set-oriented join, batched lookup, or bounded two-query assembly.
How do transaction and connection pools interact?
A local JDBC transaction borrows and retains one connection until completion. Therefore slow or blocked transactions reduce available pool capacity even when they execute little Java code.
Why are unique constraints still needed after validation?
Two concurrent requests can both pass an application-side existence check. The unique constraint arbitrates at the database serialization point. Translate its known violation into the domain outcome.
28. Practice exercises
- Build a customer repository with create, optional find, update, and delete row-count checks.
- Map a joined order and items result with a
ResultSetExtractor. - Implement allowlisted sorting and keyset pagination with duplicate timestamps.
- Add PostgreSQL
RETURNINGfor identity, defaults, and version. - Compare individual inserts, JDBC batching, rewritten batching, and PostgreSQL COPY.
- Write a transfer service whose invariant is enforced atomically and transactionally.
- Produce a deadlock in a test, inspect it, then enforce consistent lock order.
- Test unique-constraint translation and decide the public domain error.
- Use Testcontainers and real migrations for JSONB, UUID, and time-zone mapping tests.
- Load-test different pool sizes and explain the observed throughput and latency.
- Design safe retry behavior for an ambiguous connection failure after a write.
- Add pool and repository-operation metrics without logging sensitive values.
29. One-screen cheat sheet
- Spring manages JDBC workflow; you own SQL, parameters, cardinality, and mapping.
- Use one pooled
DataSourceand shared template instances. - Close every resource you own, including streams and pooled connection handles.
- Bind values. Allowlist identifiers. Never concatenate untrusted SQL fragments.
- Use explicit columns, aliases, immutable projections, and correct null mapping.
- Check update counts and let constraints arbitrate concurrent invariants.
- Use bounded batches and measure pgJDBC-specific optimizations.
- Put transactions around business operations and keep them short.
- Retry only transient, idempotent operations within a strict budget.
- Size HikariCP across all replicas and monitor pending acquisitions.
- Optimize schema, SQL, indexes, and result size before pool size.
- Test PostgreSQL behavior with PostgreSQL and real migrations.
- Choose JDBC when explicit relational control is the simpler design.
30. Official references
- Spring Framework JDBC core and exception handling
- Spring Framework JDBC batch operations
- Spring Framework JDBC access approaches
- Spring Framework declarative transactions
- Spring Boot SQL database support
- Spring Boot Testcontainers service connections
- Java SE JDBC API
- Java JDBC prepared statements
- pgJDBC connection and driver properties
- Testcontainers PostgreSQL module
- PostgreSQL RETURNING
- PostgreSQL transaction isolation
- PostgreSQL statement and lock timeouts
- HikariCP configuration reference