Configuring connection pool size for the Neo4j Python driver

You are running a concurrent migration where a pool of workers each open a session and push a chunk of records into Neo4j, and throughput has plateaued or the run is throwing connection acquisition timed out errors. The cause is almost always a mismatch between the driver’s connection pool and your worker concurrency: too few connections and workers queue for one; too many and the server’s Bolt thread pool thrashes. This task shows how to set max_connection_pool_size, connection_acquisition_timeout, max_connection_lifetime, and keep_alive so the pool matches the real concurrency of a bulk load, and how to read the symptoms that tell you which way to move. This task is part of Python Driver Integration Patterns.

Prerequisites

The pool is a bounded lease pool

Every session borrows exactly one connection from the pool for the duration of its transaction and returns it on close. max_connection_pool_size caps how many connections can be held concurrently. When all connections are leased and another session asks for one, the driver blocks that session for up to connection_acquisition_timeout seconds, then raises. So the pool size is really an upper bound on effective write concurrency, and it must be read together with your worker count — not chosen in isolation.

Workers contending for a bounded pool Left column: six worker boxes. A bounded pool in the centre holds four connection slots, all leased. Four workers connect through to the database on the right. Two workers sit in a waiting queue governed by connection_acquisition_timeout; when it elapses a waiting worker raises an acquisition-timeout error. Worker 1 Worker 2 Worker 3 Worker 4 Worker 5 Worker 6 Pool (size 4) connection connection connection connection Acquire queue bounded by acquisition_timeout then raises Neo4j Bolt threads
Four workers hold the four connections; the other two wait until a lease frees or the acquisition timeout fires.

Core implementation

All four settings are passed at driver construction. The values below suit a load driven by a fixed worker pool; the comments explain what each one guards against.

python
from neo4j import GraphDatabase

# Concurrency the migration will actually run. Every other number keys off this.
WORKER_CONCURRENCY = 32

driver = GraphDatabase.driver(
    "neo4j://cluster.internal:7687",
    auth=("migrator", "…"),

    # 1) POOL SIZE. Upper bound on connections held at once = upper bound on
    #    concurrent sessions. Give it a small margin over worker concurrency so
    #    a briefly-held connection never blocks a ready worker. Too low → workers
    #    queue and time out; too high → the server's Bolt thread pool thrashes.
    max_connection_pool_size=WORKER_CONCURRENCY + 8,   # 40

    # 2) ACQUISITION TIMEOUT. How long a session waits for a free connection
    #    before raising. Keep it modest: a long wait hides an undersized pool
    #    behind latency instead of surfacing it as an actionable error.
    connection_acquisition_timeout=60.0,               # seconds

    # 3) MAX LIFETIME. Connections older than this are retired on return to the
    #    pool. Keeps the pool below load-balancer / proxy idle-cutoff windows so
    #    you never hand a worker a silently-dead socket.
    max_connection_lifetime=3600,                      # seconds (1 hour)

    # 4) KEEP-ALIVE. TCP keep-alive stops idle connections being dropped by an
    #    intermediary during quiet stretches between chunks.
    keep_alive=True,
)

driver.verify_connectivity()   # fail fast if the pool cannot reach the cluster

The single most important relationship is between max_connection_pool_size and WORKER_CONCURRENCY: the pool must be at least as large as the number of sessions held simultaneously, plus a small headroom for churn. Sizing the pool below concurrency is the classic self-inflicted bottleneck — you have 32 workers but only 16 connections, so half of them are always queued.

Validation & verification

Confirm the pool is correctly sized by watching both ends. On the client, an undersized pool shows up as acquisition-timeout exceptions and workers spending time blocked rather than executing. On the server, inspect the live connection count and compare it against your pool bound:

cypher
// Server-side view: how many Bolt connections this driver is actually holding.
// Should sit at or just below max_connection_pool_size under full load, and
// should NOT approach the server's configured Bolt thread capacity.
SHOW SETTINGS YIELD name, value
WHERE name IN ['server.bolt.thread_pool_max_size', 'db.transaction.concurrent.maximum']
RETURN name, value;
python
# Client-side smoke test: drive the pool at target concurrency and assert no
# acquisition timeouts. If this raises, the pool is undersized for the load.
import concurrent.futures as cf

def ping(_):
    with driver.session(database="neo4j") as s:
        return s.execute_read(lambda tx: tx.run("RETURN 1 AS ok").single()["ok"])

with cf.ThreadPoolExecutor(max_workers=WORKER_CONCURRENCY) as pool:
    assert all(r == 1 for r in pool.map(ping, range(WORKER_CONCURRENCY * 4)))

If the smoke test passes at target concurrency but the real load still times out, the pool is fine and the connections are being held too long per session — the fix is shorter transactions or smaller chunks, not a bigger pool.

Edge cases & gotchas

1. Oversizing the pool to “fix” timeouts. Raising max_connection_pool_size far above worker concurrency does not add throughput; it lets more connections pile onto the server’s Bolt thread pool, where they contend for the same CPU and lock capacity. If acquisition timeouts persist at a pool size equal to concurrency, the constraint is server-side, not pool-side — reduce concurrency instead.

python
# ANTI-PATTERN: pool far larger than concurrency just moves contention to the server
max_connection_pool_size=500     # with 32 workers — wasteful, and can overload Bolt threads
# BETTER: track concurrency with a small margin
max_connection_pool_size=WORKER_CONCURRENCY + 8

2. Lifetime longer than the load balancer’s idle cutoff. If a proxy or cloud load balancer silently drops idle TCP connections at, say, 350 seconds but max_connection_lifetime is an hour, the pool hands out dead sockets and workers fail on first use. Set max_connection_lifetime below the shortest idle cutoff in the network path.

3. Acquisition timeout masking the real limit. A very large connection_acquisition_timeout turns an undersized pool into slow, silent queuing rather than a clear error. Keep it modest so starvation surfaces as an actionable exception you can act on by resizing.