Migrating from c3p0 to HikariCP

This guide is part of HikariCP vs c3p0 vs DBCP2 Benchmark. The migration is usually triggered by a specific failure: a legacy c3p0 pool whose p99 acquisition latency climbs under load while the database stays idle, or intermittent stale-connection and leaked-connection errors that c3p0’s helper threads paper over but never eliminate. Logs look like this:

WARN  c3p0.impl.AbstractPoolBackedDataSource - A C3P0Registry mbean is already registered...
WARN  com.mchange.v2.async.ThreadPoolAsynchronousRunner - com.mchange.v2.async.ThreadPoolAsynchronousRunner$DeadlockDetector
      -- APPARENT DEADLOCK!!! Creating emergency threads for unassigned pending tasks!
ERROR org.postgresql.util.PSQLException: This connection has been closed.

The “APPARENT DEADLOCK” warning is c3p0’s helper-thread pool falling behind because every checkout serializes on the pool monitor — precisely the contention HikariCP’s lock-free ConcurrentBag removes. This guide translates a c3p0 configuration into HikariCP parameter by parameter, performs the swap for both Spring and standalone apps, validates the result, and gives a clean rollback.

Rapid incident diagnosis

Before migrating, confirm the pool is actually the bottleneck so you migrate for the right reason.

  • Check c3p0’s JMX MBean numThreadsAwaitingCheckout. A non-zero, climbing value means callers are queued at the pool lock — a pooling problem, not a database problem.
  • Correlate against the database. Run SELECT count(*) FROM pg_stat_activity WHERE state = 'active';. If active sessions are far below maxPoolSize while threads await checkout, the wait is inside c3p0, not in the query.
  • Grep the logs for APPARENT DEADLOCK, unreturnedConnectionTimeout, and This connection has been closed. The first signals helper-thread saturation, the second leaks, the third missing keepalive/lifetime management.
  • Capture a thread dump during the spike (jstack). Many BLOCKED threads on com.mchange.v2.resourcepool.BasicResourcePool confirm lock contention on checkout.

If the database itself is saturated, migrating the pool will not help; fix sizing first. If the waits are inside c3p0, proceed.

Mathematical sizing / parameter formula

Do not copy c3p0’s maxPoolSize blindly. Re-derive the pool size, because c3p0 deployments are frequently oversized to mask checkout latency. Use the connection-count form of Little’s Law:

connections = arrival_rate (req/s) * service_time (s/req held)

For a service handling 800 req/s where each request holds a connection for 4 ms (0.004 s):

connections = 800 * 0.004 = 3.2  -> round up, add headroom -> ~6-8

A pool sized at 30 in c3p0 to fight lock contention typically collapses to single digits under HikariCP, because the lock-free borrow path removes the queuing that the oversized pool was compensating for. Cap the result against database CPU: a practical ceiling is (cores * 2) + effective_spindles. Oversizing past that point increases context switching and database-side contention without raising throughput. Start from the formula, then confirm with a load-test sweep as described in Measuring Connection Acquisition Latency Percentiles.

Exact remediation & configuration

Parameter translation table

This is the core of the migration. Beware the unit mismatch: c3p0 expresses several timers in seconds, HikariCP uses milliseconds throughout.

c3p0 parameter HikariCP parameter Conversion Meaning
maxPoolSize maximumPoolSize direct Hard ceiling on connections.
minPoolSize minimumIdle direct Idle floor (set explicitly — see note).
checkoutTimeout (ms) connectionTimeout (ms) direct Max wait for a free connection.
maxIdleTime (s) idleTimeout (ms) × 1000 Reclaim idle conns above minimumIdle.
maxConnectionAge (s) maxLifetime (ms) × 1000 Retire-and-replace ceiling.
idleConnectionTestPeriod (s) keepaliveTime (ms) × 1000 Soft-probe idle conns to beat reapers.
unreturnedConnectionTimeout (s) leakDetectionThreshold (ms) × 1000 Surface unreturned connections.
preferredTestQuery connectionTestQuery direct Usually unneeded on JDBC4 drivers.
initialPoolSize, acquireIncrement, numHelperThreads (none) drop HikariCP is fixed-size; no equivalents.

