This article gives the formula published by the PostgreSQL wiki to calculate the ideal concurrency of your hardware (the most cited connection pool sizing formula in the ecosystem), explains why each connection costs more than an application thread, then details the procedure for setting max_connections without guessing. Our benchmark methodology documents the measurement protocol used for any performance claims on this blog.
The essentials
- Without pooling, max_connections should cover all concurrent client connections, not just those that Postgres can efficiently process in parallel.
- PostgreSQL wiki reference formula: ideal active concurrency = (physical cores × 2) + efficient disks. A starting point to be validated by measurement, not a hard limit.
- max_connections is a
postmastercontext parameter: changing it requires a complete restart of the server, not a simple reload. - Each Postgres connection is a separate system process, not a lightweight thread: this is what makes the overhead real as soon as the number of connections climbs.
- Verified in the code: on its dedicated Postgres clusters, Aurabase varies max_connections from 50 (free tier) to 400 (enterprise tier) depending on the size of the cluster.
Why a Postgres connection costs more than an application thread
Postgres does not use a lightweight thread pool for its connections. Each client connection triggers a full-fledged system process.
The postmaster process creates a new one (“fork”) for each connection attempt, dedicated to this single session until it is closed. The official project documentation describes precisely this mechanism in its chapter on architectural fundamentals (postgresql.org/docs/current/connect-estab.html, “Connection Semantics” section, accessed August 24, 2026).
This choice has a real advantage: a crash on one connection does not affect the others, each process being isolated from the rest of the server. It also has a direct cost: each additional connection adds an entire OS process to schedule, with its own memory space and its own context switching overhead for the kernel.
An application that opens 500 direct connections to Postgres without pooling forces the server to manage 500 simultaneous system processes, even if the vast majority of them remain idle between two requests.
Two distinct mechanisms impact memory, and confusing them almost always leads to a misdiagnosis.
The first is fixed. At startup, Postgres reserves shared memory structures (locks, process table) sized on the value of max_connections, whether or not these connections are subsequently opened. The official documentation for the setting explicitly points out: increasing it may require more system shared memory than your OS's default configuration allows (postgresql.org/docs/current/runtime-config-connection.html, accessed August 24, 2026).
The second is variable, and much more dangerous at scale: work_mem is not allocated once per connection, but once per sorting or hashing operation in the query plan. The official documentation is explicit on this point: a complex query can launch several of these operations in parallel, and several sessions can do the same simultaneously, so that the memory actually used can be worth several times work_mem (postgresql.org/docs/current/runtime-config-resource.html, accessed August 24, 2026).
It's not max_connections × work_mem alone that threatens a server's memory. It is max_connections × work_mem × number of concurrent operations per query. It is this product which explains a server which swaps, or which runs out of memory after an increase in max_connections considered innocuous.
The PostgreSQL wiki sizing formula
The official PostgreSQL project wiki documents a benchmark formula for calculating how many active connections your hardware can efficiently process in parallel, not how many connections open in total (wiki.postgresql.org/wiki/Number_Of_Database_Connections, accessed August 24, 2026).
ideal active concurrency = (physical cores × 2) + efficient disks. The number of cores excludes hyperthreading. The number of effective disks remains close to 1 on modern SSD storage, where the notion of a separate physical disk (“spindle”) loses much of its original meaning.
On a server with 8 physical cores and SSD storage, the formula gives (8 × 2) + 1 = 17 active connections before throughput begins to degrade. This figure is often surprising: it seems tiny compared to the hundreds of connections that an application opens in practice. This is precisely the subject of the following paragraph.
The number calculated by the formula measures the concurrency that the CPU and disk can absorb, not the number of client connections your application needs to open. A fleet of 20 application processes, each with its own pool of 10 connections, opens 200 simultaneous connections to Postgres even if only 17 of them are actively working at any given time. Without a pooler, max_connections must cover the 200, not the 17. It is this gap that pushes most architectures to add a pooler in transaction mode, even if it means choosing which one (see our comparison PgBouncer, Supavisor and PgCat).
How to change max_connections (and why a reboot is required)
max_connections is not hot swapped. This is a postmaster context parameter: Postgres reads it once, at startup, to size its shared memory. A configuration reload (pg_reload_conf() or SIGHUP) is not enough, you must restart the server.
First check the current value and its context, to confirm that a restart will be necessary:
Then apply the new value, then restart:
max_connections includes by default superuser_reserved_connections (3 by default): these connections are reserved for a superuser in case of saturation, they are never available for your application, even if the global counter is not yet reached.
How Aurabase budgets max_connections on its Postgres clusters
Sizing max_connections is not just a theoretical exercise. Here is how Aurabase budgets it on its managed Postgres clusters:
Dedicated clusters: one Postgres cluster per project
On this level (see our comparison dedicated vs shared base), each project receives its own CloudNativePG cluster and its own max_connections budget, sized with the size of the instance:
| free (dedicated) | max_connections 50 | 1 instance · 500m vCPU · 512Mi |
|---|---|---|
| pro (default) | max_connections 200 | 2 instances · 1 vCPU · 2Gi |
| team | max_connections 300 | 3 instances · 2 vCPU · 3Gi |
| business | max_connections 400 | 3 instances · 2 vCPU · 4Gi |
Shared clusters: several projects of an organization, a shared budget
On this second path, all the projects of the same organization connect via a CNPG pooler (PgBouncer, transactionmode) in front of a shared primary:
| free | max_connections 50 | max_client_conn 100 | max_user_connections 20 |
|---|---|---|---|
| pro | max_connections 100 | max_client_conn 200 | max_user_connections 60 |
| team | max_connections 200 | max_client_conn 400 | max_user_connections 150 |
All projects in an organization connect through a shared application role. max_user_connections therefore alone caps the total server connections that this role can open across the entire cluster: this is the real cluster-global safeguard, not max_client_conn, which only limits client connections to the pooler itself.
However, this pooler only serves SDK application traffic. PostgREST, for its part, remains directly connected to the primary (-rwservice): pooling in transaction mode would break its schema reloading mechanism, which listens to a dedicated LISTEN channel named pgrst. Its own connections (2 per replica on the shared level, 10 per replica on the dedicated level) therefore count directly in the max_connections budget of the primary, outside of any pooler, exactly the kind of “forgotten” connection that step 1 of the procedure below must include.
The code explicitly documents these shared pooler budgets as starting values to be calibrated in real conditions, by measuring pg_stat_activity under load, not as fixed figures from a published benchmark. This is the same discipline as described in our benchmark methodology: measure before adjusting, not guess then hope. These clusters run on PostgreSQL 16, a choice documented in our Postgres 16 vs 17 vs 18comparison.
The 5-step procedure to size max_connections without pooling
This procedure does not depend on any particular tool: it applies to any Postgres server, managed or self-hosted.
- Count your actual client connections. Number of application processes multiplied by the size of their internal pool, plus administration tools, replication and monitoring. It is this number, not the formula, that sets the max_connections floor.
- Calculate the ideal concurrency of your hardware with the formula from the PostgreSQL wiki: (physical cores × 2) + efficient disks. This figure indicates how many of these connections can actually work in parallel without degrading throughput.
- Set max_connections above the actual need for step 1, with margin for
superuser_reserved_connectionsand for any admin tools that open their own connections outside the application. - Apply the change with ALTER SYSTEM SET, then restart the server. This is a postmaster parameter: a simple reload is not enough, as detailed above.
- Monitor pg_stat_activity over time. If the number of idle connections greatly exceeds the number of active connections, this is not a max_connections problem: it is the signal that you need a pooler in front of the server, not a higher number.
The monitoring request from step 5, directly usable:
When the formula is no longer enough: the signs that you need a pooler
Three signals systematically return when max_connections alone is no longer sufficient, whatever its value.
- The
FATAL: sorry, too many clients alreadyerror appears during peak load, while the majority of connections displayed bypg_stat_activityare in the idle state. - The application runs in a serverless environment or with ephemeral workers (edge functions, short jobs), which open and close connections much faster than Postgres' process-per-connection model was designed to accommodate.
- The above formula and procedure have already been applied, and the actual need for client connections continues to exceed what available memory can allocate without endangering work_mem or shared_buffers.
In these three cases, the correct answer is almost always a pooler positioned between the application and Postgres, not a higher max_connections. Our comparison PgBouncer, Supavisor and PgCat details the three options, and our guide to transaction mode explains the most common compromise once the pooler is in place. For all Postgres tuning beyond connections, see our production Postgres tuning checklist.