Slow Queries9 min read

MySQL Connections vs PostgreSQL Connections: Plan for a Pooler

MySQL gives every connection a thread; PostgreSQL gives every connection a process. That one difference changes capacity planning, pooling strategy, and what breaks under transaction pooling.

Of all the surprises waiting for a MySQL team migrating to PostgreSQL, connection handling is the one that bites earliest and most predictably. On MySQL, letting an application fleet open two or three thousand connections is unremarkable. Try the same thing against a fresh PostgreSQL instance and you will hit the default max_connections of 100 before the first load test finishes, raise it in a hurry, and then discover that raising it was not really the fix. I have watched this sequence play out enough times that I now put "choose a pooler" on the migration checklist before "choose an instance size."

Neither engine has the wrong design. They made different trade-offs, and the operational consequences flow directly from the architecture. Here is how I explain the difference to teams who have only run MySQL.

Threads versus processes: what actually differs

MySQL handles each client connection with a thread inside the single mysqld process. Threads share the address space, so an idle connection costs a thread stack and some per-session buffers, and the thread cache (thread_cache_size) makes connection churn cheap by recycling threads instead of creating them. The default max_connections is only 151, but production MySQL servers routinely run with limits in the thousands, and mostly get away with it because the per-connection overhead is comparatively small. Enterprise Edition and Percona Server go further with a thread pool plugin that decouples connections from execution threads entirely.

PostgreSQL forks a dedicated operating system process for every connection. The postmaster accepts the connection, forks a backend, and that backend lives for the life of the session with its own private memory for caches of catalog data, prepared plans, and per-operation work_mem allocations. Process creation is far more expensive than thread reuse, per-connection memory overhead is higher, and some cross-backend coordination costs scale with the number of backends. PostgreSQL 14 substantially improved snapshot scalability so large numbers of mostly-idle connections hurt much less than they used to, but the fundamentals stand: a PostgreSQL backend is a heavier object than a MySQL thread, and connection storms are more expensive because each new session is a fork, not a thread checkout.

What a MySQL veteran expects, and where it goes wrong

The habits that transfer badly are specific. First, sizing max_connections generously as a safety margin: on PostgreSQL, a max_connections of 2000 is not a harmless ceiling, because memory budgeting has to assume some real fraction of those backends running sorts and hashes with work_mem each, and because thousands of active backends genuinely contend. Second, treating reconnect-per-request application patterns as acceptable: MySQL's cheap connections forgive frameworks that open and close constantly, while PostgreSQL's fork cost makes the same pattern show up directly in latency. Third, assuming idle connections are free: an idle PostgreSQL backend still holds memory, and an idle-in-transaction backend is actively harmful because it can hold back vacuum cleanup for the whole cluster.

A reasonable direct-connection budget for PostgreSQL is measured in the low hundreds, sized against cores and memory rather than against how many app servers exist. The PostgreSQL sizing guide walks through the arithmetic; the short version is that connection count is a first-class capacity input on PostgreSQL in a way it rarely is on MySQL.

Why PostgreSQL needs a pooler earlier

Every serious PostgreSQL deployment beyond a handful of app instances ends up with a connection pooler between the application fleet and the database. The pooler maintains a small set of real backend connections and shares them across a much larger set of client connections. This is not an exotic optimization; it is the standard operating model, and pretending otherwise just delays the retrofit until an incident forces it.

MySQL teams sometimes read this as a PostgreSQL weakness, and there is a kernel of truth in that, but note that large MySQL shops deploy ProxySQL or thread pools for the same underlying reason: unbounded concurrency helps no database. The difference is where the pain threshold sits. MySQL tolerates thousands of connections before the ceiling matters; PostgreSQL wants the pooler at a scale most growing products reach quickly.

ProxySQL versus PgBouncer

If your mental model of pooling is ProxySQL, recalibrate slightly. ProxySQL is a full protocol-aware proxy: it multiplexes connections, routes reads and writes to different servers by query rules, rewrites queries, caches result sets, and handles failover topology. It is a Swiss army knife, and configuring it well is a project.

PgBouncer, the default choice in the PostgreSQL world, is deliberately minimal: a lightweight, single-purpose pooler with three modes. Session pooling assigns a server connection for the whole client session, which is safe but saves the least. Transaction pooling assigns a server connection only for the duration of each transaction, which is where the big multiplication factor comes from and what most deployments run. Statement pooling is stricter still and rarely appropriate. PgBouncer does not do query routing or rewriting; teams that need read/write splitting handle it in the application or add tooling on top. There are alternatives worth knowing: Odyssey and PgCat offer multi-threaded designs and, in PgCat's case, sharding and load balancing, and Supavisor targets very high client counts. But PgBouncer remains the boring, proven default, and boring is a virtue in this layer.

Transaction pooling gotchas

Transaction pooling achieves its efficiency by handing your session to a different backend from one transaction to the next, which means anything that lives at the session level silently breaks. The list is worth memorizing before the migration, not after the first confusing incident.

Prepared statements are the classic one. Protocol-level prepared statements, which most drivers use under the hood, historically failed under transaction pooling because the statement was prepared on one backend and executed on another. PgBouncer 1.21 added real support via the max_prepared_statements setting, tracking and re-preparing statements across backends, and it works well, but you must enable and size it deliberately. Session-level SET commands leak across clients or vanish between transactions; use SET LOCAL inside the transaction instead. Session-scoped advisory locks, LISTEN and NOTIFY, temporary tables, cursors held across transactions, and sequence state via currval all assume a stable backend and all misbehave. None of this is hidden, but every item on the list has cost some team a debugging afternoon.

Monitoring connection health in both worlds

On MySQL, the counters I watch are thread and connection status variables.

SELECT variable_name, variable_value
FROM performance_schema.global_status
WHERE variable_name IN (
  'Threads_connected',
  'Threads_running',
  'Threads_cached',
  'Connections',
  'Aborted_connects',
  'Max_used_connections'
);

Threads_running is the honest concurrency number; Threads_connected minus Threads_running is your idle overhead; Aborted_connects rising usually means an auth or network problem upstream.

On PostgreSQL, pg_stat_activity gives the equivalent view, and the state column is the part MySQL does not have a direct analog for.

SELECT state,
       count(*) AS sessions,
       max(now() - state_change) AS longest_in_state
FROM pg_stat_activity
WHERE pid <> pg_backend_pid()
  AND backend_type = 'client backend'
GROUP BY state
ORDER BY sessions DESC;

Active sessions tell you real concurrency. A large idle count is pooler-sizing feedback. Any idle-in-transaction session older than a few minutes deserves an alert, because it blocks vacuum from removing dead rows and can degrade the whole cluster while looking completely calm. Add the PgBouncer admin console counters, especially client wait time and pool saturation, and you have the full picture: clients can queue at the pooler while the database itself looks healthy, and only the pooler stats reveal it.

How MonPG helps once you are on PostgreSQL

MonPG is PostgreSQL-only, so it enters the story at cutover, not before. Where it earns its place is exactly the failure modes described above: it tracks connection states over time, flags idle-in-transaction sessions before they stall vacuum, correlates connection saturation with query latency and lock waits, and keeps the history you need to distinguish a genuine capacity problem from an application leaking sessions after a deploy. Connection incidents on PostgreSQL are almost always visible in retrospect; the trick is having the evidence recorded when they happen. The PostgreSQL monitoring guide covers the baseline worth having in place on day one of the migration, with connection behavior near the top of the list.