Kokil Thapa - Professional Web Developer in Nepal
Freelancer Web Developer in Nepal with 15+ Years of Experience

Kokil Thapa is an experienced full-stack web developer focused on building fast, secure, and scalable web applications. He helps businesses and individuals create SEO-friendly, user-focused digital platforms designed for long-term growth.

Database Connection Pooling with PgBouncer

By Kokil Thapa | Last reviewed: September 2026

PostgreSQL has a hard limit on open connections. Each PHP-FPM worker, queue worker, and Horizon process can hold one. Under traffic, a Laravel app on PostgreSQL 18 exhausts max_connections fast. Database Connection Pooling with PgBouncer sits between your app and the database. It multiplexes hundreds of client sessions onto a smaller pool of real server connections. If you run booking systems or legal-tech portals on shared VPS hardware, this pattern is often the difference between stable uptime and midnight pager alerts. This guide covers install, pool modes, Laravel wiring, and the production mistakes I see repeatedly on real deployments.

For broader context on why pooling exists at all, see our database connection pooling explained overview first. It frames the problem before you touch PgBouncer config files.

What is Database Connection Pooling with PgBouncer and why does it matter?

PostgreSQL allocates roughly 5–10 MB of RAM per connection. A modest EC2 instance with 4 GB RAM cannot safely run 500 concurrent backends. PHP-FPM multiplies the problem. A pool of 50 workers plus 10 queue workers plus 5 schedulers already needs 65 connections before traffic spikes.

PgBouncer is a single-purpose connection pooler written in C. It accepts TCP connections from clients, assigns them to a fixed pool of PostgreSQL backends, and returns those backends to the pool when the client disconnects or the transaction ends. The database sees 20–50 connections. Your app sees no change except a different host and port.

PgBouncer Connection Pool ArchitectureLaravel AppPHP-FPM + Queues200 client sessionsPgBouncerPort 6432Pool: 30 backendsPostgreSQL 18max_connections=100Actual RAM savedWithout pooling vs with PgBouncerNo pool: 200 PG backendsOOM or reject errorsWith pool: 30 PG backendsSame throughput, stable RAM
Database Connection Pooling with PgBouncer multiplexes many Laravel client sessions onto a fixed PostgreSQL backend pool

On a production Laravel application I maintain, moving from direct PostgreSQL connections to PgBouncer eliminated recurring FATAL: sorry, too many clients already errors during peak booking hours. The app logic did not change. Only the connection path did.

Pooling complements other database performance work. Pair it with index tuning, query caching, and optionally read replicas when read load outgrows a single primary.

How do you install and configure PgBouncer on Ubuntu?

Most of my production stacks run Ubuntu 22.04 or 24.04 with Apache and PHP-FPM. PgBouncer installs from the standard repository and listens on port 6432 by default.

Install the package

sudo apt update
sudo apt install pgbouncer
sudo systemctl enable pgbouncer

Confirm PostgreSQL 18 is reachable locally before you configure the pooler. Check your current connection count with:

sudo -u postgres psql -c "SELECT count(*) FROM pg_stat_activity;"

Edit pgbouncer.ini

The main config lives at /etc/pgbouncer/pgbouncer.ini. A minimal production-ready section looks like this:

[databases]
myapp = host=127.0.0.1 port=5432 dbname=myapp_production

[pgbouncer]
listen_addr = 127.0.0.1
listen_port = 6432
auth_type = scram-sha-256
auth_file = /etc/pgbouncer/userlist.txt
pool_mode = transaction
max_client_conn = 500
default_pool_size = 30
min_pool_size = 10
reserve_pool_size = 5
reserve_pool_timeout = 3
server_reset_query = DISCARD ALL
ignore_startup_parameters = extra_float_digits

Create the user list file with credentials PgBouncer uses to authenticate clients and connect upstream:

sudo nano /etc/pgbouncer/userlist.txt
"myapp_user" "SCRAM-SHA-256$4096:..."

Generate the SCRAM hash from PostgreSQL or copy it from pg_authid. Never store plain-text passwords in this file.

Size the pool correctly

A practical starting formula for Laravel:

  • default_pool_size = PHP-FPM workers + queue workers + 20% headroom
  • max_client_conn = all possible client processes across web, queue, cron, and admin tools
  • Leave 10–15 connections free on PostgreSQL for admin, replication, and monitoring