Critical note: HikariCP defaults minimumIdle to maximumPoolSize, producing a fixed-size pool. If you want a floating pool matching c3p0’s minPoolSize/maxPoolSize spread, set minimumIdle explicitly. For most services a fixed-size pool is preferable — it eliminates ramp jitter.

Before — c3p0

<bean id="dataSource" class="com.mchange.v2.c3p0.ComboPooledDataSource" destroy-method="close">
  <property name="jdbcUrl" value="jdbc:postgresql://db.internal:5432/app"/>
  <property name="user" value="app"/>
  <property name="password" value="${db.password}"/>
  <property name="maxPoolSize" value="30"/>
  <property name="minPoolSize" value="5"/>
  <property name="checkoutTimeout" value="3000"/>     <!-- ms -->
  <property name="maxIdleTime" value="300"/>          <!-- s  -->
  <property name="maxConnectionAge" value="1800"/>    <!-- s  -->
  <property name="idleConnectionTestPeriod" value="120"/> <!-- s -->
  <property name="unreturnedConnectionTimeout" value="30"/> <!-- s -->
</bean>

After — HikariCP (Spring XML)

<bean id="dataSource" class="com.zaxxer.hikari.HikariDataSource" destroy-method="close">
  <constructor-arg>
    <bean class="com.zaxxer.hikari.HikariConfig">
      <property name="jdbcUrl" value="jdbc:postgresql://db.internal:5432/app"/>
      <property name="username" value="app"/>
      <property name="password" value="${db.password}"/>
      <property name="maximumPoolSize" value="8"/>       <!-- re-sized via Little's Law -->
      <property name="minimumIdle" value="8"/>           <!-- explicit: fixed-size -->
      <property name="connectionTimeout" value="3000"/>  <!-- = checkoutTimeout -->
      <property name="idleTimeout" value="300000"/>      <!-- 300 s * 1000 -->
      <property name="maxLifetime" value="1800000"/>     <!-- 1800 s * 1000 -->
      <property name="keepaliveTime" value="120000"/>    <!-- 120 s * 1000 -->
      <property name="leakDetectionThreshold" value="30000"/> <!-- 30 s * 1000 -->
    </bean>
  </constructor-arg>
</bean>

After — Spring Boot (application.yml)

Spring Boot 2+ ships HikariCP as the default DataSource, so most of this is automatic once c3p0 is off the classpath:

spring:
  datasource:
    url: jdbc:postgresql://db.internal:5432/app
    username: app
    password: ${DB_PASSWORD}
    hikari:
      maximum-pool-size: 8
      minimum-idle: 8
      connection-timeout: 3000
      idle-timeout: 300000
      max-lifetime: 1800000
      keepalive-time: 120000
      leak-detection-threshold: 30000

After — standalone Java

HikariConfig cfg = new HikariConfig();
cfg.setJdbcUrl("jdbc:postgresql://db.internal:5432/app");
cfg.setUsername("app");
cfg.setPassword(System.getenv("DB_PASSWORD"));
cfg.setMaximumPoolSize(8);
cfg.setMinimumIdle(8);
cfg.setConnectionTimeout(3000);
cfg.setIdleTimeout(300_000);
cfg.setMaxLifetime(1_800_000);
cfg.setKeepaliveTime(120_000);
cfg.setLeakDetectionThreshold(30_000);
HikariDataSource ds = new HikariDataSource(cfg);

