What is JDBC — and Why Should a JPA Developer Learn It?
JDBC (Java Database Connectivity) is the standard Java API for
communicating with relational databases. It's a set of interfaces
in java.sql and javax.sql; database
vendors supply the concrete implementations as drivers. Your code
uses the same API whether the database is PostgreSQL, MySQL, or
Oracle.
Every Java database abstraction — Hibernate, Spring Data JPA, Spring JDBC, MyBatis, jOOQ — translates your high-level operations down to JDBC at the bottom. When JPA generates an unexpected query, when Hibernate throws a cryptic exception, when a query is inexplicably slow, the investigation always ends at actual SQL sent over a JDBC connection. Understanding JDBC is what lets you debug those situations instead of guessing.
// The full stack for a typical Spring Boot + JPA application
Your code (@Repository, @Query, findByEmail)
│
▼
Spring Data JPA ← generates the JPQL / method query
│
▼
Hibernate ORM ← translates JPQL to SQL
│
▼
JDBC API ← PreparedStatement, ResultSet
│
▼
JDBC Driver ← vendor-specific (e.g. pgjdbc for PostgreSQL)
│
▼
Database ← executes the SQL
| JDBC Interface | Package | Purpose |
|---|---|---|
DataSource | javax.sql | Factory for connections — the entry point in any real application |
Connection | java.sql | One session with the database; manages transactions |
PreparedStatement | java.sql | Parameterized SQL statement — the only type you should use for queries with parameters |
ResultSet | java.sql | Cursor over query results, row by row |
Statement | java.sql | Unparameterized statement — DDL migrations, never DML with user data |
Setup: Driver and DataSource
Driver dependency
<!-- PostgreSQL (preferred for new projects) -->
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<!-- managed by Spring Boot BOM; no version needed in Boot projects -->
</dependency>
<!-- MySQL -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
</dependency>
<!-- H2 for tests — scope=test keeps it off the production classpath -->
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
DataSource vs DriverManager
DriverManager is not for production — understand the differenceDriverManager.getConnection() creates a brand new
physical TCP connection to the database on every call. This is
the mechanism that existed in JDBC 1.0 (1997), and it is the
correct way to understand that a connection is a physical
resource. It is not the correct way to run a server under load.
A PostgreSQL server with default settings allows around 100
simultaneous connections; a Spring Boot application handling
concurrent requests would exhaust that instantly without a pool.
DataSource is the interface that connection pools
implement — your code gets a logical connection from the pool,
uses it, and returns it; the pool manages the physical TCP
connections underneath. In any production Spring Boot app,
you never call DriverManager.
// What DriverManager looks like under the hood — educational only
// Creates a new TCP connection every call. Never do this in a server.
Connection conn = DriverManager.getConnection(
"jdbc:postgresql://localhost:5432/shop", "app", "secret");
// What you actually use in production — DataSource from HikariCP
// Spring Boot auto-configures this from application.properties
HikariConfig config = new HikariConfig();
config.setJdbcUrl("jdbc:postgresql://localhost:5432/shop");
config.setUsername("app");
config.setPassword("${DB_PASSWORD}");
config.setMaximumPoolSize(20);
DataSource dataSource = new HikariDataSource(config);
// Pool sizing and tuning covered in Connection Pooling
# Spring Boot: configure the DataSource from properties — nothing else needed
spring.datasource.url=jdbc:postgresql://localhost:5432/shop
spring.datasource.username=app
spring.datasource.password=${DB_PASSWORD}
# HikariCP is the default pool in Spring Boot
spring.datasource.hikari.maximum-pool-size=20
spring.datasource.hikari.minimum-idle=5
spring.datasource.hikari.connection-timeout=30000
JDBC URL formats
// Format: jdbc:subprotocol://host:port/database[?options]
"jdbc:postgresql://localhost:5432/shop"
"jdbc:postgresql://prod-db.internal:5432/shop?ssl=true&sslmode=require"
"jdbc:mysql://localhost:3306/shop"
"jdbc:mysql://localhost:3306/shop?useSSL=true&serverTimezone=UTC"
"jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1" // in-memory, kept alive for test duration
"jdbc:h2:file:./data/shop" // file-based H2
PreparedStatement: the Only Correct Way to Send Parameters
A PreparedStatement separates the SQL structure from
the parameter values. The SQL is sent once; the database compiles
it once; subsequent calls only send the values. This prevents SQL
injection (the values can never change the structure of the query)
and allows the database to reuse the execution plan.
SELECT — reading data
public class OrderRepository {
private final DataSource dataSource;
public OrderRepository(DataSource dataSource) {
this.dataSource = dataSource; // constructor injection — never field injection
}
public Optional<Order> findById(long id) {
String sql = """
SELECT o.id, o.status, o.created_at, c.email AS customer_email
FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE o.id = ?
""";
try (Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setLong(1, id); // parameter index starts at 1
try (ResultSet rs = ps.executeQuery()) {
if (rs.next()) {
return Optional.of(mapRow(rs));
}
return Optional.empty();
}
} catch (SQLException e) {
throw new DataAccessException("Failed to find order: " + id, e);
}
}
public List<Order> findByStatus(String status, int limit) {
String sql = """
SELECT o.id, o.status, o.created_at, c.email AS customer_email
FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE o.status = ?
ORDER BY o.created_at DESC
LIMIT ?
""";
try (Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setString(1, status);
ps.setInt(2, limit);
try (ResultSet rs = ps.executeQuery()) {
List<Order> orders = new ArrayList<>();
while (rs.next()) {
orders.add(mapRow(rs));
}
return orders;
}
} catch (SQLException e) {
throw new DataAccessException("Failed to find orders by status", e);
}
}
private Order mapRow(ResultSet rs) throws SQLException {
return new Order(
rs.getLong("id"),
rs.getString("status"),
rs.getString("customer_email"),
rs.getObject("created_at", OffsetDateTime.class) // see Section 4
);
}
}
INSERT with generated key retrieval
public long save(CreateOrderRequest request) {
String sql = "INSERT INTO orders (customer_id, status) VALUES (?, ?)";
try (Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) {
ps.setLong(1, request.customerId());
ps.setString(2, "PENDING");
ps.executeUpdate();
try (ResultSet keys = ps.getGeneratedKeys()) {
if (keys.next()) {
return keys.getLong(1); // the auto-generated ID
}
throw new DataAccessException("No key returned after insert", null);
}
} catch (SQLException e) {
throw new DataAccessException("Failed to save order", e);
}
}
UPDATE and DELETE — always check affected rows
public void updateStatus(long orderId, String newStatus) {
String sql = "UPDATE orders SET status = ? WHERE id = ?";
try (Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setString(1, newStatus);
ps.setLong(2, orderId);
int affected = ps.executeUpdate();
if (affected == 0) {
throw new OrderNotFoundException(orderId);
// executeUpdate() returning 0 means the WHERE clause matched nothing.
// Silently ignoring this is how "updates that silently do nothing" happen.
}
} catch (SQLException e) {
throw new DataAccessException("Failed to update order status", e);
}
}
public void delete(long orderId) {
String sql = "DELETE FROM orders WHERE id = ?";
try (Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setLong(1, orderId);
ps.executeUpdate(); // returns the count; check it if idempotence matters
} catch (SQLException e) {
throw new DataAccessException("Failed to delete order", e);
}
}
Batch INSERT — when performance matters
// Inserting 10,000 order items one by one: 10,000 round trips to the database.
// Batch insert: one round trip per batch. Dramatically faster under load.
public void saveItems(long orderId, List<OrderItemRequest> items) {
String sql = """
INSERT INTO order_items (order_id, product_id, quantity, unit_price)
VALUES (?, ?, ?, ?)
""";
try (Connection conn = dataSource.getConnection()) {
conn.setAutoCommit(false);
try (PreparedStatement ps = conn.prepareStatement(sql)) {
int batchCount = 0;
for (OrderItemRequest item : items) {
ps.setLong(1, orderId);
ps.setLong(2, item.productId());
ps.setInt(3, item.quantity());
ps.setBigDecimal(4, item.unitPrice());
ps.addBatch();
if (++batchCount % 500 == 0) {
ps.executeBatch(); // flush every 500 rows — avoids unbounded memory growth
}
}
ps.executeBatch(); // flush the remaining rows
conn.commit();
} catch (SQLException e) {
conn.rollback();
throw new DataAccessException("Batch insert failed", e);
}
} catch (SQLException e) {
throw new DataAccessException("Failed to get connection", e);
}
}
Transaction Management in JDBC
By default, every statement in JDBC auto-commits immediately. To group multiple statements into a single atomic operation, disable auto-commit, execute the statements, then commit or roll back.
public class OrderFulfillmentService {
private final DataSource dataSource;
public OrderFulfillmentService(DataSource dataSource) {
this.dataSource = dataSource;
}
// Place an order: debit stock and create the order atomically
public long placeOrder(long customerId, long productId, int quantity) {
try (Connection conn = dataSource.getConnection()) {
conn.setAutoCommit(false);
try {
// Step 1: check and debit stock atomically
String debitSql = """
UPDATE products
SET stock = stock - ?
WHERE id = ? AND stock >= ?
""";
try (PreparedStatement ps = conn.prepareStatement(debitSql)) {
ps.setInt(1, quantity);
ps.setLong(2, productId);
ps.setInt(3, quantity);
if (ps.executeUpdate() == 0) {
throw new InsufficientStockException(productId, quantity);
}
}
// Step 2: create the order
long orderId;
String orderSql = "INSERT INTO orders (customer_id, status) VALUES (?, 'PENDING')";
try (PreparedStatement ps = conn.prepareStatement(orderSql, Statement.RETURN_GENERATED_KEYS)) {
ps.setLong(1, customerId);
ps.executeUpdate();
try (ResultSet keys = ps.getGeneratedKeys()) {
keys.next();
orderId = keys.getLong(1);
}
}
conn.commit();
return orderId;
} catch (Exception e) {
conn.rollback();
throw e; // re-throw after rollback — never swallow
}
} catch (SQLException e) {
throw new DataAccessException("Failed to place order", e);
}
// try-with-resources on Connection resets autoCommit and returns it to pool
}
}
A connection pool reuses connections. If your code leaves
autoCommit=false and an uncommitted transaction
when the connection is returned to the pool, the next caller
that borrows it inherits that dirty state. HikariCP's default
configuration validates and resets connection state before
reuse, but relying on that is fragile. The pattern above — using
try-with-resources on the Connection — is the
correct approach: the connection's close() returns
it to the pool, and HikariCP's health check catches any leftover
open transactions.
Date and Time: The Part Everyone Gets Wrong
JDBC's legacy date/time API (java.sql.Date,
java.sql.Timestamp) predates java.time
and has well-known timezone conversion bugs. Since JDBC 4.2, the
correct approach is to use java.time types directly
via setObject() and getObject(Class).
// WRONG — legacy types with silent timezone conversion
ps.setTimestamp(1, Timestamp.from(instant));
// Timestamp.from() uses the JVM default timezone.
// If the JVM and the database have different default timezones,
// the stored value will be wrong by the offset difference.
Timestamp ts = rs.getTimestamp("created_at");
// Also uses the JVM timezone. If the server moved or the JVM default changed,
// every historical timestamp reads as a different moment.
// CORRECT — java.time types directly via JDBC 4.2
// WRITING:
ps.setObject(1, OffsetDateTime.now(ZoneOffset.UTC)); // always UTC at the boundary
ps.setObject(2, LocalDate.of(2026, 7, 1)); // date-only, no timezone
// READING:
OffsetDateTime createdAt = rs.getObject("created_at", OffsetDateTime.class);
LocalDate deliveryDate = rs.getObject("delivery_date", LocalDate.class);
// Handle nullable timestamps correctly:
OffsetDateTime deletedAt = rs.getObject("deleted_at", OffsetDateTime.class);
// getObject() returns null when the column is NULL — no wasNull() check needed
java.time type to use and whenOffsetDateTime for timestamps stored as
TIMESTAMPTZ (with time zone) — this is the correct
type for created_at, updated_at, and
any event timestamp. LocalDate for date-only
columns (DATE). LocalTime for time-only
columns (TIME). LocalDateTime for
bare TIMESTAMP WITHOUT TIME ZONE columns — but as
covered in SQL Basics, you should prefer
TIMESTAMPTZ, making OffsetDateTime
the type to reach for by default.
Spring JdbcTemplate — JDBC Without the Boilerplate
Raw JDBC requires the same try-with-resources, connection retrieval,
and exception handling on every method. Spring's
JdbcTemplate removes that repetition while keeping
full control over the SQL — a significant step up from raw JDBC
without the full ORM abstraction of JPA.
<!-- Spring Boot auto-configures JdbcTemplate when this is on the classpath -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
@Repository
public class OrderRepository {
private final JdbcTemplate jdbc;
public OrderRepository(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
// Query for a list — JdbcTemplate handles connection, PreparedStatement, ResultSet
public List<Order> findByStatus(String status) {
String sql = """
SELECT o.id, o.status, o.created_at, c.email AS customer_email
FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE o.status = ?
ORDER BY o.created_at DESC
""";
return jdbc.query(sql, this::mapRow, status);
}
// Query for a single row — throws EmptyResultDataAccessException if nothing found
public Optional<Order> findById(long id) {
try {
Order order = jdbc.queryForObject(
"SELECT o.id, o.status, o.created_at, c.email AS customer_email FROM orders o JOIN customers c ON c.id = o.customer_id WHERE o.id = ?",
this::mapRow,
id
);
return Optional.ofNullable(order);
} catch (EmptyResultDataAccessException e) {
return Optional.empty();
}
}
// Query for a scalar value
public long countByStatus(String status) {
return jdbc.queryForObject(
"SELECT COUNT(*) FROM orders WHERE status = ?",
Long.class,
status
);
}
// INSERT returning generated key
public long save(long customerId) {
KeyHolder keyHolder = new GeneratedKeyHolder();
jdbc.update(conn -> {
PreparedStatement ps = conn.prepareStatement(
"INSERT INTO orders (customer_id, status) VALUES (?, 'PENDING')",
Statement.RETURN_GENERATED_KEYS
);
ps.setLong(1, customerId);
return ps;
}, keyHolder);
return keyHolder.getKey().longValue();
}
// UPDATE / DELETE — update() returns affected row count
public boolean updateStatus(long orderId, String newStatus) {
int affected = jdbc.update(
"UPDATE orders SET status = ? WHERE id = ?",
newStatus, orderId
);
return affected > 0;
}
private Order mapRow(ResultSet rs, int rowNum) throws SQLException {
return new Order(
rs.getLong("id"),
rs.getString("status"),
rs.getString("customer_email"),
rs.getObject("created_at", OffsetDateTime.class)
);
}
}
JdbcTemplate is the right tool when: you have complex reporting or analytics queries that JPA would express awkwardly; you need to call a stored procedure; you're doing bulk operations where ORM overhead matters; or you're working with a legacy schema that doesn't map cleanly to JPA entities. JPA/Spring Data is better for the standard CRUD operations on well-defined domain entities — it eliminates the boilerplate of mapping every column to a field. In practice, most production Spring Boot applications use both: JPA for domain CRUD, JdbcTemplate or a native query for the complex reports and batch jobs.
The DAO Pattern
The Data Access Object pattern isolates all database access behind an interface. The rest of the application works against the interface — it doesn't know or care whether the implementation uses raw JDBC, JdbcTemplate, or JPA. This makes the persistence layer replaceable and the service layer testable without a database.
// The contract — no SQL, no JDBC, no implementation detail
public interface OrderDao {
Optional<Order> findById(long id);
List<Order> findByStatus(String status);
long save(long customerId);
boolean updateStatus(long orderId, String newStatus);
void delete(long orderId);
}
// JDBC implementation — service code never sees a Statement or ResultSet
@Repository
public class JdbcOrderDao implements OrderDao {
private final JdbcTemplate jdbc;
public JdbcOrderDao(JdbcTemplate jdbc) { this.jdbc = jdbc; }
// ... methods from Section 5
}
// Service layer — only knows the interface
@Service
public class OrderService {
private final OrderDao orderDao;
public OrderService(OrderDao orderDao) { this.orderDao = orderDao; }
public OrderDetail getOrder(long id) {
return orderDao.findById(id)
.map(OrderDetail::from)
.orElseThrow(() -> new OrderNotFoundException(id));
}
}
// In tests: inject a mock instead of a real database
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
@Mock OrderDao orderDao;
@InjectMocks OrderService orderService;
@Test
void throwsWhenOrderNotFound() {
Mockito.when(orderDao.findById(99L)).thenReturn(Optional.empty());
assertThrows(OrderNotFoundException.class, () -> orderService.getOrder(99L));
}
}
Best Practices and Common Pitfalls
✅ Do
- Always use
PreparedStatementfor any SQL with parameters —Statementis only for parameter-free DDL and migrations - Always use try-with-resources for
Connection,PreparedStatement, andResultSet— in that order, innermost first - Check
executeUpdate()'s return value for UPDATE and DELETE — a return of 0 means the WHERE clause matched nothing; silently ignoring it is how ghost updates happen - Use
rs.getObject("col", SomeClass.class)for nullable columns and alljava.timetypes — it returnsnullcorrectly and avoids the timezone conversion bugs ofgetTimestamp() - Flush batch inserts in chunks (every 500–1000 rows) rather than accumulating everything into a single batch — unbounded batches exhaust heap on large imports
- Use
JdbcTemplatein Spring applications rather than managing connections manually — it handles the boilerplate correctly and translatesSQLExceptionto Spring's unchecked exception hierarchy
❌ Don't
- Don't use
DriverManager.getConnection()in server code — it creates a new physical connection every call with no pooling, no timeout, and no lifecycle management - Don't concatenate parameters into SQL strings — even for "safe" internal values like enum names or column identifiers; if you must build dynamic SQL, use a whitelist and validate against it before interpolating
- Don't leave transactions uncommitted when returning a connection to the pool — dirty connection state is inherited by the next borrower, causing mysterious transaction bugs
- Don't use
rs.getTimestamp()for datetime columns in production — users.getObject("col", OffsetDateTime.class)to avoid JVM-timezone-dependent conversion - Don't catch
SQLExceptionand do nothing with it (or just print the stack trace) — wrap it in a meaningful unchecked exception that carries context for the caller
Interview Questions
Q: What is the difference between Statement and PreparedStatement?
A Statement takes the full SQL string at execution time;
a PreparedStatement takes the SQL template with
? placeholders at creation time and binds parameter
values separately before execution. This separation is what prevents
SQL injection: the parameter value can never change the structure
of the query. PreparedStatement also allows the database
to cache the execution plan for repeated calls with different
parameter values.
Q: What happens if you forget to close a Connection in JDBC?
The physical connection stays open and is not returned to the pool.
With enough unclosed connections, the pool is exhausted — new
requests wait for a connection that never comes back, eventually
timing out. The correct pattern is try-with-resources on
Connection, which guarantees close()
is called even if an exception is thrown.
Q: What does setAutoCommit(false) do?
By default, JDBC commits each statement immediately after execution.
setAutoCommit(false) disables this, grouping subsequent
statements into a single transaction that you control explicitly with
commit() or rollback(). This is necessary
whenever two or more statements must succeed or fail together.
Q: A batch insert of 100,000 rows runs for 90 seconds. Your colleague suggests wrapping it in a single transaction to speed it up. Will it work, and why?
Yes, and substantially. Without an explicit transaction, each row
in the batch auto-commits independently, forcing the database to
perform one fsync per row — writing to the write-ahead log and
confirming durability 100,000 times. A single surrounding
transaction batches all 100,000 row insertions into one WAL flush
at commit time. The IO cost drops from N fsyncs to 1. In practice,
this can reduce a 90-second insert to a few seconds. The caveat:
the entire insert is now an atomic unit — a failure rolls back
everything, whereas without a transaction some rows were already
persisted. Whether that's acceptable depends on the use case;
for many batch imports, chunked transactions (commit every 10,000
rows) give most of the performance benefit with bounded rollback
exposure.
Q: You switch from rs.getTimestamp("created_at") to rs.getObject("created_at", OffsetDateTime.class). What was wrong with the first approach?
getTimestamp() internally applies the JVM's default
timezone when constructing the java.sql.Timestamp.
If the JVM's user.timezone property differs from the
timezone the database stores or was written with — or if the server
is redeployed with a different timezone setting — every historical
timestamp reads as a shifted moment. This is a genuine production
bug that typically appears when a service is migrated to a different
region or when a JVM's default timezone changes. getObject(col,
OffsetDateTime.class) uses the JDBC 4.2 type mapping directly,
reading the timezone-aware value from the database without applying
any local timezone conversion. The result is always the absolute
moment in time stored, regardless of the JVM's locale settings.
Q: When would you choose JdbcTemplate over Spring Data JPA for a new feature, even in a project that already uses JPA for most things?
Three concrete cases. First, complex aggregation queries that span
multiple tables with conditional groupings, window functions, or
CTEs — JPA's JPQL can express these but the result is often verbose
and the generated SQL is hard to predict and tune; SQL expressed
directly in JdbcTemplate is transparent and
debuggable. Second, bulk INSERT or UPDATE operations over tens of
thousands of rows — the JPA entity lifecycle (dirty checking,
first-level cache, entity events) adds per-entity overhead that
becomes significant at that scale; a parameterized bulk UPDATE
statement through JdbcTemplate or a native query
is faster. Third, reading-optimized projections from a reporting
query — there's no benefit in loading full entity objects with
all associations when you only need three columns for a dashboard;
JdbcTemplate maps directly to a record or a DTO
without instantiating persistence context infrastructure.