Loading content...
Application-level connection pools work well for single-process applications, but modern production systems rarely consist of a single process. When you have 50 Kubernetes pods, 100 serverless functions, or 20 microservices all connecting to the same database, application-level pooling becomes insufficient—or even harmful.
The problem:
Each application instance maintains its own pool. If each has 20 connections:
The solution: External connection poolers sit between applications and databases, aggregating many application connections into a smaller number of database connections. This is where PgBouncer (PostgreSQL) and ProxySQL (MySQL) become essential infrastructure.
By the end of this page, you will understand PgBouncer and ProxySQL architectures, pooling modes, production configurations, deployment patterns, and operational considerations. You'll know when to deploy external poolers and how to configure them for different workload patterns.
PgBouncer is a lightweight connection pooler for PostgreSQL. Despite its simple design, it's battle-tested at massive scale—used by GitLab, Discord, and many other high-traffic PostgreSQL deployments.
Architecture:
PgBouncer operates as a proxy between applications and PostgreSQL. Applications connect to PgBouncer (typically on port 6432), which then maintains a pool of connections to the actual database.
123456789101112131415161718192021222324252627
┌─────────────────────────────────────────────────────────────────┐│ Applications │├───────────┬──────────┬──────────┬──────────┬──────────┬────────┤│ Pod 1 │ Pod 2 │ Pod 3 │ Pod 4 │ Pod 5 │ ... ││ (pool:5) │ (pool:5) │ (pool:5) │ (pool:5) │ (pool:5) │ │└─────┬─────┴────┬─────┴────┬─────┴────┬─────┴────┬─────┴────────┘ │ │ │ │ │ └──────────┴──────────┼──────────┴──────────┘ │ 50+ application connections │ ▼ ┌───────────────────────────────┐ │ PgBouncer │ │ Connection Multiplexer │ │ │ │ Maintains ~30 DB connections │ │ for 500+ app connections │ └─────────────┬─────────────────┘ │ 30 database connections │ ▼ ┌───────────────────────────────┐ │ PostgreSQL Server │ │ (max_connections = 100) │ └───────────────────────────────┘Key PgBouncer features:
PostgreSQL forks a new process for each client connection (unlike MySQL's thread-per-connection). This makes PostgreSQL connections particularly expensive—each process consumes 1-10MB of memory and adds context switching overhead. PgBouncer is almost universally recommended for production PostgreSQL deployments with more than a few application instances.
PgBouncer offers three pooling modes, each with different tradeoffs. Choosing the right mode depends on your application's usage patterns.
Session Pooling (pool_mode = session):
1234567891011
Client connects → Gets dedicated server connection All queries use SAME server connection Prepared statements work ✓ Session variables work ✓ SET statements persist ✓ LISTEN/NOTIFY works ✓Client disconnects → Server connection returned to pool Efficiency: LOW (1:1 client:server while connected)Compatibility: HIGH (full PostgreSQL features)Best for: Applications requiring session stateTransaction Pooling (pool_mode = transaction):
1234567891011121314151617
Client connects → No server connection yetClient starts transaction → Gets server connection All queries in transaction use SAME connection Can use prepared statements WITHIN transaction ✓ SET LOCAL works within transaction ✓Transaction ends → Server connection returned to poolClient can do another transaction → Gets potentially DIFFERENT server Efficiency: HIGH (many clients share few servers)Compatibility: MEDIUM (no session state between transactions)Best for: Most web applications, stateless query patterns LIMITATIONS:- Prepared statements not shared across transactions- SET (without LOCAL) doesn't persist- LISTEN/NOTIFY doesn't work- Advisory locks don't persistStatement Pooling (pool_mode = statement):
12345678910111213
Client sends query → Gets server connection for ONE queryQuery completes → Server connection immediately returnedNext query → May get DIFFERENT server connection Efficiency: HIGHEST (maximum connection sharing)Compatibility: LOW (no multi-statement transactions!) SEVERE LIMITATIONS:- Transactions NOT supported (autocommit only)- Each statement may run on different server- Only for simple, independent queries Best for: Simple analytics dashboards, read-only APIs| Mode | Efficiency | Transactions | Prepared Stmts | Session State | Use Case |
|---|---|---|---|---|---|
| Session | Low (1:1) | Full support | Works | Preserved | Legacy apps, full compatibility |
| Transaction | High (N:1) | Works | Per-transaction | Not preserved | Most web apps (recommended) |
| Statement | Highest | Not supported | Not supported | Not preserved | Simple read queries only |
Transaction mode is the default recommendation for most applications. It provides excellent connection efficiency while supporting transactions—the most common pattern in web applications. Only use session mode if your application specifically requires session-level features like prepared statements cached across transactions or LISTEN/NOTIFY.
A well-configured PgBouncer instance can handle thousands of connections reliably. Here's a production-grade configuration with explanations.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263
;; Database configuration[databases]; Format: logical_name = connection_stringmyapp = host=primary.db.internal port=5432 dbname=myappmyapp_readonly = host=replica.db.internal port=5432 dbname=myapp ; Wildcard for dynamic routing (use with caution); * = host=db.internal port=5432 [pgbouncer];;; Connection settingslisten_addr = 0.0.0.0 ; Listen on all interfaceslisten_port = 6432 ; Standard PgBouncer portunix_socket_dir = /var/run/pgbouncer ;;; Authenticationauth_type = scram-sha-256 ; Strong auth (or md5 for compatibility)auth_file = /etc/pgbouncer/userlist.txt; auth_query for auth from database (preferred for many users):; auth_query = SELECT usename, passwd FROM pg_shadow WHERE usename=$1 ;;; Pool configurationpool_mode = transaction ; Transaction pooling (recommended)default_pool_size = 25 ; Connections per user/database pairmin_pool_size = 5 ; Minimum connections to keep openreserve_pool_size = 5 ; Extra connections for peaksreserve_pool_timeout = 3 ; Seconds before using reserve ;;; Connection limitsmax_client_conn = 1000 ; Maximum client connectionsmax_db_connections = 50 ; Maximum connections to each databasemax_user_connections = 50 ; Maximum connections per user ;;; Timeoutsserver_connect_timeout = 10 ; Seconds to connect to serverserver_login_retry = 3 ; Seconds between connection retriesserver_lifetime = 3600 ; Seconds before server conn recycledserver_idle_timeout = 600 ; Seconds before idle server closedclient_idle_timeout = 0 ; 0 = no client idle timeoutclient_login_timeout = 60 ; Seconds for client auth ;;; Query handlingquery_timeout = 0 ; 0 = no query timeout (handled by app)query_wait_timeout = 120 ; Seconds to wait for server connection ;;; TLS configurationserver_tls_sslmode = require ; Encrypt to PostgreSQL; server_tls_ca_file = /etc/ssl/certs/ca.pemclient_tls_sslmode = require ; Encrypt from applicationsclient_tls_key_file = /etc/pgbouncer/server.keyclient_tls_cert_file = /etc/pgbouncer/server.crt ;;; Logging and monitoringlog_connections = 1log_disconnections = 1log_pooler_errors = 1stats_period = 60 ; Stats logging intervaladmin_users = pgbouncer_admin ; Users allowed admin access ;;; Process settingspidfile = /var/run/pgbouncer/pgbouncer.pidlogfile = /var/log/pgbouncer/pgbouncer.log; For systemd: logfile = /dev/null and use journald1234567
; Format: "username" "password_hash"; Generate hash: echo -n "password" | md5sum, prefix with "md5"; Or for scram-sha-256, copy from PostgreSQL pg_authid "appuser" "SCRAM-SHA-256$4096:salt$client_key:server_key""readonly" "SCRAM-SHA-256$4096:salt$client_key:server_key""admin" "SCRAM-SHA-256$4096:salt$client_key:server_key"max_db_connections is the key setting—this limits how many connections PgBouncer opens to your database. Set it to roughly (database_cores × 2), leaving room for admin connections. max_client_conn can be much higher since PgBouncer is efficient with client connections.
ProxySQL is a high-performance MySQL proxy and connection pooler. It's significantly more feature-rich than PgBouncer, offering query caching, query routing, and sophisticated traffic management.
Architecture:
ProxySQL sits between applications and MySQL servers, providing connection multiplexing plus additional features like read/write splitting and query caching.
ProxySQL vs PgBouncer:
| Feature | ProxySQL (MySQL) | PgBouncer (PostgreSQL) |
|---|---|---|
| Connection pooling | Yes | Yes |
| Read/write splitting | Yes (built-in) | No (use separate entries) |
| Query caching | Yes | No |
| Query rewriting | Yes | No |
| Runtime reconfiguration | Yes (SQL interface) | Yes (RELOAD) |
| Resource usage | Moderate | Very low |
| Complexity | Higher | Lower |
| Monitoring | Rich metrics | Basic stats |
Use ProxySQL for MySQL when you need connection pooling plus any of: read/write splitting, query caching, query routing rules, or sophisticated traffic management. For basic connection pooling only, MySQL's thread pool plugin or application-level pooling may suffice.
ProxySQL configuration is done through its admin interface using SQL commands. Here's a complete setup for a typical production deployment with read/write splitting.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990
-- Connect to ProxySQL admin interface-- mysql -u admin -padmin -h 127.0.0.1 -P 6032 --prompt='ProxySQL> ' -- =====================================================-- STEP 1: Configure Backend MySQL Servers-- ===================================================== -- Add primary server (hostgroup 0)INSERT INTO mysql_servers ( hostgroup_id, hostname, port, weight, max_connections, max_replication_lag) VALUES (0, 'primary.db.internal', 3306, 1000, 100, 0); -- Add replica servers (hostgroup 1 for reads)INSERT INTO mysql_servers ( hostgroup_id, hostname, port, weight, max_connections, max_replication_lag) VALUES (1, 'replica1.db.internal', 3306, 1000, 100, 10), (1, 'replica2.db.internal', 3306, 1000, 100, 10); -- =====================================================-- STEP 2: Configure MySQL Users-- ===================================================== -- Add application user (frontend)INSERT INTO mysql_users ( username, password, default_hostgroup, max_connections, transaction_persistent) VALUES ('appuser', 'hashed_password', 0, 500, 1); -- =====================================================-- STEP 3: Configure Query Rules for Read/Write Split-- ===================================================== -- Route SELECT queries to read hostgroup (1)INSERT INTO mysql_query_rules ( rule_id, active, match_pattern, destination_hostgroup, apply) VALUES (10, 1, '^SELECT.*FOR UPDATE', 0, 1), -- FOR UPDATE goes to primary (20, 1, '^SELECT', 1, 1); -- Other SELECTs to replicas -- Everything else goes to default hostgroup (0 = primary) -- =====================================================-- STEP 4: Global Variables Configuration-- ===================================================== -- Connection pooling settingsUPDATE global_variables SET variable_value='true' WHERE variable_name='mysql-connection_pooling_enabled'; -- Connection limitsUPDATE global_variables SET variable_value='2000' WHERE variable_name='mysql-max_connections'; -- Connection timeoutsUPDATE global_variables SET variable_value='10000' WHERE variable_name='mysql-connect_timeout_server';UPDATE global_variables SET variable_value='30000' WHERE variable_name='mysql-wait_timeout'; -- Multiplexing configurationUPDATE global_variables SET variable_value='true' WHERE variable_name='mysql-multiplexing';UPDATE global_variables SET variable_value='true' WHERE variable_name='mysql-connection_pooling_enabled'; -- Query caching (optional)UPDATE global_variables SET variable_value='true' WHERE variable_name='mysql-query_cache_size_MB'; -- =====================================================-- STEP 5: Apply Configuration-- ===================================================== -- Load configuration to runtimeLOAD MYSQL USERS TO RUNTIME;LOAD MYSQL SERVERS TO RUNTIME;LOAD MYSQL QUERY RULES TO RUNTIME;LOAD MYSQL VARIABLES TO RUNTIME; -- Save to disk for persistence across restartsSAVE MYSQL USERS TO DISK;SAVE MYSQL SERVERS TO DISK;SAVE MYSQL QUERY RULES TO DISK;SAVE MYSQL VARIABLES TO DISK;ProxySQL's killer feature is runtime reconfiguration. You can add/remove servers, change query rules, and update users without restarting or disconnecting clients. This enables zero-downtime maintenance and dynamic scaling.
Major cloud providers offer managed connection pooling proxies, eliminating operational overhead for teams using their database services.
AWS RDS Proxy:
1234567891011121314151617
// Connection string uses RDS Proxy endpoint instead of direct RDSconst connectionString = `postgresql://${process.env.DB_USER}:${process.env.DB_PASSWORD}@myproxy.proxy-xxx.us-east-1.rds.amazonaws.com:5432/myapp`; // With IAM authenticationimport { RDSDataClient, ExecuteStatementCommand } from "@aws-sdk/client-rds-data"; const client = new RDSDataClient({ region: "us-east-1" }); // Using Data API (no connection management needed)const command = new ExecuteStatementCommand({ resourceArn: "arn:aws:rds:us-east-1:123456789:cluster:mycluster", secretArn: "arn:aws:secretsmanager:us-east-1:123456789:secret:mydbsecret", sql: "SELECT * FROM users WHERE id = :id", parameters: [{ name: "id", value: { longValue: userId } }],}); const result = await client.send(command);Other cloud options:
| Provider | Service | Databases | Key Features |
|---|---|---|---|
| AWS | RDS Proxy | PostgreSQL, MySQL | Serverless integration, IAM auth, automatic failover |
| Google Cloud | Cloud SQL Auth Proxy | PostgreSQL, MySQL, SQL Server | Secure tunnel, IAM auth, sidecar pattern |
| Azure | Built into Flexible Server | PostgreSQL, MySQL | PgBouncer built-in, simple enable/disable |
| Prisma | Prisma Accelerate | Multiple | Edge caching, connection pooling, query caching |
| PlanetScale | Built-in | MySQL (Vitess) | Automatic horizontal scaling, serverless-native |
Use managed poolers when: using serverless functions, running on that cloud provider, want minimal operational overhead, budget allows premium pricing. Self-host PgBouncer/ProxySQL when: running on-premises, cost-sensitive, need fine-grained control, cross-cloud scenarios, or using features not in managed offerings.
There are several ways to deploy external connection poolers, each with tradeoffs.
Pattern 1: Standalone Pooler Cluster
Dedicated servers/pods running the pooler, separate from applications.
12345678910111213141516171819202122232425
┌─────────────┐ ┌─────────────┐ ┌─────────────┐│ App Pod │ │ App Pod │ │ App Pod │└──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │ │ │ └────────────────┼────────────────┘ │ ┌─────────┴─────────┐ │ Load Balancer │ └─────────┬─────────┘ │ ┌────────────────┼────────────────┐ │ │ │┌──────┴──────┐ ┌──────┴──────┐ ┌──────┴──────┐│ PgBouncer │ │ PgBouncer │ │ PgBouncer ││ Pod 1 │ │ Pod 2 │ │ Pod 3 │└──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │ │ │ └────────────────┼────────────────┘ │ ┌─────────┴─────────┐ │ PostgreSQL │ └───────────────────┘ Pros: Centralized management, clear separationCons: Additional load balancer, network hopPattern 2: Sidecar Pattern
Pooler runs as a sidecar container alongside each application pod.
12345678910111213141516
┌────────────────────────────────────────────────────┐│ Kubernetes Pod ││ ┌──────────────────────┐ ┌────────────────────┐ ││ │ App Container │ │ PgBouncer Sidecar │ ││ │ ├──┤ │ ││ │ connects to │ │ connects to DB │ ││ │ localhost:6432 │ │ over network │ ││ └──────────────────────┘ └─────────┬──────────┘ │└──────────────────────────────────────┼─────────────┘ │ ┌────────┴────────┐ │ PostgreSQL │ └─────────────────┘ Pros: No network hop for app→pooler, easy per-pod configurationCons: More pooler instances, each maintains connectionsPattern 3: Database-Adjacent
Pooler runs on the same machine/network as the database.
12345678910111213141516171819202122
┌─────────────┐ ┌─────────────┐ ┌─────────────┐│ App Pod │ │ App Pod │ │ App Pod │└──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │ │ │ └────────────────┼────────────────┘ │ ▼ ┌─────────────────────────────────────────┐ │ Database Server │ │ ┌─────────────────────────────────┐ │ │ │ PgBouncer │ │ │ │ (localhost / unix socket) │ │ │ └────────────┬────────────────────┘ │ │ │ │ │ ┌────────────┴────────────────────┐ │ │ │ PostgreSQL │ │ │ │ (unix socket connection) │ │ │ └─────────────────────────────────┘ │ └─────────────────────────────────────────┘ Pros: Minimal latency between pooler and DBCons: Couples pooler lifecycle to DB, single point of failureFor Kubernetes: Standalone pooler cluster with 2-3 replicas, fronted by a ClusterIP service. This provides high availability, centralized management, and clear separation between application and data layers. Use HPA to scale pooler pods based on connection count.
Running external poolers in production requires attention to monitoring, high availability, and maintenance procedures.
123456789101112131415161718192021222324
-- Connect to PgBouncer admin database-- psql -h localhost -p 6432 -U pgbouncer_admin pgbouncer -- Overall statisticsSHOW STATS;-- Columns: database, total_xact_count, total_query_count, total_received, -- total_sent, total_xact_time, total_query_time, avg_xact_time, avg_query_time -- Current pools statusSHOW POOLS;-- Columns: database, user, cl_active, cl_waiting, sv_active, sv_idle, -- sv_used, sv_tested, sv_login, maxwait, pool_mode -- Current connectionsSHOW CLIENTS;SHOW SERVERS; -- ConfigurationSHOW CONFIG; -- Key metrics to alert on:-- cl_waiting > 0 for extended periods (client wait)-- sv_active = max_db_connections (pool exhaustion)-- maxwait > 1 second (clients waiting too long)High availability configuration:
You now understand external connection poolers—PgBouncer for PostgreSQL and ProxySQL for MySQL. These tools are essential for scaling beyond single-application deployments. Key decisions: choose transaction mode for PgBouncer (usually), use ProxySQL when you need read/write splitting or query caching, and deploy in a clustered pattern for high availability. Next, we'll cover application-side pooling patterns for scenarios where external poolers aren't available.