If your VPS runs 40 PHP-FPM workers and 8 queue workers, start with default_pool_size = 50. Tune from there using PgBouncer stats. For full-stack deployment help including server sizing, see our Linux system administration service.

PgBouncer Setup Steps1. Install2. pgbouncer.inipool_mode, sizes3. userlist.txtSCRAM auth4. Reloadsystemctl reloadLaravel .env changeDB_HOST=127.0.0.1DB_PORT=6432App connects to PgBouncer, not PostgreSQL directlyRun php artisan config:cache after deploy
Install PgBouncer, configure pool sizes and auth, then point Laravel DB_PORT at 6432 instead of 5432

Official parameter reference lives in the PgBouncer configuration documentation. Cross-check PostgreSQL-side limits in the PostgreSQL connection settings manual.

Which PgBouncer pool mode should Laravel and PHP use?

PgBouncer offers three pool modes. Picking the wrong one breaks sessions, prepared statements, or advisory locks. This is the decision most teams get wrong on first deploy.

Pool modeWhen server conn returns to poolLaravel fitMain risk
Session poolingClient disconnectsLegacy apps using temp tables, LISTEN/NOTIFYMinimal pooling benefit
Transaction poolingTransaction ends (COMMIT/ROLLBACK)Standard Laravel 12/13 HTTP + queue workloadsNo session-scoped features across requests
Statement poolingEach statement completesRare; mostly incompatible with ORMsBreaks multi-statement transactions

Transaction pooling is the correct default for Laravel on PHP 8.3 or 8.5. Each HTTP request wraps queries in implicit or explicit transactions. When the request ends, the backend returns to the pool. Eloquent, query builder, and migrations all work normally.

Session pooling only makes sense if you rely on PostgreSQL session variables, temporary tables that survive multiple requests, or LISTEN/NOTIFY. None of those apply to typical Blade or API apps.

Statement pooling breaks Laravel migrations, DB::transaction() blocks, and any multi-query unit of work. Do not use it with Eloquent.

Transaction Pooling LifecycleRequest APgBouncerAssigns backendPostgreSQLRequest BRequest A: BEGIN → SELECT → UPDATE → COMMITBackend returned to pool after COMMITRequest B reuses the same backend — different client, shared pool
In transaction pooling mode, PgBouncer releases the PostgreSQL backend as soon as COMMIT or ROLLBACK completes

Long-running transactions still hold a backend. Keep that in mind when debugging Laravel deadlocks and long transactions. A stuck job blocks one pool slot until it finishes or times out.

How do you connect Laravel to PostgreSQL through PgBouncer?

Laravel 12 and 13 treat PgBouncer as a standard PostgreSQL host. Change the connection target in .env and adjust two driver settings that conflict with transaction pooling.

Environment and config changes

DB_CONNECTION=pgsql
DB_HOST=127.0.0.1
DB_PORT=6432
DB_DATABASE=myapp_production
DB_USERNAME=myapp_user
DB_PASSWORD=your_secure_password

In config/database.php, disable persistent connections and prepared statements for the pgsql driver:

'pgsql' => [
    'driver' => 'pgsql',
    'host' => env('DB_HOST', '127.0.0.1'),
    'port' => env('DB_PORT', '6432'),
    'database' => env('DB_DATABASE', 'forge'),
    'username' => env('DB_USERNAME', 'forge'),
    'password' => env('DB_PASSWORD', ''),
    'charset' => env('DB_CHARSET', 'utf8'),
    'prefix' => '',
    'prefix_indexes' => true,
    'search_path' => 'public',
    'sslmode' => env('DB_SSLMODE', 'prefer'),
    'options' => extension_loaded('pdo_pgsql') ? [
        PDO::ATTR_PERSISTENT => false,
        PDO::ATTR_EMULATE_PREPARES => true,
    ] : [],
],

PDO::ATTR_EMULATE_PREPARES => true is critical. Transaction pooling reassigns server connections between statements. Named prepared statements tied to a specific backend fail with opaque errors if emulation is off.

After deploy, run:

php artisan config:cache
php artisan queue:restart

Queue workers cache config at boot. Restart them whenever connection settings change. See the official Laravel database documentation for connection option details.

Separate connections for migrations and admin

Some teams keep a second Laravel connection that bypasses PgBouncer for migrations and one-off admin tasks. Add to config/database.php:

