At 10,000 concurrent connections, pgBouncer's single-threaded event loop became the bottleneck for a fintech platform we consulted on last year. Their PostgreSQL cluster had plenty of headroom — CPU at 30%, memory at 45% — but pgBouncer itself was pegged at 100% on a single core, spiking connection queue latency from 2ms to over 800ms during peak trading hours. Switching pooling strategy — not adding hardware — solved the problem in a week. Connection pooling sounds like solved infrastructure, but the choice of which pooler, and how it is configured, determines whether your database scales gracefully or becomes the invisible ceiling on your application's growth.
PostgreSQL has no built-in connection pool. Every connection spawns a backend process consuming roughly 5–10 MB of RAM, and context-switching across hundreds of them degrades query throughput. At 500 raw connections you feel it; at 2,000 you're in trouble without a pooler. Three projects dominate this space today: pgBouncer, the battle-hardened veteran; Odyssey, Yandex's high-concurrency multi-threaded answer; and pgcat, a Rust-based newcomer that adds load balancing and sharding awareness on top of pooling.
This guide gives you the production-grade comparison you need to pick the right tool — not just a feature matrix, but configuration examples, real tradeoffs, and a decision framework based on your connection count, SSL requirements, and topology.
- pgBouncer is the safest default for most teams: battle-tested, simple to operate, and excellent below 5,000 concurrent connections in transaction mode.
- Odyssey (Yandex) is multi-threaded and scales linearly with CPU cores — the right choice when pgBouncer's single thread becomes the bottleneck at high concurrency.
- pgcat is written in Rust, adds native load balancing and replica routing, and is Supabase's chosen pooler — ideal for sharded or multi-primary setups.
- All three support transaction-mode pooling, the most efficient mode for OLTP; statement mode is rarely appropriate for modern applications.
- TLS termination differs significantly: pgBouncer requires stunnel for full mutual TLS; Odyssey and pgcat handle TLS natively end-to-end.
- pgBouncer 1.22+ added significant improvements including prepared statement support in transaction mode and better cancel request handling.
Background: Why PostgreSQL Needs a Connection Pooler
PostgreSQL uses a process-per-connection model inherited from its origins in the early 1990s. When a client connects, PostgreSQL forks a new backend process. That process persists for the lifetime of the connection, holding memory for its working set, plan cache, and session state. This design is robust — a crashing backend cannot corrupt others — but it does not scale horizontally the way a thread-pool model does.
The practical ceiling depends on your server, but most teams hit degradation somewhere between 300 and 1,000 concurrent connections. PostgreSQL's own documentation recommends keeping max_connections at 100–200 for most workloads and using a pooler to fan in from your application tier. The three modes a pooler can operate in define the tradeoff between performance and feature compatibility:
- Session mode — one server connection per client session, held for the session lifetime. No performance benefit over direct connections; useful only for compatibility with session-level constructs (
SET LOCAL, advisory locks, temporary tables). - Transaction mode — server connection returned to the pool after each transaction commit or rollback. The sweet spot for OLTP: 1,000 application connections might require only 20–50 server connections. Some session-level features are unavailable.
- Statement mode — connection returned after every individual SQL statement. Incompatible with multi-statement transactions; use only for simple read-only analytics.
Transaction mode is the correct default for the vast majority of web applications. If your application relies heavily on prepared statements, advisory locks, or SET parameters that persist across transactions, audit those use cases before switching from session to transaction mode — or upgrade to pgBouncer 1.22+ which now handles prepared statements in transaction mode via a per-client cache.
pgBouncer Deep Dive
pgBouncer has been the de facto PostgreSQL connection pooler since 2007. It is written in C, single-threaded, uses libevent for asynchronous I/O, and is extraordinarily lightweight: a default installation consumes about 2 MB of RSS. It is maintained by the pgBouncer open-source community and is included in most Linux distribution package repositories.
Architecture
pgBouncer runs as a single process. All client connections and all server connections are handled by one event loop on one core. This design is simple to reason about, simple to debug, and has almost no operational surprises. It becomes a bottleneck only when the event loop itself — not PostgreSQL — is the limiting factor, which in practice means sustained loads exceeding roughly 5,000–8,000 active connections on a single instance.
pgBouncer 1.22+ Improvements
The 1.22 release (late 2023) was a landmark for pgBouncer adoption. The most significant addition is prepared statement support in transaction mode. Historically, applications using prepared statements (including most ORM-generated queries from Prisma, Hibernate, and SQLAlchemy) were forced into session mode. pgBouncer 1.22 introduces a per-client prepared statement cache that transparently maps client-side statement names to server-side statements across connections. This unblocks transaction mode for a large class of applications that previously could not use it.
Other 1.22+ improvements include better CancelRequest handling (cancel requests now reliably reach the correct backend), improved NOTIFY/LISTEN support, and tighter HBA (host-based authentication) rule enforcement.
Configuration
A production-ready pgBouncer configuration for transaction mode:
# /etc/pgbouncer/pgbouncer.ini
[databases]
# Map logical database name to the real PostgreSQL host
myapp = host=pg-primary.internal port=5432 dbname=myapp
[pgbouncer]
listen_addr = 0.0.0.0
listen_port = 5432
# Transaction mode — optimal for OLTP
pool_mode = transaction
# Maximum server-side connections to PostgreSQL per database+user pair
default_pool_size = 25
# Soft cap: allows burst above default_pool_size up to this limit
max_client_conn = 2000
# Reserve pool for times when default_pool_size is exhausted
reserve_pool_size = 5
reserve_pool_timeout = 3.0
# Server connection lifetime — recycle to avoid PostgreSQL bloat
server_lifetime = 3600
server_idle_timeout = 600
# Authentication
auth_type = scram-sha-256
auth_file = /etc/pgbouncer/userlist.txt
# TLS to PostgreSQL backend (client TLS requires stunnel in front)
server_tls_sslmode = require
server_tls_ca_file = /etc/ssl/certs/ca-certificates.crt
# Logging
log_connections = 0
log_disconnections = 0
log_pooler_errors = 1
# Admin interface
admin_users = pgbouncer_adminpgBouncer does not natively terminate TLS from clients in a way that supports client certificate authentication (mTLS). If your security requirements mandate mTLS from application to pooler, you must place a TLS proxy (stunnel, nginx, or HAProxy with SSL passthrough) in front of pgBouncer. Odyssey and pgcat both handle this natively.
When pgBouncer Shines
- Teams with fewer than 5,000 concurrent connections who want zero operational complexity.
- Environments where maturity, community support, and battle-tested behavior are the primary requirements.
- Deployments alongside PgBouncer-aware proxies like AWS RDS Proxy (which itself uses a pgBouncer-compatible protocol).
- Applications migrating from session to transaction mode thanks to the 1.22+ prepared statement cache.
Odyssey Deep Dive
Odyssey is a PostgreSQL connection pooler developed by Yandex and open-sourced in 2018. It was built specifically to address pgBouncer's single-threaded scalability ceiling, which Yandex hit at the scale of their internal services. Odyssey is written in C, uses a multi-threaded worker architecture, and supports full SSL/TLS termination natively — including client certificate authentication.
Architecture
Odyssey spawns a configurable number of worker threads, each running its own machinarium coroutine-based event loop. Client connections are distributed across workers using a consistent hashing scheme based on the database and user combination, which ensures that all connections for the same database+user pair land on the same worker — important for per-pool state management. This architecture scales near-linearly with CPU cores up to the point where PostgreSQL itself becomes the bottleneck.
Odyssey also supports logical pooling: multiple logical pool configurations can share an underlying set of server connections, allowing fine-grained per-user or per-database pool sizing without spawning separate processes.
Configuration
# /etc/odyssey/odyssey.conf
daemonize yes
unix_socket_dir "/tmp"
pid_file "/var/run/odyssey/odyssey.pid"
log_file "/var/log/odyssey/odyssey.log"
log_format "%p %t %l [%i %s] (%c) %m\n"
# Number of worker threads — typically set to CPU core count
workers 4
# Resolvers for async DNS lookups
resolvers 1
# Client TLS — full TLS termination including client certs
tls "require"
tls_ca_file "/etc/ssl/certs/ca-certificates.crt"
tls_cert_file "/etc/odyssey/server.crt"
tls_key_file "/etc/odyssey/server.key"
storage "pg_primary" {
type "remote"
host "pg-primary.internal"
port 5432
tls "require"
tls_ca_file "/etc/ssl/certs/ca-certificates.crt"
}
database "myapp" {
user "app_user" {
authentication "scram-sha-256"
password "hunter2"
storage "pg_primary"
storage_db "myapp"
storage_user "app_user"
# Transaction-mode pooling
pool "transaction"
pool_size 25
pool_timeout 0
pool_ttl 3600
pool_cancel yes
pool_rollback yes
# Client-side queue limits
client_max 5000
}
}Odyssey's workers setting should match the number of physical CPU cores available to the process, not logical hyperthreaded cores. On a 4-core server set workers 4. Increasing beyond physical core count adds context-switching overhead without throughput gains.
When Odyssey Shines
- High-traffic applications sustaining 5,000–50,000+ concurrent connections where pgBouncer's event loop would saturate a single core.
- Environments requiring native end-to-end TLS with client certificate authentication (mTLS) without an additional proxy layer.
- Teams already operating in ecosystems with Yandex tooling or deploying on Yandex Cloud.
- Setups needing per-user pool isolation at large scale with logical pooling.
Odyssey's community is smaller than pgBouncer's. Documentation is less extensive, and third-party integrations (Prometheus exporters, monitoring dashboards) are less mature. Budget time for operational setup that would be plug-and-play with pgBouncer.
pgcat Deep Dive
pgcat is a PostgreSQL connection pooler and proxy written in Rust, originally developed at Supabase and released as open source in 2022. It is now the default pooler powering Supabase's managed PostgreSQL platform. pgcat's distinguishing feature is that it combines connection pooling with intelligent load balancing and replica routing in a single binary — functionality that previously required a separate proxy like HAProxy or PgBouncer + pgpool-II.
Architecture
pgcat is built on Tokio, Rust's async runtime, and uses a single multi-threaded executor. It speaks the PostgreSQL wire protocol on both sides — client-facing and server-facing — allowing it to inspect query content for routing decisions. pgcat can parse SET ROLE, read/write split hints, and route SELECT statements to read replicas automatically. It also supports sharding: given a sharding key in the query or connection parameters, pgcat routes to the correct shard without application-layer awareness.
The Rust implementation provides memory safety without a garbage collector, which eliminates GC pause jitter that can appear in JVM-based proxies under sustained load.
Configuration
# /etc/pgcat/pgcat.toml
[general]
host = "0.0.0.0"
port = 5432
enable_prometheus_exporter = true
prometheus_exporter_port = 9930
# Worker threads
worker_threads = 4
# TLS — native, no external proxy needed
tls_certificate = "/etc/pgcat/server.crt"
tls_private_key = "/etc/pgcat/server.key"
[pools.myapp]
# Load-balanced across primary + replicas
pool_mode = "transaction"
default_role = "any" # "any" routes reads to replicas, writes to primary
query_parser_enabled = true # Parse queries to auto-detect read vs write
primary_reads_enabled = false
[pools.myapp.users.app_user]
password = "hunter2"
pool_size = 25
statement_timeout = 10000
[[pools.myapp.shards]]
servers = [
["pg-primary.internal", 5432, "primary"],
["pg-replica-1.internal", 5432, "replica"],
["pg-replica-2.internal", 5432, "replica"]
]
database = "myapp"pgcat's query_parser_enabled = true enables automatic read/write splitting: SELECT statements go to replicas, everything else goes to primary. This is a major operational simplification compared to configuring separate pgBouncer instances per role and managing the routing in application code or a separate proxy.
When pgcat Shines
- Architectures with read replicas where automatic read/write splitting reduces application complexity.
- Sharded PostgreSQL setups (horizontal partitioning across multiple servers) where shard-aware routing at the pooler layer avoids application-level scatter-gather.
- Teams already using Supabase or building on its ecosystem.
- Environments where a single binary replacing pooler + load balancer + replica router reduces operational surface area.
pgcat's query parser for read/write splitting is heuristic-based and can misclassify queries that read from sequences, use CTEs with side effects, or call functions with hidden writes. Always test your query patterns against pgcat's parser before enabling primary_reads_enabled = false in production. When in doubt, annotate queries with /* pgcat: primary */ hints to force routing.
Side-by-Side Comparison
| Feature | pgBouncer | Odyssey | pgcat |
|---|---|---|---|
| Language | C | C | Rust |
| Threading model | Single-threaded | Multi-threaded (configurable workers) | Multi-threaded (Tokio async) |
| Max connections (practical) | ~5,000–8,000 per instance | 50,000+ per instance | 50,000+ per instance |
| Pool modes | Session, Transaction, Statement | Session, Transaction | Session, Transaction |
| Native TLS (client-facing) | Basic (no mTLS without stunnel) | Full (mTLS supported) | Full (mTLS supported) |
| Load balancing | No | No | Yes (primary + replicas) |
| Read/write splitting | No | No | Yes (query parser) |
| Sharding support | No | No | Yes |
| Prepared statements (tx mode) | Yes (1.22+) | Partial | Yes |
| Auth methods | md5, scram-sha-256, trust, HBA | md5, scram-sha-256, cert, HBA | md5, scram-sha-256, trust |
| Prometheus metrics | Via exporter (pgbouncer_exporter) | Via third-party | Built-in |
| Maturity | Very high (since 2007) | High (since 2018) | Medium (since 2022) |
| Primary adopter | Broad industry standard | Yandex, high-scale PostgreSQL users | Supabase |
How to Choose
The right pooler depends on three variables: your connection count, your topology, and your operational appetite for less-mature tooling.
Choose pgBouncer if:
- Your peak concurrent connections stay below 5,000 and you are on a single primary (no replicas to load balance).
- You need the widest ecosystem support: most managed services, monitoring stacks, and PostgreSQL SaaS products have first-class pgBouncer integration.
- Your application uses prepared statements and you can run pgBouncer 1.22+ — the new transaction-mode prepared statement cache removes the historic blocker.
- Operational simplicity and a decade of production war stories are more valuable than raw concurrency headroom.
Choose Odyssey if:
- You are sustaining or expecting more than 5,000–8,000 concurrent connections and a single pgBouncer core is the measured bottleneck — not a theoretical concern.
- Your security posture requires mTLS from application to pooler without adding a TLS termination proxy to your stack.
- You are operating on infrastructure where multi-core utilization at the pooling layer directly maps to cost reduction.
Choose pgcat if:
- You have one or more read replicas and want automatic read/write splitting without application changes or a separate HAProxy tier.
- You are building a sharded PostgreSQL architecture and want shard routing to live in the pooler, not scattered across application services.
- You are building on Supabase or its ecosystem, where pgcat is the native pooler.
- You are comfortable adopting a younger project in exchange for a significantly expanded feature set.
Do not choose your pooler based on peak theoretical throughput benchmarks. Benchmark your specific workload — query mix, transaction length, connection churn rate — against each pooler on production-equivalent hardware. pgBouncer consistently outperforms its theoretical single-thread ceiling because real-world queries spend the majority of time waiting on PostgreSQL, not in the pooler's event loop.
Key Takeaways
- pgBouncer is the correct default for most production PostgreSQL deployments — mature, simple, and sufficient for the vast majority of connection loads below 5,000 concurrent clients.
- Transaction mode is the right pool mode for OLTP applications; pgBouncer 1.22+ removes the prepared statement barrier that historically forced teams into less efficient session mode.
- Odyssey solves the single-thread scalability ceiling with a multi-threaded architecture and native mTLS, but demands more operational investment than pgBouncer.
- pgcat is the only pooler that bundles connection pooling, load balancing, replica routing, and sharding in a single binary — a meaningful simplification for complex topologies.
- TLS handling is a meaningful differentiator: pgBouncer needs a sidecar for mTLS; Odyssey and pgcat handle it natively.
- All three support Prometheus metrics, but pgcat's built-in exporter requires no additional tooling, while pgBouncer and Odyssey rely on external exporters.
Working with JusDB on PostgreSQL Connection Pooling
Connection pooling configuration is one of the highest-leverage infrastructure changes available to a PostgreSQL team. Choosing the wrong pooler — or misconfiguring the right one — can silently cap your application's scalability regardless of how well-tuned your queries and indexes are. JusDB works with engineering teams to evaluate their current connection architecture, identify whether pooling is the active bottleneck, and implement and tune the pooler that fits their topology and growth trajectory.
Our PostgreSQL consulting engagements typically cover pooler selection and configuration alongside index strategy, query plan analysis, and replication topology. If your team is hitting connection limits, experiencing elevated latency under concurrent load, or planning a migration to a sharded or multi-replica architecture, we can help you get it right.
- Learn more about our PostgreSQL consulting services — including connection architecture, performance tuning, and managed migration support.
- Contact JusDB to discuss your specific connection pooling challenge with a database engineer.
Related Reading
- pgBouncer and PostgreSQL Connection Pooling: A Production Setup Guide — step-by-step configuration, authentication modes, and monitoring.
- Database Connection Pooling: The Complete Guide — pooling concepts, pool sizing formulas, and language-specific client configuration.
- PostgreSQL Performance Tuning: shared_buffers, work_mem, and Beyond — server-side tuning that complements connection pooling for maximum throughput.