How do I handle SQL exceptions in JDBC for MySQL properly?

For MySQL (mysql-connector-j), “proper” SQLException handling is the same core approach as JDBC in general, plus a few MySQL-specific signals (SQLState + error code) that are worth using for translation/retry decisions.

1) Keep the important diagnostics (MySQL error code + SQLState)

MySQL gives you two invaluable fields:

  • e.getErrorCode()MySQL vendor error code (e.g., 1062 for duplicate key)
  • e.getSQLState()SQLState (often 23000, 40001, etc.)

A good pattern is: wrap once with context, but preserve those fields.

package org.kodejava.jdbc;

import java.sql.SQLException;

public final class MySqlExceptions {
    public static RuntimeException translate(String operation, SQLException e) {
        String msg = operation
                     + " failed (SQLState=" + e.getSQLState()
                     + ", errorCode=" + e.getErrorCode() + ")";

        // Keep e as the cause.
        return switch (e.getErrorCode()) {
            case 1062 ->
                    new IllegalStateException(msg + " - duplicate key", e); // unique constraint violation
            case 1213 ->
                    new IllegalStateException(msg + " - deadlock", e);      // often retryable
            case 1205 ->
                    new IllegalStateException(msg + " - lock wait timeout", e); // often retryable
            default -> new RuntimeException(msg, e);
        };
    }
}

2) MySQL error codes you’ll commonly care about

These are the ones that usually drive different handling:

Situation MySQL error code Typical SQLState What to do
Unique constraint violation (“Duplicate entry”) 1062 23000 Return “already exists” / map to 409 / domain error
Deadlock found 1213 40001 Often safe to retry the whole transaction
Lock wait timeout exceeded 1205 often 41000 Often retry or surface “please retry”
Foreign key constraint fails 1451/1452 23000 Map to domain validation (cannot delete/insert due to FK)
Connection/link failure varies 08xxx / 08S01 Treat as transient infra failure; maybe retry with backoff

Rule of thumb: prefer the vendor error code for MySQL-specific branching (it’s the most consistent), and keep SQLState for general categorization/logging.

3) Retrying safely (only for the right failures)

Only retry if:

  • the operation is idempotent, or you’re retrying the entire transaction from the beginning, and
  • the failure is one of the known transient classes (deadlock / lock timeout / connection hiccup).

A minimal “should retry?” helper:

package org.kodejava.jdbc;

import java.sql.SQLException;

public final class MySqlRetry {
    public static boolean isRetryable(SQLException e) {
        int code = e.getErrorCode();
        String state = e.getSQLState();

        // MySQL deadlock / lock wait timeout
        if (code == 1213 || code == 1205) return true;

        // Connection exception class (SQLState starts with "08")
        if (state != null && state.startsWith("08")) return true;

        return false;
    }
}

If you do retry, keep it small (e.g., 2–3 attempts) with jittered backoff, and log the final failure with the full chain (getNextException()) and suppressed exceptions.

4) Transactions: rollback without hiding the original error

With MySQL, rollback can also throw if the connection is broken. Best practice: attach rollback failure as suppressed so you don’t lose the root cause.

package org.kodejava.jdbc;

import java.sql.Connection;
import java.sql.SQLException;

public final class TxUtil {
    public static void rollbackQuietly(Connection con, SQLException original) {
        try {
            con.rollback();
        } catch (SQLException rb) {
            original.addSuppressed(rb);
        }
    }
}

5) MySQL Connector/J note: you usually don’t need Class.forName(...)

With modern JDBC drivers (including MySQL Connector/J 8+), the driver is auto-registered via the Service Provider mechanism. Calling Class.forName("com.mysql.cj.jdbc.Driver") is typically unnecessary unless you’re in a very unusual classloading environment.

6) What to log (and what not to log)

Log:

  • operation name (e.g., "insert user")
  • SQLState, error code
  • exception chain (getNextException())
  • safe parameter identifiers (e.g., user id), not secrets

Avoid logging:

  • credentials
  • sensitive values (passwords, tokens)
  • huge SQL strings with embedded data (use prepared statements so you don’t have that problem)

How do I handle SQL exceptions in JDBC properly?

Handling SQLException “properly” in JDBC is mostly about (1) not leaking resources, (2) preserving diagnostic detail, (3) rolling back safely, and (4) translating errors into something meaningful at your app boundary.

1) Always close JDBC resources (use try-with-resources)

This eliminates most error-handling bugs (leaks and double-closes), and it also handles exceptions thrown during close() by attaching them as suppressed exceptions.

package org.kodejava.jdbc;

