Tuesday, July 7, 2026

Npgsql Connection Pool Behavior — Empirical Findings

Tested against PostgreSQL 18.4 using .NET 10 and the Npgsql ADO.NET provider. Physical connections were counted server-side via pg_stat_activity, filtered by a distinct Application Name, using a separate unpooled observer connection.

Reference: https://www.npgsql.org/doc/connection-string-parameters.html

ParameterMeaningDefault
Minimum Pool SizeMinimum connection pool size0
Maximum Pool SizeMaximum connection pool size100 (since 3.1), 20 previously
Connection Idle LifetimeSeconds a connection may stay idle before pruning300
Connection Pruning IntervalHow often the pruning timer runs (seconds)10

TL;DR

  • On application/pool startup, Npgsql opens ZERO connections. Not the minimum, not the maximum.
  • Connections are created lazily, on demand, one per concurrent request, growing only as far as the workload needs — capped at Maximum Pool Size.
  • Minimum Pool Size is a pruning floor, not a warm-up target. Npgsql does not eagerly open Min connections at startup or on first use.
  • Idle connections are pruned back down to Minimum Pool Size after they exceed Connection Idle Lifetime. The pool never closes physical connections below Min.

How connections are created

Creation is purely on demand, driven by peak concurrent checkouts — not by the total number of Open() calls, and not by the configured Min/Max.

  • Constructing the pool / NpgsqlDataSource opens 0 connections.
  • Each OpenConnectionAsync() that occurs while previous connections are still held forces a new physical backend, because one physical connection serves only one logical connection at a time.
  • Opening → using → returning in a tight loop (await using) reuses a single backend, no matter how many times you call Open().

Ramp observed while holding connections open concurrently (Max=200):

holding 10 open  -> 10 backends
holding 30 open  -> 30 backends
holding 50 open  -> 50 backends

Max (200) was never approached — the cap only matters when concurrent demand exceeds it, at which point additional requests queue and wait for a free connection.

How connections are released

  • Dispose() / end of using is only a logical close — it returns the connection to the pool. The physical backend stays open.
  • A background pruning timer (Connection Pruning Interval) closes connections that have been idle longer than Connection Idle Lifetime, down to Minimum Pool Size.
  • Observed pruning as a single sweep to the floor (not gradual) with a short idle lifetime.

Experiment results

Both runs: ramp to 50 concurrent connections, release all, then poll every 2s. Idle Lifetime = 3s, Prune Interval = 1s (shortened from defaults to observe quickly).

Scenario A — Min=0, Max=200

t=  0.1s  backends=  0    pool constructed (no Open yet)
t=  0.3s  backends= 10    holding 10 open
t=  0.5s  backends= 30    holding 30 open
t=  0.6s  backends= 50    holding 50 open
t=  0.6s  backends= 50    released all 50 to pool
t=  2.6s  backends= 50    idle...
t=  4.7s  backends=  0    idle...   <-- pruned all the way to 0
... stays 0 ...

Scenario B — Min=20, Max=200

t=  0.1s  backends=  0    pool constructed (no Open yet)
t=  0.3s  backends= 10    holding 10 open      <-- 10, NOT 20 (Min is not pre-created)
t=  0.5s  backends= 30    holding 30 open
t=  0.7s  backends= 50    holding 50 open
t=  0.7s  backends= 50    released all 50 to pool
t=  2.7s  backends= 50    idle...
t=  4.7s  backends= 20    idle...   <-- pruned down to Min=20, then stops
... stays 20 forever ...

Can the pool shrink below Minimum Pool Size?

MinIdle steady-stateBelow Min?
00Yes — an idle app holds zero server connections
2020No — prunes from 50 down to exactly 20 and holds indefinitely

Practical guidance

  • Default (Min=0): an idle application eventually holds zero connections; the first request after an idle period pays cold-connect latency.
  • Set a non-zero Min (e.g. 20) to keep warm connections ready and avoid that latency — but note they still populate on demand under load, not instantly at process start. Min only guarantees they won't be pruned away once they exist.
  • Maximum Pool Size is a safety ceiling on concurrent server connections; it is never opened up-front. Size it to your expected peak concurrency (and mind PostgreSQL's own max_connections).
  • With real defaults (idle lifetime 300s), the same pruning happens but ~5 minutes after connections go idle, rather than the 3 seconds used in these tests.

Test harness notes

  • Console app (PoolTest/Program.cs) parameterized as: dotnet run -c Release -- <min> <max> <idleSec> <pruneSec> <concurrency>
  • Counting query (run over a separate unpooled connection):
    SELECT count(*) FROM pg_stat_activity WHERE application_name = 'pooltest_probe';

No comments:

Post a Comment