'pgsql_direct' => [
    'driver' => 'pgsql',
    'host' => env('DB_DIRECT_HOST', '127.0.0.1'),
    'port' => env('DB_DIRECT_PORT', '5432'),
    /* same credentials and database */
],

Run migrations with:

php artisan migrate --database=pgsql_direct

Migrations use advisory locks and DDL statements that behave more predictably on a direct session connection. For day-to-day traffic, keep the default connection on PgBouncer port 6432.

On booking platforms like Adventure Third Pole Trek, web workers, Horizon queues, and scheduled tasks all hit the database concurrently. Pooling keeps that multi-process footprint under control without rewriting application code.

What are common PgBouncer production mistakes and how do you fix them?

I've debugged pooling issues on several Deployer-managed production servers. These patterns recur across Laravel, Symfony, and custom PHP apps.

Connection leaks from persistent PDO

Setting PDO::ATTR_PERSISTENT => true pins a client connection open across requests. PgBouncer cannot recycle that slot efficiently. Always set persistent connections to false behind a pooler.

Using session-scoped features in transaction mode

SET search_path, temporary tables, and pg_advisory_lock tied to a session break when the backend swaps between transactions. Laravel's default search_path = public in config is fine. Custom session-level SQL is not.

Undersized default_pool_size

When all backends are busy, clients wait in queue. Symptoms include slow page loads with healthy CPU and no slow queries. Check wait time:

psql -p 6432 pgbouncer -c "SHOW POOLS;"

Rising cl_waiting values mean you need a larger pool or fewer concurrent workers. Our testing and optimization service includes load testing that surfaces this before launch traffic hits.

Forgetting queue and scheduler workers

PHP-FPM worker count is only half the picture. Count Horizon workers, queue:work processes, cron-triggered Artisan commands, and external monitoring probes. Each opens a client connection to PgBouncer.

Skipping server_reset_query

Transaction pooling can leave session state on a backend after COMMIT. Set server_reset_query = DISCARD ALL in PgBouncer config. This clears prepared plans, temp tables, and session variables before the next client gets that backend.

PgBouncer TroubleshootingSymptom detectedSlow requestscl_waiting highPrepare errorsPDO statement failToo many clientsPG still maxedRaise pool sizeReduce FPM workersEMULATE_PREPAREStrue in configVerify app usesport 6432 not 5432Monitor with SHOW POOLS; SHOW STATS; and pg_stat_activity
Common PgBouncer production errors map to pool sizing, prepared statement config, or apps still connecting directly to PostgreSQL

Monitoring commands worth adding to cron

  1. SHOW POOLS; — active, waiting, and idle client counts per database
  2. SHOW STATS; — total requests, bytes sent, average query time through the pooler
  3. SHOW CLIENTS; — which client IPs hold connections (useful when debugging runaway workers)
  4. PostgreSQL pg_stat_activity — confirm backend count stays near default_pool_size, not client count

Log PgBouncer stats to your existing monitoring stack. A JSON snapshot parsed through a JSON formatter during incident review saves time when you are under pressure.

How does PgBouncer compare to other PostgreSQL pooling options?

PgBouncer is not the only option. Understanding alternatives helps you pick the right layer for your stack and team size.

SolutionLayerBest forTrade-off
PgBouncerExternal proxy (port 6432)PHP, Laravel, multi-process apps on VPS or EC2Extra service to install and monitor
Pgpool-IIExternal proxy with replication awarenessAutomatic read/write split, failoverHeavier config; overkill for single-primary setups
Built-in pool (Pg 18)Inside PostgreSQL (pg_prewarm, connection limits)Not true pooling; only limits damageDoes not multiplex clients onto fewer backends
ORM-level poolingApplication (e.g. persistent PDO)Single-process runtimes (Node, Java)Persistent PDO fights PgBouncer in PHP-FPM

For most Laravel deployments on a single PostgreSQL primary, PgBouncer is the right choice. Add Pgpool-II or managed proxy layers only when you need automatic failover or read/write splitting at the proxy tier. Application-level read replica routing in Laravel is often simpler — see our guide on that pattern.

If you are migrating from MySQL where connection overhead was lower, read MySQL to PostgreSQL migration notes before sizing pools. PostgreSQL connection costs are real and catch teams off guard.

Enterprise apps with strict uptime requirements benefit from pooling as part of broader architecture work. Our enterprise application development practice treats connection management as infrastructure, not an afterthought.