import javax.sql.DataSource;
import java.sql.*;

public final class JdbcExample {
    public static void runQuery(DataSource ds, long id) {
        String sql = "select name from users where id = ?";

        try (Connection con = ds.getConnection();
             PreparedStatement ps = con.prepareStatement(sql)) {

            ps.setLong(1, id);

            try (ResultSet rs = ps.executeQuery()) {
                if (rs.next()) {
                    String name = rs.getString(1);
                    // use name...
                }
            }

        } catch (SQLException e) {
            throw toDataAccessException("Failed running query: " + sql, e);
        }
    }

    static RuntimeException toDataAccessException(String message, SQLException e) {
        // Keep the SQLException as the cause so details are not lost.
        return new RuntimeException(message + " (SQLState=" + e.getSQLState() + ", code=" + e.getErrorCode() + ")", e);
    }
}

Key points

  • Prefer PreparedStatement over Statement (safety + plan reuse).
  • Wrap/translate once, near your data-access boundary, but keep e as the cause.

2) Log/inspect the full JDBC error chain (and suppressed exceptions)

JDBC drivers can chain multiple exceptions (e.g., one per batch item), and try-with-resources can add suppressed exceptions from close().

package org.kodejava.jdbc;

import java.sql.SQLException;

public final class SqlDiagnostics {
    public static String describe(SQLException e) {
        StringBuilder sb = new StringBuilder();
        for (Throwable t = e; t != null; t = (t instanceof SQLException se) ? se.getNextException() : null) {
            if (t instanceof SQLException se) {
                sb.append("SQLException: message=").append(se.getMessage())
                        .append(", SQLState=").append(se.getSQLState())
                        .append(", code=").append(se.getErrorCode())
                        .append('\n');
            } else {
                sb.append("Throwable: ").append(t).append('\n');
            }

            for (Throwable sup : t.getSuppressed()) {
                sb.append("  suppressed: ").append(sup).append('\n');
            }
        }
        return sb.toString();
    }
}

When this matters

  • Batch updates (BatchUpdateException)
  • Failures during resource cleanup (network drop during close())

3) Handle transactions: commit/rollback with a safe rollback path

If you manually manage transactions (setAutoCommit(false)), your exception handling must:

  1. rollback on failure,
  2. not mask the original exception if rollback also fails,
  3. restore state if you’re reusing connections (pools usually reset, but don’t rely on it blindly).
package org.kodejava.jdbc;

import java.sql.*;

public final class JdbcTxExample {
    public static void transfer(Connection con, long fromId, long toId, long amount) throws SQLException {
        boolean oldAutoCommit = con.getAutoCommit();
        con.setAutoCommit(false);

        try (PreparedStatement debit = con.prepareStatement("update acct set bal = bal - ? where id = ?");
             PreparedStatement credit = con.prepareStatement("update acct set bal = bal + ? where id = ?")) {

            debit.setLong(1, amount);
            debit.setLong(2, fromId);
            debit.executeUpdate();

            credit.setLong(1, amount);
            credit.setLong(2, toId);
            credit.executeUpdate();

            con.commit();

        } catch (SQLException e) {
            try {
                con.rollback();
            } catch (SQLException rb) {
                e.addSuppressed(rb); // keep original, attach rollback failure for debugging
            }
            throw e; // or translate here
        } finally {
            try {
                con.setAutoCommit(oldAutoCommit);
            } catch (SQLException ac) {
                // typically log; don't hide earlier failure
            }
        }
    }
}

4) Don’t swallow exceptions; translate them at the right layer

Common strategy:

  • DAO/repository layer: catch SQLException, add context (operation + key parameters), then rethrow as:
    • a checked app exception (if you want callers to handle), or
    • a runtime “data access” exception (common in service-oriented apps).
  • Service/controller boundary: map to user-safe messages (avoid exposing SQL text / internals).

Avoid:

  • catch (SQLException e) {} (silences failures)
  • Throwing a new exception without e as cause (loses SQLState/vendor code)

5) Use SQLState / vendor codes for decisions (sparingly)

If you need conditional handling (e.g., unique constraint violation), prefer SQLState classes when possible:

  • 23*** integrity constraint violation (many DBs)
  • 40*** transaction rollback / serialization failure (often retryable)

But keep it minimal: drivers/databases vary.

6) Be careful with retries

Only retry when you can justify it:

  • transient network issues
  • deadlocks / serialization failures (often safe to retry the whole transaction)

Never retry blindly on all SQLExceptions.


