Your Postgres Connection Limit Is Not Your Concurrency Limit

Date:

A database that accepts a hundred connections is not a database that does useful work on a hundred things at once. On a four-core box, throughput stopped scaling at four — and the fix was to stop counting connections and start counting cores.

The short answer: max_connections is a ceiling — the point at which a new connection is refused — not a measure of how much work a database can do at once. That number is governed by your CPU cores. On a four-core box, read-only throughput plateaued by eight clients and read-write throughput peaked at four and then fell, while latency climbed roughly a hundredfold. So don’t raise the connection limit to cure a slowdown: cap concurrent heavy jobs near the core count, leave cheap read-only and network-bound queries uncounted, and spend any spare tuning budget on memory instead.

A small data pipeline runs on a self-hosted PostgreSQL on a four-core developer workstation. When several heavy jobs — bulk transforms over a shared set of tables — happened to run at the same time, the machine pinned all four cores at 100% and everything slowed to a crawl. The obvious diagnosis was that the database was running out of connections. The obvious fix was to raise max_connections, or to add more worker processes so the work would finish sooner.

Both were wrong, in the same way, for the same reason.

The connection limit is a ceiling, not a target

max_connections defaults to

  1. It is a hard limit: connection 101 is refused outright. It is

easy to read that number as “how much concurrency the database supports,” and therefore to treat a slowdown as evidence you are near it. But the connection ceiling and the point where more concurrency stops helping are two completely different numbers, and on most hardware the second is far below the first. Raising the ceiling moves the wrong one.

The number that actually governs throughput is the core count. A query that is executing — not waiting on the network, actually computing — needs a core. Past the point where every core is busy, each additional concurrent query does not get its own core; it time-slices one, adds a context switch, and, if it writes, contends for the same locks and the same write-ahead log as the others. You do not buy throughput. You spend it.

You do not buy throughput. You spend it.

The measurement

Rather than argue about it, sweep it. On the four-core box, an isolated scratch database, pgbench driving a rising number of concurrent clients — first read-only (SELECT only, the purest measure of compute-bound throughput), then read-write (the standard mixed transaction, which adds lock and WAL contention). Six seconds per level; throughput and average latency recorded at each.

Read-only throughput climbs to the core count, tops out around twice it, and then goes flat.

Read-only PostgreSQL throughput versus concurrent clients on a four-core host. Transactions per second rise steeply from about 3,700 at one client to about 8,500 at four, peak near 8,800 at eight clients, then flatten and drift slightly down through 16, 32 and 48 clients. A dashed line marks eight clients as twice the core count.

One client did about 3,700 transactions per second. Four — the core count — did about 8,500, most of the total gain. Eight squeezed out a peak near 8,800. After that, adding clients did nothing: 16, 32 and 48 all sat between 7,700 and 8,400. The database accepted every one of those connections happily. It just had no more cores to run them on.

The database accepted every connection happily — it just had no more cores to run them on.

Read-write is sharper: it peaks at the core count and then actively declines.

Read-write PostgreSQL throughput versus concurrent clients on a four-core host. Transactions per second peak at about 1,046 at four clients, then decline through eight, twelve and sixteen clients, recover partially near thirty-two, and collapse to about 207 at forty-eight. A dashed line marks four clients as the core count.

Here the peak — about 1,046 transactions per second — landed exactly at four clients. Beyond it, throughput fell: the writers were fighting each other for locks and for the write-ahead log, and that contention subtracts from the total. This is the curve that matters, because real workloads write.

The part that does not show up in a throughput average: latency

Throughput going flat sounds harmless — you are not losing work, just not gaining it. Latency tells the real story. It is the tax you pay for every connection past the knee, and it is not linear.

Average transaction latency versus concurrent clients for both workloads on a logarithmic scale. Read-only latency rises gently from about 0.3 milliseconds at one client to about 6 milliseconds at forty-eight. Read-write latency rises from about 2 milliseconds to about 231 milliseconds at forty-eight, a roughly hundredfold increase, with the steep climb beginning at the four-client core-count mark.

Read-write latency went from about 2 milliseconds at one client to 231 milliseconds at 48 — a hundredfold increase — for a workload that was, by then, doing less total work than it did at four clients. Per-client throughput told the same story from the other side: it collapsed from 3,700 transactions per second per client at one client to 161 at 48, a 23× degradation. Every extra connection made every connection slower.

None of this is a Postgres limitation, and none of it is fixed by a bigger connection ceiling. It is what happens when you ask four cores to run more than four things.

