skip to content

jdbc

connections, prepared statements, transactions and pools.

chapter
chapter 10.08
reading time
10 min read

JDBC is the java.sql package: a set of interfaces (Connection, PreparedStatement, ResultSet) that every database vendor implements in a driver jar. you get a connection, prepare a statement with ? placeholders, execute it, and walk the result with a cursor.

three things matter more than the API: always use PreparedStatement (SQL injection), know your transaction isolation (what other transactions can do to your reads), and pool your connections (opening one is slow).

the picture

you know how one charger cable works with phones from a dozen brands? the plug is a standard; each phone implements its side. JDBC is the plug. your code only talks to the interfaces in java.sql. the driver jar (postgres, mysql, oracle...) is the vendor's side, translating those calls into the database's own wire protocol.

hover a stage.

hover a stage. everything left of the driver is the JDK's interfaces; the driver is the only vendor code. since JDBC 4.0 (java 6) drivers register themselves through ServiceLoader, so no more Class.forName.

the JDK ships the interfaces and no driver at all. you can see the plug with nothing in it:

Main.java
import java.sql.*;
 
void main() {
    IO.println("drivers on the classpath: " + DriverManager.drivers().count());
    try (Connection c = DriverManager.getConnection("jdbc:postgresql://localhost/shop", "app", "secret")) {
        IO.println("connected?");
    } catch (SQLException e) {
        IO.println(e.getMessage());
    }
}
output, from jdk 25
drivers on the classpath: 0
No suitable driver found for jdbc:postgresql://localhost/shop

this one needs a real JVM (files, the network or a database), so it doesn't run in the browser. copy it into java Main.java to try it.

that's why the rest of this chapter's code is shown, not run: there's no database here. put the postgres driver on the classpath (and a database behind it) and the code below works as written.

the shape of every query

OrderDao.java
String sql = "SELECT id, name, total FROM orders WHERE total > ? ORDER BY id";
 
try (Connection conn = dataSource.getConnection();
     PreparedStatement ps = conn.prepareStatement(sql)) {
 
    ps.setInt(1, 100);                          // parameters are 1-based
 
    try (ResultSet rs = ps.executeQuery()) {
        while (rs.next()) {                     // move the cursor, false when done
            long id = rs.getLong("id");
            String name = rs.getString("name");
            int total = rs.getInt("total");
            orders.add(new Order(id, name, total));
        }
    }
}

three resources, all AutoCloseable, all in try-with-resources. a leaked Connection is a leaked server-side session, and with a pool it's worse: the pool runs dry and everything starts waiting.

ResultSet rs = ps.executeQuery();
idnametotal
▶before first
1ada250
2linus120
3grace310
after last
read so far = ·cursor = before first

the query ran. the cursor sits before the first row, so there's nothing to read yet. call getString now and you get an SQLException.

step 1/7
step through reading a result. the cursor starts before the first row, which is why every read loop is while (rs.next()) and never do ... while. the cell being read is the current row's.

writes use executeUpdate(), which returns the number of rows changed. for many rows, addBatch() and executeBatch() send them in one round trip instead of one each, which is usually the single biggest JDBC speedup there is.

never build SQL with +

the one rule of JDBC. if user input is concatenated into SQL, the user is writing part of your query.

name =
try =
string concatenation · what the database parses
SELECT * FROM users WHERE name = 'x' OR '1'='1'
the quote closed the string. the rest is SQL now, written by the user.
PreparedStatement · sent separately
SELECT * FROM users WHERE name = ?
param 1 = "x' OR '1'='1"
the SQL was parsed before the value arrived. the value can only ever be a value.

the filter becomes name = 'x' OR '1'='1', which is true for every row. a login check built like this lets anyone in.

type a name, or pick one. on the left, the input is pasted into the SQL text, and a single quote escapes the string. on the right it travels as a parameter, separately from the SQL, and can't change its structure.
Main.java
void main() {
    String[] inputs = { "ada", "x' OR '1'='1" };
    for (String name : inputs) {
        String sql = "SELECT * FROM users WHERE name = '" + name + "'";
        IO.println(sql);
    }
}
output
SELECT * FROM users WHERE name = 'ada'
SELECT * FROM users WHERE name = 'x' OR '1'='1'
don'tconcatenation
java
String sql = "SELECT * FROM users WHERE name = '"
    + name + "' AND password = '" + pw + "'";
ResultSet rs = conn.createStatement().executeQuery(sql);
doplaceholders
java
var ps = conn.prepareStatement(
    "SELECT * FROM users WHERE name = ? AND password_hash = ?");