Schema design still matters. Pooling fixes connection count, not slow queries. Review common schema design mistakes alongside pool tuning. Good indexes reduce the time each backend stays busy, which indirectly improves pool throughput.

For ongoing ops — backups, pool stat review, PostgreSQL upgrades — support and maintenance keeps production systems stable after the initial PgBouncer rollout.

E-commerce workloads like Quick And Easy Nepalese Grocery spike connections during checkout and payment callbacks. Pooling absorbs those bursts without raising max_connections to unsafe levels.

Developers building database-driven websites in Nepal on budget VPS plans should plan for PgBouncer early. Waiting until connection errors appear in production forces emergency tuning under traffic.

Advanced Eloquent patterns — subqueries, eager loads, chunking — interact with pool wait times. See advanced Eloquent techniques for query patterns that release backends faster.

After any infrastructure change, validate backups still work against the direct PostgreSQL port. Follow database backup strategies for small servers so restore drills include both pooled and direct connection paths.

Team environments with frequent migrations should document which connection each CI job uses. Our database migrations in team environments guide covers coordination patterns that apply here.

Learn more about my production database work on the about me page or browse the full project portfolio for Laravel and PostgreSQL systems in production.

Key Takeaways

  • Place PgBouncer on port 6432 between Laravel and PostgreSQL to multiplex hundreds of clients onto 20–50 real backends.
  • Use transaction pooling for standard Laravel 12/13 apps; disable persistent PDO and enable emulated prepares.
  • Size default_pool_size to PHP-FPM workers plus queue workers plus headroom, not to peak HTTP concurrency alone.
  • Run migrations on a direct pgsql_direct connection to port 5432; route normal traffic through the pooler.
  • Monitor SHOW POOLS; for rising cl_waiting and set server_reset_query = DISCARD ALL in production.
  • Pair pooling with index tuning and query optimization — pooling fixes connection limits, not slow SQL.

People Also Ask

Does PgBouncer work with Laravel Horizon and queue workers?

Yes. Point every worker's DB_PORT at 6432 and restart Horizon after config changes. Each worker process is a separate PgBouncer client. Include all workers when calculating max_client_conn and default_pool_size.

Can you use PgBouncer with Redis caching in Laravel?

They solve different problems. Redis caches data and sessions. PgBouncer limits PostgreSQL backend connections. Use both together on production Laravel apps without conflict.

What happens to Laravel migrations through PgBouncer?

DDL and advisory locks work more reliably on a direct PostgreSQL connection. Keep a secondary pgsql_direct database config on port 5432 for php artisan migrate. Route normal application traffic through PgBouncer only.

Is PgBouncer enough for high-traffic PostgreSQL on a small VPS?

It removes the connection ceiling bottleneck. It does not replace RAM, CPU, or disk I/O upgrades. On a Rs 2,500/month VPS (~USD 19), pooling often doubles effective connection capacity without hardware changes. Slow queries still need indexes and optimization.

Deploy Database Connection Pooling with PgBouncer on your next release

Database Connection Pooling with PgBouncer is a low-risk, high-impact change for Laravel apps on PostgreSQL 18. Install the pooler, set transaction mode, update .env, disable persistent PDO, and monitor pool stats for one week. Most teams see connection errors disappear without touching application code.

If you want help sizing pools, wiring Laravel config, or load-testing before a busy season, contact us for a production database review. Pooling is boring infrastructure — and boring infrastructure keeps client sites online when traffic spikes.

Frequently Asked Questions

PgBouncer is a lightweight C proxy between your app and PostgreSQL. Many client sessions share a smaller fixed pool of real database backends, so the database sees 20–50 connections while your app opens hundreds.

PostgreSQL has a hard max_connections limit, and each PHP-FPM worker, queue worker, Horizon process, and scheduler can hold one connection. A modest stack with 50 PHP-FPM workers, 10 queue workers, and 5 schedulers already needs 65 connections before traffic spikes. On shared VPS hardware, that exhausts the limit fast and triggers FATAL: sorry, too many clients already errors during peak hours.

On Ubuntu 22.04 or 24.04, install from the standard repository with sudo apt update and sudo apt install pgbouncer, then sudo systemctl enable pgbouncer. Confirm PostgreSQL 18 is reachable locally first. Edit /etc/pgbouncer/pgbouncer.ini for database mapping, pool settings, and auth. Create /etc/pgbouncer/userlist.txt with SCRAM-SHA-256 credentials, never plain text. Point Laravel DB_PORT at 6432 instead of 5432.

