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 belowmaxPoolSizewhile threads await checkout, the wait is inside c3p0, not in the query. - Grep the logs for
APPARENT DEADLOCK,unreturnedConnectionTimeout, andThis 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). ManyBLOCKEDthreads oncom.mchange.v2.resourcepool.BasicResourcePoolconfirm 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
- Add the dependency, keep c3p0 temporarily. Add
com.zaxxer:HikariCPso both pools are present during the change; this lets you revert by switching a bean, not a build. - Set
maxLifetimebelow every upstream reaper. Keep it under the databaseidle_in_transaction_session_timeout, any PgBouncer/RDS Proxy idle limit, and any NAT/load-balancer TCP idle window — by at least 30–60 s. - Translate every timer with the table above, converting seconds to milliseconds. This is where most migrations break.
- Set
minimumIdleexplicitly if you want a floating pool; otherwise accept the fixed-size default. - Deploy to one instance (canary). Watch acquisition latency and error rate against the rest of the fleet still on c3p0.
- 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 thec3p0/mchange-commonsdependency 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?
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?
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?
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?
hibernate.c3p0.* properties.Why did maxConnectionAge seem to have no effect after migration?
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.Related
- HikariCP vs c3p0 vs DBCP2 Benchmark — the parent comparison and full cross-pool parameter map.
- HikariCP Configuration Deep Dive — full HikariCP parameter reference for post-migration tuning.
- Measuring Connection Acquisition Latency Percentiles — how to validate the migration with a percentile sweep.
- Pool Architecture & Algorithm Fundamentals — the parent overview of pool concurrency models.