Quick checklist

  • try-with-resources for Connection/Statement/ResultSet
  • Keep SQLException as the cause; don’t lose SQLState / error code
  • Walk getNextException() and check suppressed exceptions
  • In manual transactions: rollback in catch, attach rollback failures via addSuppressed
  • Translate exceptions at the repository boundary; expose safe messages at the edge

How do I configure C3P0 connection pool in JDBC?

To configure c3p0 as a JDBC connection pool, you typically:

  1. create a DataSource (usually ComboPooledDataSource)
  2. tune pool parameters (min/max pool size, idle time, timeouts, statement cache)
  3. use the DataSource everywhere instead of DriverManager.getConnection(...)
  4. close Connection/Statement/ResultSet normally (c3p0 returns connections to the pool on close())

Below are the common ways to do it.


1) Programmatic configuration (most common for plain JDBC)

package org.kodejava.jdbc;

import com.mchange.v2.c3p0.ComboPooledDataSource;

import javax.sql.DataSource;
import java.beans.PropertyVetoException;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;

public final class C3p0DataSourceFactory {
    private static final ComboPooledDataSource ds = new ComboPooledDataSource();

    static {
        try {
            ds.setDriverClass("org.postgresql.Driver");
        } catch (PropertyVetoException e) {
            throw new ExceptionInInitializerError(e);
        }

        ds.setJdbcUrl("jdbc:postgresql://localhost:5432/<db>");
        ds.setUser("<db_user>");
        ds.setPassword("<db_password>");

        // Pool sizing
        ds.setMinPoolSize(5);
        ds.setInitialPoolSize(5);
        ds.setMaxPoolSize(20);
        ds.setAcquireIncrement(2);

        // Timeouts / lifecycles
        ds.setCheckoutTimeout(10_000);          // ms to wait for a connection
        ds.setMaxIdleTime(300);                 // seconds before idle conns are culled
        ds.setMaxConnectionAge(0);              // 0 = no limit (consider setting in prod)
        ds.setIdleConnectionTestPeriod(60);     // seconds between idle tests

        // Validation (pick ONE strategy; this is the most reliable)
        ds.setPreferredTestQuery("SELECT 1");
        ds.setTestConnectionOnCheckout(false);
        ds.setTestConnectionOnCheckin(true);

        // Statement caching (optional)
        ds.setMaxStatements(200);
        ds.setMaxStatementsPerConnection(20);
    }

    private C3p0DataSourceFactory() {}

    public static DataSource dataSource() {
        return ds;
    }

    // Optional: call on app shutdown (desktop apps / simple main())
    public static void shutdown() {
        ds.close();
    }

    // Example usage
    public static void main(String[] args) throws Exception {
        try (Connection c = dataSource().getConnection();
             PreparedStatement ps = c.prepareStatement("SELECT now()");
             ResultSet rs = ps.executeQuery()) {
            while (rs.next()) {
                System.out.println(rs.getObject(1));
            }
        } finally {
            shutdown();
        }
    }
}

Notes on the key settings

  • minPoolSize / maxPoolSize: your main capacity knobs.
  • checkoutTimeout: prevents threads from waiting forever when the pool is exhausted.
  • Connection testing:
    • Prefer preferredTestQuery + testConnectionOnCheckin=true (or periodic tests) to avoid “broken connection” surprises.
    • testConnectionOnCheckout=true is safer but adds latency to every borrow.
  • Statement cache helps if you repeatedly run the same SQL.

2) Configure via c3p0.properties (cleaner config)

Create src/main/resources/c3p0.properties:

# Basic
c3p0.jdbcUrl=jdbc:postgresql://localhost:5432/<db>
c3p0.user=<db_user>
c3p0.password=<db_password>
c3p0.driverClass=org.postgresql.Driver

# Pool sizing
c3p0.minPoolSize=5
c3p0.initialPoolSize=5
c3p0.maxPoolSize=20
c3p0.acquireIncrement=2

# Timeouts / health checks
c3p0.checkoutTimeout=10000
c3p0.maxIdleTime=300
c3p0.idleConnectionTestPeriod=60
c3p0.preferredTestQuery=SELECT 1
c3p0.testConnectionOnCheckin=true

# Statement cache (optional)
c3p0.maxStatements=200
c3p0.maxStatementsPerConnection=20

Then your Java becomes minimal:

package org.kodejava.jdbc;

import com.mchange.v2.c3p0.ComboPooledDataSource;

import javax.sql.DataSource;

public final class DataSources {
    private static final ComboPooledDataSource ds = new ComboPooledDataSource(); // reads c3p0.properties

    public static DataSource c3p0() {
        return ds;
    }

    public static void shutdown() {
        ds.close();
    }
}