What to cap instead

The corrected mental model is a one-liner: size concurrency to cores, not to the connection ceiling. Concretely, for this pipeline:

  • Cap concurrent heavy jobs at roughly the core count. Not with max_connections — that only refuses the overflow at the door, it does not keep you off the inefficient part of the curve. A small connection-count semaphore does the job: a heavy job tags its database connection, and a new heavy job checks how many such tags are already active and refuses to start if the machine is already full (with an override for the rare case you mean it). Cheap read-only queries are not counted — they are not what saturates the cores.
  • For a network-bound job, the database is not the limit at all. A crawler or an API poller holds one connection and spends its life waiting on the network. Ten of those will not trouble the database; the thing to limit there is politeness to the remote host, not local connections.
  • If you want more headroom, tune memory before you touch concurrency. Against a fixed core count, more connections cannot help; giving the working set more room to stay resident can. On a memory- constrained shared box, a moderate shared_buffers bump (the stock 128 MB is low) buys real headroom — but on Windows, and on a machine also running other services, moderate is the operative word, and the change needs a restart. The core count is still the ceiling; memory just stops you re-reading the same hot pages off disk. If you are consistently core-bound, the honest fix is more cores — a hosting decision, not a configuration one.

Size concurrency to cores, not to the connection ceiling.

The short version

  • max_connections is a ceiling, not a target. It says how many clients may connect before the database refuses them — not how many can do useful work at once.
  • Cores set the real limit. On the four-core box, read-only throughput plateaued by eight clients and read-write throughput peaked at four and then declined; latency climbed roughly a hundredfold past the knee while total work fell.
  • Cap concurrent heavy jobs near the core count — with a small connection-count semaphore, not a bigger max_connections. Leave cheap read-only and network-bound queries uncounted.
  • Spend spare tuning budget on memory, not connections. Against a fixed core count more connections cannot help; a moderate shared_buffers bump can.
  • The same counting error shows up well beyond databases — in CI, for one, where more parallel jobs don’t mean faster builds.

Frequently Asked Questions

Does raising max_connections ever help?

Only when you are genuinely being refused — errors saying the connection limit is reached — and even then the right fix is usually a connection pooler in front of the database, not a higher ceiling. Raising it to escape a slowdown (rather than an outright refusal) moves a number that was not the bottleneck and can make things worse, because every allowed connection reserves memory.

How many concurrent connections should a small server allow to be efficient?

For CPU-bound work, efficiency peaks around the core count and tops out near twice it; past that, throughput is flat and latency climbs. A common starting point for active connections is two to four times the cores, tuned by measurement — but the useful ceiling is much closer to the core count than to the default max_connections of 100. Measure your own workload; the knee is easy to find with a short sweep.

Is this specific to PostgreSQL?

No. The shape is a property of finite cores and shared locks, not of one database engine. Any CPU-bound, contention-prone workload shows the same knee — throughput flattening near the core count and latency rising past it. The connection-limit-versus-core-count confusion is just especially common with databases because the connection number is so visible.

What about adding more worker processes to finish sooner?

That is the same mistake wearing different clothes. More workers past the core count do not finish sooner; they finish at the same total rate while each takes longer, and a write-heavy job finishes later because of the added contention. Fewer workers, run to completion, is faster than many workers thrashing.

Share post:

Subscribe

Popular

More like this
Related

How to Merge Spreadsheets by URL (and Why Our Tool Reported 5 Misses That Weren’t)

Matching two spreadsheets on a URL column is the same job as matching a stock list to a price list by product code. It goes wrong in the same two places: how you clean the code, and how you count what didn't match.

Many-to-One: Why a Mapping Backlog Outgrows Its Target Taxonomy

You're mapping a messy source vocabulary onto a clean target taxonomy. The target has about 5,600 slots, so the job must be roughly 5,600 decisions — right? The backlog is already past 8,900, and still climbing.

The Fix That Didn’t Take: When a Content-Hash Gate Skips Your Code Change

You shipped a fix to how one field is computed, re-ran the pipeline across everything, and it reported a clean run. Then the consistency check still flags those records as wrong. The fix is in the code, the rebuild "ran" — and the records never changed. Your own optimisation skipped them.

Your Dedup Scan Is Over-Counting — Because It Forgot What You Already Fixed

The report said 80 duplicate groups still to resolve. You'd been resolving them for days. You run it again after a big batch and it *still* says 80-ish. The backlog isn't stubborn — the scan is counting work you already finished.