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
| Parameter | Meaning | Default |
|---|---|---|
| Minimum Pool Size | Minimum connection pool size | 0 |
| Maximum Pool Size | Maximum connection pool size | 100 (since 3.1), 20 previously |
| Connection Idle Lifetime | Seconds a connection may stay idle before pruning | 300 |
| Connection Pruning Interval | How 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 /
NpgsqlDataSourceopens 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 callOpen().
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 ofusingis 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 thanConnection Idle Lifetime, down toMinimum 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?
| Min | Idle steady-state | Below Min? |
|---|---|---|
| 0 | 0 | Yes — an idle app holds zero server connections |
| 20 | 20 | No — 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