c3p0 will auto-load configuration from:

  • c3p0.properties on the classpath, or
  • c3p0-config.xml (more advanced, supports named configs)

3) Important usage rule: always close connections

With pooling, connection.close() does not close the physical DB connection; it returns it to the pool. So keep using try-with-resources:

try (Connection con = dataSource.getConnection();
     PreparedStatement ps = con.prepareStatement("SELECT * FROM t WHERE id = ?");
) {
  ps.setLong(1, 123L);
  // ...
}

4) Common pitfalls (quick checklist)

  • Don’t create a new ComboPooledDataSource per query. Create one and reuse it.
  • Set checkoutTimeout so you fail fast under load.
  • Use a validation query if your DB/network closes idle connections.
  • Tune pool sizes to match DB limits (max connections on the server) and your concurrency.

Maven dependencies

<dependencies>
  <dependency>
    <groupId>com.mchange</groupId>
    <artifactId>c3p0</artifactId>
    <version>0.11.1</version>
  </dependency>

  <dependency>
    <groupId>org.postgresql</groupId>
    <artifactId>postgresql</artifactId>
    <version>42.7.7</version>
  </dependency>
</dependencies>

Maven Central Maven Central

How to Configure Apache DBCP Connection Pool in JDBC?

To configure an Apache DBCP connection pool for “plain” JDBC, you typically create a pooled DataSource once at startup, then get connections from it (and always close them to return to the pool).

Below are the two most common approaches.

1) Recommended (DBCP2): BasicDataSource (simplest)

Create and configure the pool

package org.kodejava.jdbc;

import org.apache.commons.dbcp2.BasicDataSource;

import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;

public final class Dbcp2Example {
    public static DataSource dataSource() {
        BasicDataSource ds = new BasicDataSource();
        ds.setUrl("jdbc:postgresql://localhost:5432/app");
        ds.setUsername("<db_user>");
        ds.setPassword("<db_password>");

        ds.setMaxTotal(20);
        ds.setMaxIdle(10);
        ds.setMinIdle(2);

        ds.setValidationQuery("SELECT 1");
        ds.setTestOnBorrow(true);

        return ds;
    }

    public static void main(String[] args) throws Exception {
        DataSource ds = dataSource();
        try (Connection c = ds.getConnection();
             PreparedStatement ps = c.prepareStatement("SELECT 1");
             ResultSet rs = ps.executeQuery()) {
            while (rs.next()) System.out.println(rs.getInt(1));
        }
    }
}

Key idea: with pooling, conn.close() does not close the physical DB connection; it returns it to the pool.


2) Lower-level (more flexible): PoolingDataSource + Commons Pool

This approach wires DBCP to Commons Pool manually (useful when you want full control over the pool object/config).

package org.kodejava.jdbc;

import org.apache.commons.dbcp2.ConnectionFactory;
import org.apache.commons.dbcp2.DriverManagerConnectionFactory;
import org.apache.commons.dbcp2.PoolableConnection;
import org.apache.commons.dbcp2.PoolableConnectionFactory;
import org.apache.commons.dbcp2.PoolingDataSource;
import org.apache.commons.pool2.impl.GenericObjectPool;
import org.apache.commons.pool2.impl.GenericObjectPoolConfig;

import javax.sql.DataSource;

public final class ManualDbcpPoolFactory {

    public static DataSource createDataSource() {
        ConnectionFactory cf =
                new DriverManagerConnectionFactory("jdbc:postgresql://localhost:5432/app",
                        "<db_user>", "<db_password>");

        PoolableConnectionFactory pcf = new PoolableConnectionFactory(cf, null);
        pcf.setValidationQuery("SELECT 1");

        GenericObjectPoolConfig<PoolableConnection> config = new GenericObjectPoolConfig<>();
        config.setMaxTotal(20);
        config.setMaxIdle(10);
        config.setMinIdle(2);
        config.setTestOnBorrow(true);

        GenericObjectPool<PoolableConnection> pool = new GenericObjectPool<>(pcf, config);
        pcf.setPool(pool);

        return new PoolingDataSource<>(pool);
    }
}

Practical tuning checklist (what to set and why)

  • Sizing
    • maxTotal: hard cap of concurrent borrowed connections.
    • maxIdle / minIdle: how many connections to keep around to absorb spikes.
  • Validation
    • Use validationQuery (or validationQueryTimeout) and pick one strategy:
      • testOnBorrow=true (safer, slightly more overhead), or
      • testWhileIdle=true + eviction run (common for reducing borrow-time latency).
  • Timeouts
    • maxWaitMillis: how long callers wait for a free connection before failing.
  • Always close
    • Ensure try-with-resources everywhere; leaks will exhaust the pool.