ps.setString(1, name);
ps.setString(2, hash(pw));
ResultSet rs = ps.executeQuery();

placeholders work for values only. table names, column names, ORDER BY directions and IN (...) lists of unknown length can't be ?. for those, pick from an allowlist in code (Map.of("newest", "created_at DESC")), never from raw input. (and yes, the good side also hashes the password. see security and crypto.)

quick check
which of these can be a ? placeholder in a PreparedStatement?

transactions

by default a JDBC connection is in autocommit mode: every statement is its own transaction, committed the moment it finishes. to make several statements succeed or fail together, turn that off:

Transfer.java
try (Connection conn = dataSource.getConnection()) {
    conn.setAutoCommit(false);
    try (var debit = conn.prepareStatement("UPDATE accounts SET balance = balance - ? WHERE id = ?");
         var credit = conn.prepareStatement("UPDATE accounts SET balance = balance + ? WHERE id = ?")) {
        debit.setInt(1, 80);  debit.setLong(2, alice);  debit.executeUpdate();
        credit.setInt(1, 80); credit.setLong(2, bob);   credit.executeUpdate();
        conn.commit();                     // both, or...
    } catch (SQLException e) {
        conn.rollback();                   // ...neither
        throw e;
    }
}

atomic is only half of it. the other half is isolation: while your transaction runs, other transactions are running too, and the isolation level decides how much of their unfinished or freshly-committed work you can see.

anomaly =
isolation =
T1T2
  1. BEGIN
  2. UPDATE accounts SET balance = 0 WHERE name = 'alice'
  3. SELECT balance FROM accounts WHERE name = 'alice'
  4. ROLLBACK
committed in the database: alice = 100

two transactions, T1 and T2, interleaved. press step.

step 1/5
pick an anomaly and an isolation level, then step. this shows what the SQL standard allows at each level: the anomaly either happens or is prevented. T1's and T2's statements interleave top to bottom.
level (Connection.TRANSACTION_*)dirty readnon-repeatable readphantom
READ_UNCOMMITTEDpossiblepossiblepossible
READ_COMMITTEDnopossiblepossible
REPEATABLE_READnonopossible
SERIALIZABLEnonono

that's the standard's table. real databases differ, and the differences matter: postgres, oracle and SQL server default to READ_COMMITTED, mysql's InnoDB defaults to REPEATABLE_READ. postgres and InnoDB implement REPEATABLE_READ with a snapshot, so the phantom in the figure doesn't happen there for plain reads. and SERIALIZABLE often doesn't block, it aborts one of the transactions with a serialization error that your code must catch and retry. set the level per connection with conn.setTransactionIsolation(...), and read your database's docs for what it actually means.

connection pools

opening a connection is a TCP handshake, usually TLS, authentication, and server-side session setup: several network round trips and real work on the database. doing that per request is the classic slow-app bug. a pool opens a few connections once and lends them out: getConnection() borrows one, close() gives it back instead of closing it.

no poola pool (HikariCP, the usual choice)
dataSource.getConnection()a new TCP + TLS + auth sessiontake an idle connection from a queue
conn.close()tears the session downreturns it to the pool
200 concurrent requests200 sessions, if the database even allows thatat most maximumPoolSize (Hikari's default: 10), the rest wait up to connectionTimeout (default 30 s)

the pool size is a knob on the database's capacity, not your app's thread count: a database with 8 cores doesn't run 200 queries in parallel any faster than 20. and since connections now get reused, anything a request changes on one (autocommit, isolation, a session variable) has to be reset before the next borrower gets it. good pools do that for you.

one level deeper:what PreparedStatement does on the server+

the database parses the SQL once, plans it, and can then execute it many times with different parameters. drivers vary in how they use this: postgres's driver switches to a named server-side prepared statement after a statement is executed a few times on the same connection; others emulate prepared statements client-side by safely escaping values into the text. either way the injection guarantee holds, because the driver, not your string concatenation, decides how a value is encoded. for caching across requests (which use different connections), pools can keep a per-connection statement cache.

keylines
  • JDBC is interfaces in java.sql; the vendor's driver implements them. the JDK ships no driver.
  • Connection → PreparedStatement → ResultSet, all in try-with-resources.
  • never concatenate input into SQL. ? for values, an allowlist for identifiers.
  • turn off autocommit to group statements, then commit or roll back.
  • isolation levels decide which anomalies you can see; real databases implement them differently.
  • pool your connections; size the pool for the database, not your thread count.