Step-by-step swap

  1. Add the dependency, keep c3p0 temporarily. Add com.zaxxer:HikariCP so both pools are present during the change; this lets you revert by switching a bean, not a build.
  2. Set maxLifetime below every upstream reaper. Keep it under the database idle_in_transaction_session_timeout, any PgBouncer/RDS Proxy idle limit, and any NAT/load-balancer TCP idle window — by at least 30–60 s.
  3. Translate every timer with the table above, converting seconds to milliseconds. This is where most migrations break.
  4. Set minimumIdle explicitly if you want a floating pool; otherwise accept the fixed-size default.
  5. Deploy to one instance (canary). Watch acquisition latency and error rate against the rest of the fleet still on c3p0.
  6. Remove c3p0 from the classpath once the canary is clean for a full peak cycle. For Hibernate, delete hibernate.c3p0.* properties; for Spring Boot, drop the c3p0/mchange-commons dependency so auto-config selects HikariCP.

Because step 1 keeps both pools available, the swap is a configuration switch and the apply is effectively zero-downtime on a rolling deploy.

Validation & verification

Confirm the new pool behaves before removing c3p0.

Database side — verify connection count tracks the new, smaller pool and that connections actually recycle at maxLifetime:

SELECT count(*), state
FROM pg_stat_activity
WHERE usename = 'app'
GROUP BY state;

-- connection age should never exceed maxLifetime (1800 s here)
SELECT max(now() - backend_start) AS oldest_conn
FROM pg_stat_activity
WHERE usename = 'app';

Application side — read HikariCP’s Micrometer gauges or JMX MBean:

hikaricp.connections.active   -> should stay below maximumPoolSize at peak
hikaricp.connections.pending  -> should be ~0; sustained >0 means undersized
hikaricp.connections.acquire  -> p99 should be sub-millisecond

Load-test assertion: re-run the concurrency sweep from before the migration. The pass criterion is a flat p99 acquisition latency as thread count rises — the contrast against c3p0’s climbing tail is the whole point of the migration, as detailed in the HikariCP vs c3p0 vs DBCP2 Benchmark. Pin GC out of the timing with JFR so a pause is not mistaken for pool latency.

Rollback: if the canary regresses, switch the dataSource bean back to ComboPooledDataSource (still on the classpath from step 1) and redeploy. No schema or driver change is involved, so rollback is a single bean swap. Investigate, then retry — the usual culprits are a missed seconds-to-milliseconds conversion or a maxLifetime set above an upstream idle reaper.

Frequently Asked Questions

Do I need to change my JDBC driver when moving from c3p0 to HikariCP?
No. Both pools wrap the same JDBC driver; you are only replacing the DataSource implementation. Keep the same driver version and JDBC URL. On a modern JDBC4 driver you can usually drop preferredTestQuery/connectionTestQuery entirely, since HikariCP uses Connection.isValid().
Why is my HikariCP pool so much smaller than my c3p0 pool was?
Large c3p0 pools are often inflated to hide checkout-lock contention. HikariCP’s lock-free borrow path removes that contention, so the pool sized by Little’s Law (arrival_rate * hold_time) is far smaller and performs better. Re-derive the size rather than copying maxPoolSize.
My connections still drop after being idle. What did I miss?
You likely did not set keepaliveTime, or your maxLifetime is above an upstream idle reaper (PgBouncer, RDS Proxy, NAT gateway, or the database’s own idle timeout). Set keepaliveTime below the reaper interval and maxLifetime at least 30–60 seconds under it so HikariCP retires connections before the upstream silently closes them.
Can I run c3p0 and HikariCP side by side during migration?
Yes, and you should. Keeping both on the classpath lets you canary one instance on HikariCP while the rest stay on c3p0, and makes rollback a single bean switch. Remove c3p0 only after a clean peak cycle, including deleting any hibernate.c3p0.* properties.
Why did maxConnectionAge seem to have no effect after migration?
Almost always a unit error. c3p0’s maxConnectionAge is in seconds; HikariCP’s maxLifetime is in milliseconds. A value of 1800 becomes 1.8 seconds in HikariCP, retiring connections almost immediately. Multiply every c3p0 seconds-based timer by 1000.