Java

Connection Pools and Transactions in Java

Executive Summary

Connection pools and transactions are the two pieces production JDBC adds. DriverManager.getConnection is per-use and unscalable, because each call pays for a handshake and authentication. So production code replaces it with HikariCP, the de-facto standard pool. You configure it once with a JDBC URL, credentials, and maximumPoolSize. Then it keeps warm connections and lends them out, and your code’s conn.close() returns the connection to the pool instead of the void.

Pool sizing follows the database, not the threads. The database’s own connection capacity is the ceiling, so a modest pool that queues briefly beats an oversized pool that the database refuses. Transactions ride the Connection. Autocommit is on by default, so each statement commits alone. A multi-statement operation therefore turns it off, runs its statements, and commits on success. On failure, it rolls back in the catch, and this try-rollback pattern makes failures atomic. The transfer example is the canonical proof, two updates that must both land or both vanish.

Isolation levels tune what concurrent transactions may see of each other’s uncommitted work. READ_COMMITTED is the sane default, and raising isolation knowingly trades concurrency for consistency. The boundaries are simple. Borrow a connection per operation, never per request lifetime. Also keep transactions short and, where possible, off the network and I/O. Finally, let the pool and the transaction each do the one job they exist for.

Why DriverManager Does Not Scale

The fundamentals article opened connections with DriverManager.getConnection, which is honest teaching and terrible operations. Every call performs the full ritual. First comes the TCP connect, then TLS negotiation if configured, authentication, and session state, and close reverses all of it. Per request, that tax is your latency budget’s worst line item. Worse, the database’s connection limit is the real wall, because each connection is a process on its side:

// WRONG: per-request connect: hundreds of milliseconds per call, and a
// stampede of fresh connections when traffic spikes
try (Connection conn = DriverManager.getConnection(url, user, pass)) {
    doWork(conn);                        // most of the request's time: the handshake
}

// RIGHT: borrow a warm connection from a pool, return it when done
try (Connection conn = dataSource.getConnection()) {   // lent in microseconds
    doWork(conn);
}   // close() RETURNS the connection to the pool: warm, ready for the next borrower

The design is the executor article’s pool, transplanted from threads to sockets. In other words, an expensive resource is created once and amortized across thousands of uses. It is lent and returned rather than born and destroyed. The same lesson also transfers exactly: unbounded growth is the failure mode. So the pool has a maximum size, and borrowers queue politely when it is exhausted. This is the bounded-shelf thinking the concurrent collections article formalized. For pool sizing and timeout behavior from the database’s side, see Connection Pooling Explained.

HikariCP: The Standard Pool, Configured

HikariCP is the pool that won Java. For example, it is the default in Spring Boot, and its own README documents its obsession with microsecond-level borrowing. Configuration is a one-time object, typically at application startup:

import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;

var config = new HikariConfig();
config.setJdbcUrl("jdbc:postgresql://db.internal:5432/books");
config.setUsername("app");
config.setPassword(System.getenv("DB_PASSWORD"));   // secrets from env, never code
config.setMaximumPoolSize(10);        // the database is the ceiling: not thread count
config.setConnectionTimeout(5_000);   // borrowers wait at most 5s, then fail loudly
config.setPoolName("books-pool");     // named: visible in logs and thread dumps

HikariDataSource pool = new HikariDataSource(config);

// the borrow-return loop, everywhere in your code, forever after:
try (Connection conn = pool.getConnection()) {
    ...
}

Read the two settings that decide production behavior. First, the database’s capacity bounds maximumPoolSize, not your thread count. The pool’s own guidance is counterintuitive but measured. A small pool that keeps connections busy outperforms a large one whose connections queue for the database anyway. That is the same cores-versus-blocking reasoning as the executor sizing table.

Second, connectionTimeout turns pool exhaustion from an infinite hang into a fast, loud failure after five seconds. As a result, you get a stack trace at the borrow site instead of the debugging article’s everyone-WAITING mystery. Pool exhaustion usually comes from a leak, a borrowed connection that is never closed. The try-with-resources line above is the entire prevention.

Transactions: All or Nothing

Autocommit is JDBC’s default and its trap: every statement commits immediately, alone. One statement per business operation makes it harmless, and the fundamentals article’s single queries were exactly that. However, the moment an operation spans statements, autocommit produces partial writes. The canonical example is the one every bank runs in production:

// WRONG: autocommit - two statements, two transactions, one disaster between them
try (var conn = pool.getConnection();
     var debit = conn.prepareStatement("UPDATE accounts SET balance = balance - ? WHERE id = ?");
     var credit = conn.prepareStatement("UPDATE accounts SET balance = balance + ? WHERE id = ?")) {

    debit.setBigDecimal(1, amount);  debit.setLong(2, fromId);
    debit.executeUpdate();           // COMMITTED immediately

    failHere();                       // the crash: money left 'from', never arrived

    credit.setBigDecimal(1, amount); credit.setLong(2, toId);
    credit.executeUpdate();
}
// RIGHT: one transaction, two outcomes: everything, or nothing
try (var conn = pool.getConnection()) {
    conn.setAutoCommit(false);                    // the transaction starts here
    try (var debit = conn.prepareStatement(...);
         var credit = conn.prepareStatement(...)) {

        debit.executeUpdate();                    // inside the transaction
        credit.executeUpdate();                   // still inside: nothing visible yet
        conn.commit();                            // NOW both are permanent
    } catch (SQLException e) {
        conn.rollback();                          // both vanish: atomic failure
        throw new TransferFailedException(fromId, toId, e);
    }
}

The delta is the whole feature. With autocommit, a crash between statements leaves a half-transfer that only a reconciliation report will ever find. With the transaction, by contrast, the database hides the intermediate state, and the rollback restores exactly nothing-happened. The shape to memorize is the try-rollback pattern. Call setAutoCommit(false), do the work, and commit on the happy path. Otherwise, roll back in the catch before rethrowing. The tutorial’s rule and the exceptions article’s discipline agree on why: a failure inside a transaction must unwind the transaction, not just the stack.

Isolation Levels: What Concurrent Transactions See

Two transactions running at once raise a familiar design question. The locks article would recognize it instantly: what may one transaction see of another’s unfinished work? The database, not your Java code, answers it, with named levels:

Level Prevents Cost The usual verdict
READ_UNCOMMITTED Nothing: dirty reads possible None, and wrong Avoid
READ_COMMITTED (typical default) Dirty reads: you only see committed data Small The sane default
REPEATABLE_READ Also non-repeatable reads within one transaction Locks held longer When a read-twice operation must see the same thing
SERIALIZABLE Also phantoms: full illusion of one transaction at a time Real concurrency cost Surgical use only
conn.setTransactionIsolation(Connection.TRANSACTION_REPEATABLE_READ);  // if ever

The working guidance matches the industry. Stay on the database’s default, which is READ_COMMITTED in PostgreSQL and most engines. Then design operations so the default is sufficient. Raise the level for a specific operation only when you can name the phenomenon you are preventing. Serializable everything is the database version of synchronizing every method. Therefore, the locks article’s over-synchronization verdict transfers word for word.

How Real Systems Use Connection Pools and Transactions

Every production Java service relies on connection pools and transactions. It runs a pool, usually HikariCP unless an organization standardized on something older. Similarly, every business write runs in a transaction, usually through the mapping layers you meet in two articles. The observability pattern matters as much as the configuration. Named pools let thread dumps name the wait. Exported pool metrics also show saturation trends before the hang. Finally, short connection timeouts make exhaustion fail loudly during business hours instead of silently at peak.

The partial-write bug I keep as a cautionary tale predates my transaction habit. In fact, it is the autocommit transfer exactly as written in this article’s WRONG block. A points service moved value between ledgers in two updates under autocommit. Then a disk-full error hit between them. The debit landed, the credit failed, and the system soldiered on with the difference missing.

We found it in a nightly reconciliation three days later. By then, the affected accounts had compounded the mystery with legitimate activity. The fix was one setAutoCommit(false) and one rollback in the catch. It took fifteen minutes, against three days of investigation. That lesson reshaped how I review data code. Autocommit is not a default to inherit; it is a decision to notice. So the review question for every multi-statement operation is one sentence: where does this roll back? If the answer is “nowhere”, the review is not over until the transaction exists.

Decision Framework

  1. Is the application doing more than occasional queries? A pool, created once at startup and shared, with DriverManager reserved for scripts and examples.
  2. How big should the pool be? The database’s capacity bounds it. Size it small enough to keep connections busy, and measure it under load per the profiling article.
  3. What happens when the pool is exhausted? A connection timeout that fails loudly, and a leak hunt guided by which code path borrowed and never returned.
  4. Does the operation span one statement? Autocommit is acceptable, and explicit is still clearer.
  5. Does it span several statements that must be atomic? The try-rollback pattern: setAutoCommit(false), work, commit, rollback in the catch before rethrowing.
  6. Does a read-twice operation see inconsistent data under the default isolation? Name the phenomenon, raise the level for that operation alone, and measure the concurrency cost.
  7. Does the transaction span network calls or user think-time? Redesign the boundary: long transactions hold locks and pool capacity, and Part 9’s request handling will make that expensive.