Transaction pooling is the correct default for Laravel 12 and 13 on PHP 8.3 or 8.5. The PostgreSQL backend returns to the pool when COMMIT or ROLLBACK completes, which matches typical HTTP request lifecycles. Session pooling only suits apps using temp tables across requests or LISTEN/NOTIFY. Statement pooling breaks Eloquent, DB::transaction blocks, and migrations — do not use it with Laravel.

Set DB_CONNECTION=pgsql, DB_HOST=127.0.0.1, and DB_PORT=6432 in .env. In config/database.php for the pgsql driver, set PDO::ATTR_PERSISTENT to false and PDO::ATTR_EMULATE_PREPARES to true under the options array. Run php artisan config:cache after deploy, then php artisan queue:restart because queue workers cache config at boot. Laravel treats PgBouncer as a standard PostgreSQL host with a different port.

Port 6432 on 127.0.0.1. Laravel DB_PORT should point there instead of PostgreSQL’s 5432.

Roughly 5–10 MB per connection. A 4 GB EC2 instance cannot safely run 500 concurrent backends.

Start with default_pool_size equal to PHP-FPM workers plus queue workers plus 20% headroom. Set max_client_conn to cover all possible client processes across web, queue, cron, and admin tools. Leave 10–15 connections free on PostgreSQL for admin, replication, and monitoring. If you run 40 PHP-FPM workers and 8 queue workers, start default_pool_size at 50 and tune using PgBouncer stats.

Transaction pooling reassigns PostgreSQL backends between statements after each transaction ends. Named prepared statements are tied to a specific backend, so with PDO::ATTR_EMULATE_PREPARES set to false they fail with opaque errors when the backend swaps. Setting it to true lets PHP emulate prepares client-side while PgBouncer multiplexes server connections. This is critical and must not be skipped.

In transaction pooling mode, session state can linger on a backend after COMMIT. Setting server_reset_query = DISCARD ALL in pgbouncer.ini clears prepared plans, temporary tables, and session variables before the next client receives that backend. Skipping this is a common production mistake that causes unpredictable behaviour when backends are reused across unrelated Laravel requests.

Persistent PDO connections pin slots open — always set PDO::ATTR_PERSISTENT to false. Session-scoped SQL like SET search_path, temp tables, or pg_advisory_lock breaks in transaction mode. Undersized default_pool_size causes client waits — check SHOW POOLS for rising cl_waiting. Forgetting queue and scheduler workers undercounts connections. Missing server_reset_query leaves stale session state. Some apps still connect directly to port 5432, bypassing the pool entirely.

Keep a second connection in config/database.php called pgsql_direct that points to PostgreSQL on port 5432, bypassing PgBouncer. Migrations use advisory locks and DDL that behave more predictably on a direct session connection. Run php artisan migrate --database=pgsql_direct. For day-to-day HTTP and queue traffic, keep the default connection on PgBouncer port 6432.

Connect with psql -p 6432 pgbouncer and run SHOW POOLS for active, waiting, and idle counts; SHOW STATS for request totals and average query time; and SHOW CLIENTS to see which IPs hold connections. Rising cl_waiting in SHOW POOLS means the pool is undersized. Cross-check PostgreSQL pg_stat_activity to confirm backend count stays near default_pool_size, not the client count. Log stats to your monitoring stack for incident review.

PgBouncer is an external proxy on port 6432, ideal for PHP and Laravel multi-process apps on VPS or EC2, but adds a service to install and monitor. Pgpool-II handles replication-aware read/write split and failover but is heavier and overkill for single-primary setups. PostgreSQL 18 built-in connection limits only cap damage — they do not multiplex clients onto fewer backends. ORM-level persistent PDO pooling fights PgBouncer in PHP-FPM and should not be used together.

Plan early, before connection errors appear in production. Developers on budget VPS plans in Nepal and elsewhere should account for PHP-FPM, Horizon, queue workers, and schedulers each opening connections. E-commerce checkout bursts and booking platform peaks multiply demand quickly. Waiting until FATAL: sorry, too many clients already shows up forces emergency tuning under live traffic. Pooling absorbs bursts without raising max_connections to unsafe RAM levels.

Share this article

0 Comments

Leave a comment

Your email is not published. Comments appear once they have been read. Sign in to have your details filled in.

Quick Contact Options
Choose how you want to connect me: