Connection pooling is more critical with PostgreSQL than it is with SQL Server, and the reason is architectural. Each PostgreSQL client connection spawns a dedicated OS process on the server, making connections expensive to establish and memory-intensive to hold. Under load, exhausting that process limit is one of the most common scaling failures for .NET APIs backed by PostgreSQL.
This post walks through three layers of the solution: Npgsql's built-in client-side pool, the multiplexing mode introduced in Npgsql 7, and PgBouncer as a server-side proxy for high-concurrency deployments. By the end you will have a clear picture of which layer solves which problem and what configuration to use in a .NET 8 API.
Before starting, make sure you have:
- .NET 8 SDK or later
- PostgreSQL 14 or later
- Npgsql 8.x (the standalone driver) or Npgsql.EntityFrameworkCore.PostgreSQL 8.x
- PgBouncer installed locally or via Docker if you want to follow the PgBouncer section
max_connections is 100. Each connection consumes roughly 5–10 MB of RAM on the server. A single .NET API instance with a default Npgsql pool size of 100 can exhaust max_connections on its own, before any other clients connect.
Npgsql maintains a pool of physical connections per unique connection string. When your code opens a logical connection (DbContext method call, NpgsqlConnection.OpenAsync()), Npgsql hands out a connection from the pool rather than opening a new one. When the logical connection is closed or the DbContext is disposed, the physical connection returns to the pool for reuse.
Configuring the pool via NpgsqlDataSourceBuilder
In .NET 8, the recommended approach is to build an NpgsqlDataSource once at startup and register it with the DI container. This is more efficient than passing a raw connection string because Npgsql can reuse prepared statement caches and configuration across the application lifetime.
// Program.cs
var connectionString = builder.Configuration.GetConnectionString("Default")!;
var dataSource = new NpgsqlDataSourceBuilder(connectionString)
.Build();
builder.Services.AddSingleton(dataSource);
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseNpgsql(dataSource));
Key connection string pool parameters
// appsettings.json connection string with explicit pool settings
// "Server=localhost;Port=5432;Database=mydb;Username=api;Password=secret;
// Minimum Pool Size=2;Maximum Pool Size=20;
// Connection Idle Lifetime=300;Connection Pruning Interval=10"
var builder = new NpgsqlConnectionStringBuilder(connectionString)
{
MinPoolSize = 2, // Keep at least 2 connections open at all times
MaxPoolSize = 20, // Never exceed 20 physical connections per instance
ConnectionIdleLifetime = 300, // Close idle connections after 5 minutes
ConnectionPruningInterval = 10 // Check for idle connections every 10 seconds
};
Size the pool relative to your workload and the number of application instances. If you run 5 replicas each with MaxPoolSize=20, you need PostgreSQL's max_connections set above 100 (5 × 20), with headroom for admin connections. A common production formula is MaxPoolSize = max_connections / number_of_instances - buffer.
MaxPoolSize too high. More connections means more RAM consumed on the PostgreSQL server, more lock contention, and more context switching. A smaller pool with a short wait timeout is often faster than a large pool where the server is thrashing. Start at 20 per instance and tune from there.
Npgsql's multiplexing mode allows multiple concurrent commands to share a single physical connection through pipelining. Instead of each async operation waiting for an exclusive connection from the pool, multiplexing queues commands and sends them over shared connections, reducing the total number of physical connections needed under high concurrency.
Enabling multiplexing
var dataSource = new NpgsqlDataSourceBuilder(connectionString)
.EnableDynamicJson() // optional; keeps JSON handling flexible
.Build();
// Multiplexing is enabled via the connection string parameter
// Add "Multiplexing=true;Max Auto Prepare=20" to your connection string:
// "...;Multiplexing=true;Max Auto Prepare=20"
// Or configure it on the builder directly:
var csBuilder = new NpgsqlConnectionStringBuilder(connectionString)
{
Multiplexing = true,
MaxAutoPrepare = 20 // Npgsql auto-prepares the 20 most-used statements
};
var dataSource = new NpgsqlDataSourceBuilder(csBuilder.ConnectionString).Build();
When multiplexing helps and when it does not
Multiplexing reduces the connection count under high-concurrency, short-duration query workloads. It is most effective when many requests are running simple queries simultaneously, such as a read-heavy API endpoint with hundreds of concurrent users.
Multiplexing is not compatible with all Npgsql features. Do not enable it if your code uses any of the following, as they require exclusive connection ownership:
- Explicit transactions (
BeginTransactionAsync) - COPY operations
LISTEN/NOTIFY- Cursors or portal-based streaming
SaveChangesAsync(), multiplexing provides limited benefit and may cause errors. It is better suited to scenarios using raw NpgsqlCommand or Dapper without explicit transactions.
When you have many application instances (microservices, containerised replicas, serverless functions) each maintaining their own Npgsql pool, the total connection count against PostgreSQL can still grow beyond what the server handles efficiently. PgBouncer is a lightweight connection pooler that sits between your .NET applications and PostgreSQL, multiplexing many client connections into a smaller number of server connections.
PgBouncer pooling modes
Session mode assigns one server connection per client connection for the full session lifetime. This is the most compatible mode but provides the least reduction in server-side connections. Use it as a safe default when first introducing PgBouncer.
Transaction mode assigns a server connection only for the duration of a single transaction, then releases it immediately. This is the most efficient mode and provides the largest reduction in server-side connections, but it is incompatible with session-level features: prepared statements, advisory locks, SET settings, and temporary tables all break in transaction mode unless handled carefully.
Configuring Npgsql for PgBouncer transaction mode
// When using PgBouncer in transaction mode, disable Npgsql's prepared statement
// cache and set No Reset On Close to skip the session-cleanup command
var csBuilder = new NpgsqlConnectionStringBuilder(connectionString)
{
// Point at PgBouncer's port (default 6432), not PostgreSQL directly
Host = "pgbouncer-host",
Port = 6432,
// Disable server-side prepared statements (not supported in transaction mode)
MaxAutoPrepare = 0,
NoResetOnClose = true, // Skip "DISCARD ALL" on connection return
// Keep Npgsql's client pool small; PgBouncer owns the server-side pool
MaxPoolSize = 10
};
var dataSource = new NpgsqlDataSourceBuilder(csBuilder.ConnectionString).Build();
builder.Services.AddDbContext<AppDbContext>(o => o.UseNpgsql(dataSource));
Minimal PgBouncer configuration for a .NET API
; pgbouncer.ini
[databases]
mydb = host=postgres-host port=5432 dbname=mydb
[pgbouncer]
listen_port = 6432
listen_addr = *
auth_type = scram-sha-256
auth_file = /etc/pgbouncer/userlist.txt
pool_mode = transaction
max_client_conn = 1000 ; total clients PgBouncer accepts
default_pool_size = 25 ; server connections per database/user pair
server_reset_query = ; empty: skip DISCARD ALL in transaction mode
default_pool_size to the total server-side connection count you need to budget for.
After configuring pooling, verify the actual connection count on the PostgreSQL server to confirm your settings are having the intended effect.
-- Count active connections by application and state
SELECT
application_name,
state,
count(*) AS connection_count
FROM pg_stat_activity
WHERE datname = 'mydb'
GROUP BY application_name, state
ORDER BY connection_count DESC;
-- Check if any connections are waiting for a pool slot (wait_event_type = 'Client')
SELECT pid, wait_event_type, wait_event, query_start, state
FROM pg_stat_activity
WHERE wait_event_type = 'Client'
AND datname = 'mydb';
// In .NET 8, log Npgsql pool events to watch for pool exhaustion at startup
builder.Logging.AddFilter("Npgsql", LogLevel.Information);
// Pool exhaustion looks like this in logs:
// The connection pool has been exhausted, either raise MaxPoolSize
// or set ConnectionTimeout to a higher value.
Connection pooling with PostgreSQL and .NET requires thinking at three levels. Npgsql's client-side pool handles the common case for a single application instance: size it conservatively and use NpgsqlDataSourceBuilder for lifetime management. Multiplexing is a useful additional tool for raw command workloads with very high concurrency and no transactions. PgBouncer steps in when you have many application instances and need to cap the total server-side connection count below what PostgreSQL can efficiently handle.
The most important thing you can do before tuning is measure: check pg_stat_activity under realistic load, watch for pool exhaustion in logs, and size your pool relative to max_connections and the number of application replicas actually running.