Maven dependencies

<dependencies>
    <dependency>
        <groupId>org.apache.commons</groupId>
        <artifactId>commons-dbcp2</artifactId>
        <version>2.14.0</version>
    </dependency>

    <dependency>
        <groupId>org.apache.commons</groupId>
        <artifactId>commons-pool2</artifactId>
        <version>2.13.1</version>
    </dependency>

    <dependency>
        <groupId>org.postgresql</groupId>
        <artifactId>postgresql</artifactId>
        <version>42.7.7</version>
    </dependency>
</dependencies>

Maven Central Maven Central Maven Central

How do I use connection pooling in JDBC with HikariCP?

To use connection pooling in plain JDBC with HikariCP, the main shift is:

  • stop using DriverManager.getConnection(...) everywhere
  • create one DataSource (the pool) at startup
  • whenever you need a DB connection, call dataSource.getConnection()
  • always close resources with try-with-resources (closing returns the connection to the pool, it does not kill the physical connection)

1) Create a pooled DataSource once

A simple “factory” that builds a singleton pool:

package org.kodejava.jdbc;

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

import javax.sql.DataSource;
import java.time.Duration;

public final class DataSourceFactory {
    private static final HikariDataSource dataSource = create();

    private DataSourceFactory() {}

    private static HikariDataSource create() {
        HikariConfig config = new HikariConfig();

        config.setJdbcUrl("jdbc:postgresql://localhost:5432/app_db");
        config.setUsername("db_user");
        config.setPassword("db_password"); // use env vars/secret store in real apps

        // Pool sizing (tune per app + DB limits)
        config.setMaximumPoolSize(10);
        config.setMinimumIdle(2);

        // Timeouts
        config.setConnectionTimeout(Duration.ofSeconds(5).toMillis()); // wait for a connection from pool
        config.setIdleTimeout(Duration.ofMinutes(5).toMillis());
        config.setMaxLifetime(Duration.ofMinutes(30).toMillis());

        // Optional: validation / observability
        config.setPoolName("AppHikariPool");

        return new HikariDataSource(config);
    }

    public static DataSource getDataSource() {
        return dataSource;
    }

    /** Call this on application shutdown */
    public static void shutdown() {
        dataSource.close();
    }
}

Notes:

  • maximumPoolSize is usually the most important setting.
  • Prefer one pool per database, not one per DAO/class.

2) Use it in JDBC code (and always close)

Example query using the pooled DataSource:

package org.kodejava.jdbc;

import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;

public class UserRepository {
    private final DataSource dataSource;

    public UserRepository(DataSource dataSource) {
        this.dataSource = dataSource;
    }

    public String findEmailById(long id) throws SQLException {
        String sql = "select email from users where id = ?";

        try (Connection con = dataSource.getConnection();
             PreparedStatement ps = con.prepareStatement(sql)) {

            ps.setLong(1, id);

            try (ResultSet rs = ps.executeQuery()) {
                return rs.next() ? rs.getString("email") : null;
            }
        }
    }
}

Key point: con.close() (done by try-with-resources) returns the connection to the pool.


3) Shutdown cleanly

If you’re writing a CLI app / desktop app / simple server, ensure the pool is closed on exit:

package org.kodejava.jdbc;

public class App {
    public static void main(String[] args) throws Exception {
        var ds = DataSourceFactory.getDataSource();
        var repo = new UserRepository(ds);

        System.out.println(repo.findEmailById(1L));

        DataSourceFactory.shutdown();
    }
}

For long-running apps, register a shutdown hook:

Runtime.getRuntime().addShutdownHook(new Thread(DataSourceFactory::shutdown));

4) Common configuration tips (practical)

  • Pool size: start with maximumPoolSize=10 for typical web apps, then tune using metrics and DB limits.
  • Don’t set minimumIdle too high unless you truly need warm connections.
  • Transactions: still work the same (use con.setAutoCommit(false) and commit/rollback), but make sure you always return the connection to the pool.
  • If you see “connection leak” warnings, it usually means some path didn’t close the connection (missing try-with-resources).

Maven dependencies

<dependencies>
  <dependency>
    <groupId>com.zaxxer</groupId>
    <artifactId>HikariCP</artifactId>
    <version>6.3.0</version>
  </dependency>

  <dependency>
    <groupId>org.postgresql</groupId>
    <artifactId>postgresql</artifactId>
    <version>42.7.7</version>
  </dependency>
</dependencies>

Maven Central Maven Central