When NOT to Use This

  • Do not hand-roll a pool. Connection validation, eviction, leak detection, and shutdown are solved problems, and HikariCP is a single dependency.
  • Do not size the pool by thread count. The database is the constrained resource, so a 200-thread pool aimed at a 30-connection database is a queue wearing a badge.
  • Do not hold a borrowed connection for a request’s lifetime or across user think-time. Instead, borrow and return at the operation, and the pool stays healthy.
  • Do not raise isolation speculatively. Name the read phenomenon you are preventing, or else stay on the default and design the operation honestly.
  • Do not wrap a transaction around slow calls into code you do not own. For example, a third-party HTTP call inside a transaction holds a pooled connection and database locks for its whole duration.

Common Mistakes

  • setAutoCommit(false) with no rollback path. The exception unwinds the stack, and the connection returns to the pool mid-transaction. After that, behavior depends on the pool’s reset policy, which is not a design.
  • Committing inside the try before all statements ran. The early commit ends atomicity, so everything after it is autocommit’s partial-write story again.
  • Rollback that swallows. Catching SQLException, rolling back, and returning normally turns a failure into a silent no-op that looks like success.
  • Leaked connections. A borrow outside try-with-resources becomes the debugging article’s WAITING-in-getConnection hang, usually on a Friday.
  • Pool larger than the database allows. The database refuses connections, and the pool’s errors look mysterious. The fix is sizing to the real ceiling.
  • Setting isolation per connection and forgetting: pooled connections are reused, so per-borrow state must be reset deliberately or inherited unpredictably.
  • Transactions around external calls. This is the locks article’s held-lock-across-I/O warning, with the database as the lock holder.

Key Takeaways

  • Opening connections per request does not scale. Instead, a HikariCP pool, created once and borrowed per operation, amortizes the handshake across the application’s life.
  • Pool sizing follows the database’s capacity, not thread count. Meanwhile, connection timeouts turn exhaustion into a loud five-second failure instead of a hang.
  • close() on a pooled connection returns it to the pool. The borrow-return loop is try-with-resources, because leaks are the one cause of exhaustion.
  • Autocommit commits every statement alone: multi-statement operations need setAutoCommit(false), commit on success, rollback in the catch.
  • The transfer is the canonical transaction. Debit and credit land together or not at all, and the database enforces the promise.
  • The default isolation, READ_COMMITTED, is the design target. Raise levels surgically, and only when you can name the read phenomenon you are preventing.
  • Keep transactions short and free of foreign I/O. After all, held locks and held pool capacity are the cost of every line inside the boundary.

FAQ

What is a connection pool in Java?

A cache of open database connections, created at startup and lent per operation. Code borrows with getConnection and uses it. Then close() returns it to the pool instead of closing it. It amortizes the expensive connect handshake across thousands of operations.

How do I configure HikariCP?

Build a HikariConfig with the JDBC URL and credentials. Also set maximumPoolSize within the database’s capacity and a connectionTimeout that fails loudly. Then wrap it in a HikariDataSource once at startup. Every operation borrows from the data source inside try-with-resources.

What is autocommit in JDBC?

The default mode where every statement commits immediately and alone. It is convenient for single statements but dangerous for multi-statement operations. That is because a failure between statements leaves a partial write. So business operations call setAutoCommit(false) and manage commit and rollback themselves.

How do I use transactions in JDBC?

Call setAutoCommit(false) on the connection and run the operation’s statements. Then call conn.commit() on success, or conn.rollback() in the catch before rethrowing. The database then guarantees the statements are atomic: all applied, or none visible.

What transaction isolation level should I use in Java?

The database’s default, typically READ_COMMITTED, is the right start: it prevents dirty reads at small cost. Raise the level only for one operation, when you can name the concurrency phenomenon it must prevent. After all, higher levels trade real concurrency for consistency.

Conclusion

Your data layer now has its production machinery. First, a bounded, named, monitored pool lends warm connections in microseconds. Second, business operations are atomic by construction, so they commit whole or roll back whole. Connection pools and transactions turn JDBC’s mechanics into a layer a service can stand on. They are also exactly what the CRUD article now assembles.

The next article builds that application: a complete create, read, update, delete service over JDBC. It has a repository that borrows from the pool and a transaction per business operation. Records serve as rows, and a JUnit test tier runs over in-memory H2. In short, everything from the last two articles lands in one working program.

Borrow short, commit whole, roll back everything. That is the whole discipline of production data access.

Last updated on 26 September 2026.

Share this article

Leave a Reply

Your email address will not be published. Required fields are marked *