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.
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.
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:
// 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;
# 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.
# 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.
Related
- Up: Python Driver Integration Patterns — the driver lifecycle and session model this pool serves.
- Batch Processing & Chunking Workflows — the worker concurrency and chunk sizing that dictate the pool bound.
- Async Neo4j Driver Patterns for Concurrent Loads — bounding concurrency with a semaphore instead of a larger pool.