<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Sharafath Ali VK</title>
    <description>The latest articles on DEV Community by Sharafath Ali VK (@sharafathalivk).</description>
    <link>https://dev.to/sharafathalivk</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3966718%2Ffef6dab0-d4b4-4315-a1c4-9adcd4660b40.jpg</url>
      <title>DEV Community: Sharafath Ali VK</title>
      <link>https://dev.to/sharafathalivk</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/sharafathalivk"/>
    <language>en</language>
    <item>
      <title>PostgreSQL Connection Pooling Explained: How It Works and Why It Matters</title>
      <dc:creator>Sharafath Ali VK</dc:creator>
      <pubDate>Wed, 03 Jun 2026 14:38:06 +0000</pubDate>
      <link>https://dev.to/sharafathalivk/postgresql-connection-pooling-explained-how-it-works-and-why-it-matters-31g6</link>
      <guid>https://dev.to/sharafathalivk/postgresql-connection-pooling-explained-how-it-works-and-why-it-matters-31g6</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Opening a new database connection for every query is one of the most expensive things your backend can do. Connection pooling is how you stop doing that.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Table of Contents
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Why connections are expensive&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;What exactly happens when you connect&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The problem at scale&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;What connection pooling actually is&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Pool modes — the most important decision&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;PgBouncer — the standard solution&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;PgBouncer setup and configuration&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Application-level pooling vs PgBouncer&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;How to size your pool correctly&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;What happens when the pool is exhausted&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Monitoring your pool&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Common mistakes&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The mental model&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Why connections are expensive
&lt;/h2&gt;

&lt;p&gt;Most developers think of a database connection as roughly equivalent to opening a file — a cheap OS-level handle. It is not.&lt;/p&gt;

&lt;p&gt;A PostgreSQL client connection is typically served by a dedicated backend OS process. When you connect to Postgres, the server forks a new backend process specifically for your connection. That process:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Allocates its own memory segment (around 5–10 MB per connection by default)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Loads shared catalog caches into local memory&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Establishes a TCP socket&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Negotiates the protocol handshake&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Authenticates the connecting user&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Sets up session state (search path, timezone, encoding) This takes somewhere between 20 and 100 milliseconds depending on your hardware, network, and authentication method. On a fast local network with &lt;code&gt;md5&lt;/code&gt; auth, you might get 20ms. With SSL and &lt;code&gt;scram-sha-256&lt;/code&gt; over a real network, it is closer to 80–100ms.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Now consider a typical web request that needs 3 database queries. If you open a fresh connection for each request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Request arrives
  → Connect to Postgres (80ms)
  → Query 1 (2ms)
  → Query 2 (3ms)
  → Query 3 (1ms)
  → Disconnect
Total: 86ms

Without connection overhead:
  → Query 1 (2ms)
  → Query 2 (3ms)
  → Query 3 (1ms)
Total: 6ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The connection setup is 93% of the request time. The actual database work is 6ms. You are paying a massive tax for something that is entirely avoidable.&lt;/p&gt;




&lt;h2&gt;
  
  
  What exactly happens when you connect
&lt;/h2&gt;

&lt;p&gt;Understanding the full connection lifecycle makes it clear why this is expensive and why pooling is not just an optimization but a necessity at any real scale.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. TCP handshake
&lt;/h3&gt;

&lt;p&gt;The client initiates a TCP connection to Postgres (default port 5432). Three-way handshake: SYN → SYN-ACK → ACK. On localhost this is sub-millisecond. Over a network, it depends on latency.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. SSL negotiation (if enabled)
&lt;/h3&gt;

&lt;p&gt;If SSL is configured — and it should be in production — there is a TLS handshake on top of the TCP connection. This involves certificate exchange, cipher negotiation, and key derivation. Typically adds 10–30ms on a real network.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Postgres startup message
&lt;/h3&gt;

&lt;p&gt;The client sends a startup packet containing the protocol version, database name, and username. Postgres reads this and decides how to proceed.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Authentication
&lt;/h3&gt;

&lt;p&gt;Postgres runs the configured authentication method:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;trust&lt;/code&gt; — no authentication (development only, never production)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;md5&lt;/code&gt; — password hashed with MD5, fast&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;scram-sha-256&lt;/code&gt; — modern challenge-response, slower but cryptographically sound&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;peer&lt;/code&gt; — Unix socket auth against OS user, fast&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;ldap&lt;/code&gt; / &lt;code&gt;radius&lt;/code&gt; / &lt;code&gt;cert&lt;/code&gt; — external auth, varies&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  5. Process fork
&lt;/h3&gt;

&lt;p&gt;Postgres forks a new backend process (&lt;code&gt;postgres: username dbname&lt;/code&gt; in &lt;code&gt;ps&lt;/code&gt; output). This is a real &lt;code&gt;fork()&lt;/code&gt; system call. The postmaster (Postgres's master process) maintains a listener and forks a child for every accepted connection. The fork itself is relatively fast due to copy-on-write semantics, but the child process then needs to set up its own memory structures.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Shared catalog load
&lt;/h3&gt;

&lt;p&gt;The new backend process loads catalog caches — system table data that it needs to execute queries. Table schemas, type OIDs, function definitions. Some of this is shared memory, some is per-process. First queries in a new connection are slightly slower because the cache is cold.&lt;/p&gt;

&lt;h3&gt;
  
  
  7. Session parameter setup
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;search_path&lt;/code&gt;, &lt;code&gt;TimeZone&lt;/code&gt;, &lt;code&gt;client_encoding&lt;/code&gt;, &lt;code&gt;DateStyle&lt;/code&gt;, and any other session-level parameters are applied. If your application sets &lt;code&gt;SET&lt;/code&gt; parameters on connect (common in multi-tenant apps), those run here.&lt;/p&gt;

&lt;h3&gt;
  
  
  8. Ready for query
&lt;/h3&gt;

&lt;p&gt;Only now does the connection enter the idle state, ready to accept your first query. Everything above happened before a single byte of your actual query was sent.&lt;/p&gt;




&lt;h2&gt;
  
  
  The problem at scale
&lt;/h2&gt;

&lt;p&gt;The per-connection cost compounds quickly under load.&lt;/p&gt;

&lt;h3&gt;
  
  
  Max connections ceiling
&lt;/h3&gt;

&lt;p&gt;Postgres has a hard limit on simultaneous connections configured by &lt;code&gt;max_connections&lt;/code&gt; in &lt;code&gt;postgresql.conf&lt;/code&gt;. The default is 100. You can increase it, but there is a real ceiling because each connection is a process consuming memory and file descriptors.&lt;/p&gt;

&lt;p&gt;At &lt;code&gt;max_connections = 100&lt;/code&gt;, with each connection carrying roughly 10 MB of process overhead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;100 connections × 10 MB = 1 GB just for connection memory
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is before any query actually runs. Increase &lt;code&gt;max_connections&lt;/code&gt; to 500:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;500 connections × 10 MB = 5 GB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On a server with 16 GB of RAM, 5 GB consumed by idle connection overhead before any real work is done is a significant problem.&lt;/p&gt;

&lt;h3&gt;
  
  
  The thundering herd
&lt;/h3&gt;

&lt;p&gt;Without connection pooling, connection counts track request counts directly. At 500 concurrent requests, you need 500 database connections. At 1,000 concurrent requests, you need 1,000. Each spike in traffic causes a spike in connection count.&lt;/p&gt;

&lt;p&gt;If a deploy causes a restart, or if your database becomes briefly unavailable, every application server tries to reconnect simultaneously. Thousands of &lt;code&gt;fork()&lt;/code&gt; calls hit the Postgres postmaster at once. This is called the thundering herd — it can take a restarted Postgres longer to recover from the reconnection storm than the original restart took.&lt;/p&gt;

&lt;h3&gt;
  
  
  Context switching overhead
&lt;/h3&gt;

&lt;p&gt;The OS scheduler has to manage all running processes. At 200 active connections, Postgres has 200 backend processes. Even if most are idle (waiting for the next query), they exist as schedulable entities. Context switching between 200 processes costs CPU. At high connection counts this becomes measurable — Postgres spends CPU cycles managing connections rather than executing queries.&lt;/p&gt;




&lt;h2&gt;
  
  
  What connection pooling actually is
&lt;/h2&gt;

&lt;p&gt;A connection pool is a cache of pre-established database connections that are reused across multiple application requests.&lt;/p&gt;

&lt;p&gt;Instead of:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;App → [connect] → Postgres → [query] → [disconnect]
App → [connect] → Postgres → [query] → [disconnect]
App → [connect] → Postgres → [query] → [disconnect]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With a pool:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Pool → [connect × 20] → Postgres  (done once at startup)

App → Pool → [borrow connection] → [query] → [return connection]
App → Pool → [borrow connection] → [query] → [return connection]
App → Pool → [borrow connection] → [query] → [return connection]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The pool maintains a fixed set of open connections. Requests borrow a connection, use it, and return it. The connection itself is never closed between requests — it stays open and ready. The next request picks it up instantly with zero handshake overhead.&lt;/p&gt;

&lt;p&gt;The critical insight: &lt;strong&gt;the number of real Postgres connections is now decoupled from the number of concurrent application requests.&lt;/strong&gt; 1,000 application requests can be served by 20 actual Postgres connections if the queries are fast enough, because most connections are returned to the pool before the next request needs one.&lt;/p&gt;




&lt;h2&gt;
  
  
  Pool modes — the most important decision
&lt;/h2&gt;

&lt;p&gt;This is the decision that most developers get wrong or do not think about at all. Pool mode determines when a server-side connection is considered "available" to be assigned to the next client.&lt;/p&gt;

&lt;h3&gt;
  
  
  Session mode
&lt;/h3&gt;

&lt;p&gt;A server connection is held for the entire duration of a client session — from client connect to client disconnect.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Client connects → gets a server connection
Client sends queries → uses that connection
Client disconnects → server connection returned to pool
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the safest mode. Everything that works with a direct Postgres connection works in session mode — prepared statements, advisory locks, &lt;code&gt;SET&lt;/code&gt; session variables, &lt;code&gt;LISTEN/NOTIFY&lt;/code&gt;, everything.&lt;/p&gt;

&lt;p&gt;The downside: the pool size directly limits concurrent clients. If you have 20 server connections in session mode and 21 clients connect, the 21st waits until someone disconnects. You get multiplexing across time (reconnections are cheap) but not across concurrent sessions.&lt;/p&gt;

&lt;p&gt;Use session mode when: your application uses session-level features (prepared statements, advisory locks, temp tables, &lt;code&gt;LISTEN&lt;/code&gt;) and cannot be refactored to avoid them. Also the correct default if you are not sure.&lt;/p&gt;

&lt;h3&gt;
  
  
  Transaction mode
&lt;/h3&gt;

&lt;p&gt;A server connection is held only for the duration of a transaction — from &lt;code&gt;BEGIN&lt;/code&gt; to &lt;code&gt;COMMIT&lt;/code&gt; or &lt;code&gt;ROLLBACK&lt;/code&gt;. Between transactions, the server connection is returned to the pool and can be given to a different client.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Client 1: BEGIN → [gets server conn A] → COMMIT → [returns conn A]
Client 2 starts after Client 1: BEGIN → [gets server conn A] → COMMIT
Client 3: BEGIN → [gets server conn B] → COMMIT
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two clients sharing a single server connection across different transactions. This is where real multiplexing happens.&lt;/p&gt;

&lt;p&gt;In transaction mode, 20 server connections can genuinely serve hundreds of clients simultaneously as long as each transaction is short. This is the mode that lets you run 1,000 application connections through 20 Postgres connections.&lt;/p&gt;

&lt;p&gt;The trade-off: session-level features break. Prepared statements created in one transaction are not visible in the next (different server connection). &lt;code&gt;SET&lt;/code&gt; variables apply per-connection and get reset. Advisory locks taken in one transaction may be held by the server connection even after your transaction ends. &lt;code&gt;LISTEN&lt;/code&gt; does not work at all.&lt;/p&gt;

&lt;p&gt;Use transaction mode when: your application does not use any session-level features, or you explicitly design around the limitations. This is the correct mode for most high-throughput web applications using an ORM.&lt;/p&gt;

&lt;h3&gt;
  
  
  Statement mode
&lt;/h3&gt;

&lt;p&gt;A server connection is held only for a single statement. Even explicit transactions are broken into individual statements, each potentially on a different server connection.&lt;/p&gt;

&lt;p&gt;This means multi-statement transactions are impossible — each statement might go to a different connection, so &lt;code&gt;BEGIN/COMMIT&lt;/code&gt; wrapping multiple statements does not work.&lt;/p&gt;

&lt;p&gt;Use statement mode when: every operation is a single atomic statement and you need absolute maximum multiplexing. Rare in practice. Most applications need transactions.&lt;/p&gt;




&lt;h2&gt;
  
  
  PgBouncer — the standard solution
&lt;/h2&gt;

&lt;p&gt;PgBouncer is the industry-standard connection pooler for PostgreSQL. It is a lightweight proxy that sits between your application and Postgres.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Application servers
    ↓
  PgBouncer (port 6432)
    ↓
  PostgreSQL (port 5432)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your application connects to PgBouncer instead of directly to Postgres. PgBouncer maintains a pool of real Postgres connections and assigns them to your application connections according to the pool mode.&lt;/p&gt;

&lt;p&gt;PgBouncer is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Written in C, extremely low overhead&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Single-process, uses &lt;code&gt;libevent&lt;/code&gt; for async I/O (handles thousands of client connections with minimal CPU)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Battle-tested — used at companies running millions of queries per second&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Not a query router or load balancer. It only pools connections to a single Postgres instance (or replica). For multiple replicas you run multiple PgBouncers or put a load balancer in front.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  PgBouncer setup and configuration
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Installation
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Ubuntu / Debian&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install &lt;/span&gt;pgbouncer

&lt;span class="c"&gt;# macOS&lt;/span&gt;
brew &lt;span class="nb"&gt;install &lt;/span&gt;pgbouncer

&lt;span class="c"&gt;# From source&lt;/span&gt;
git clone https://github.com/pgbouncer/pgbouncer.git
&lt;span class="nb"&gt;cd &lt;/span&gt;pgbouncer &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; ./configure &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; make &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; make &lt;span class="nb"&gt;install&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  pgbouncer.ini — the main config file
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[databases]&lt;/span&gt;
&lt;span class="c"&gt;; Format: alias = host=... port=... dbname=...
; Your app connects to PgBouncer using the alias (myapp)
; PgBouncer connects to Postgres using the real connection details
&lt;/span&gt;&lt;span class="py"&gt;myapp&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;host=127.0.0.1 port=5432 dbname=myapp_production&lt;/span&gt;

&lt;span class="nn"&gt;[pgbouncer]&lt;/span&gt;
&lt;span class="c"&gt;; PgBouncer listens on this port
&lt;/span&gt;&lt;span class="py"&gt;listen_port&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;6432&lt;/span&gt;
&lt;span class="py"&gt;listen_addr&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;127.0.0.1&lt;/span&gt;

&lt;span class="c"&gt;; Pool mode — this is the most important setting
; Options: session | transaction | statement
&lt;/span&gt;&lt;span class="py"&gt;pool_mode&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;transaction&lt;/span&gt;

&lt;span class="c"&gt;; Max connections FROM applications TO PgBouncer
; This is how many client connections PgBouncer accepts
&lt;/span&gt;&lt;span class="py"&gt;max_client_conn&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;1000&lt;/span&gt;

&lt;span class="c"&gt;; Max connections FROM PgBouncer TO Postgres PER database+user pair
; This is the real Postgres connection count
&lt;/span&gt;&lt;span class="py"&gt;default_pool_size&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;25&lt;/span&gt;

&lt;span class="c"&gt;; Minimum connections kept open even when idle
&lt;/span&gt;&lt;span class="py"&gt;min_pool_size&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;5&lt;/span&gt;

&lt;span class="c"&gt;; Extra connections opened when pool is fully in use
&lt;/span&gt;&lt;span class="py"&gt;reserve_pool_size&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;5&lt;/span&gt;

&lt;span class="c"&gt;; How long (seconds) a client can wait for a connection before error
&lt;/span&gt;&lt;span class="py"&gt;reserve_pool_timeout&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;3&lt;/span&gt;

&lt;span class="c"&gt;; Auth method
&lt;/span&gt;&lt;span class="py"&gt;auth_type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;scram-sha-256&lt;/span&gt;
&lt;span class="py"&gt;auth_file&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;/etc/pgbouncer/userlist.txt&lt;/span&gt;

&lt;span class="c"&gt;; Logging
&lt;/span&gt;&lt;span class="py"&gt;logfile&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;/var/log/pgbouncer/pgbouncer.log&lt;/span&gt;
&lt;span class="py"&gt;pidfile&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;/var/run/pgbouncer/pgbouncer.pid&lt;/span&gt;

&lt;span class="c"&gt;; Admin access
&lt;/span&gt;&lt;span class="py"&gt;admin_users&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;pgbouncer_admin&lt;/span&gt;
&lt;span class="py"&gt;stats_users&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;pgbouncer_stats&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  userlist.txt — credentials
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;; Format: "username" "password"
; Password must be scram-sha-256 or md5 hashed, not plaintext
"myapp_user" "SCRAM-SHA-256$4096:..."
"pgbouncer_admin" "SCRAM-SHA-256$4096:..."
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To generate the hashed password:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Run this in psql, copy the output into userlist.txt&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;concat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'"'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;usename&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'" "'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;passwd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'"'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_shadow&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;usename&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'myapp_user'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Start PgBouncer
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pgbouncer &lt;span class="nt"&gt;-d&lt;/span&gt; /etc/pgbouncer/pgbouncer.ini
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Update your application connection string
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Before (connecting directly to Postgres)
DATABASE_URL=postgresql://myapp_user:password@localhost:5432/myapp_production

# After (connecting through PgBouncer)
DATABASE_URL=postgresql://myapp_user:password@localhost:6432/myapp
#                                                        ^^^^  ^^^^^
#                                                    PgBouncer  alias from
#                                                       port    pgbouncer.ini
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your application code does not change. Only the connection string changes.&lt;/p&gt;




&lt;h2&gt;
  
  
  Application-level pooling vs PgBouncer
&lt;/h2&gt;

&lt;p&gt;Most database libraries and ORMs have built-in connection pooling. Node.js &lt;code&gt;pg&lt;/code&gt; has &lt;code&gt;Pool&lt;/code&gt;, Python's &lt;code&gt;psycopg2&lt;/code&gt; has connection pools, Java has HikariCP. These are application-level pools.&lt;/p&gt;

&lt;p&gt;The difference matters:&lt;/p&gt;

&lt;h3&gt;
  
  
  Application-level pool
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;App server 1: [pool: 10 connections] → Postgres
App server 2: [pool: 10 connections] → Postgres
App server 3: [pool: 10 connections] → Postgres
App server 4: [pool: 10 connections] → Postgres

Total Postgres connections: 40
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each application server maintains its own pool. With 4 servers each holding 10 connections, Postgres sees 40 connections. Scale to 20 servers and Postgres sees 200 connections. The Postgres connection count scales with the number of application servers.&lt;/p&gt;

&lt;h3&gt;
  
  
  PgBouncer
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;App server 1: [pool: 10 client conns] → PgBouncer
App server 2: [pool: 10 client conns] → PgBouncer
App server 3: [pool: 10 client conns] → PgBouncer
App server 4: [pool: 10 client conns] → PgBouncer
                                            ↓
                                  [25 real connections]
                                            ↓
                                        Postgres

Total Postgres connections: 25 (regardless of app server count)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;PgBouncer is a shared pool across all application servers. Add more application servers and Postgres still sees 25 connections. The Postgres connection count is now fixed and independent of how many servers you run.&lt;/p&gt;

&lt;p&gt;This is the critical advantage of a dedicated pooler over application-level pooling for multi-server deployments.&lt;/p&gt;

&lt;h3&gt;
  
  
  When application-level pooling is enough
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Single application server (connection count is bounded anyway)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Small scale where Postgres connection count never approaches &lt;code&gt;max_connections&lt;/code&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;You need session-level features and cannot use transaction mode&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;You want simpler infrastructure&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  When you need PgBouncer
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Multiple application servers (connection count grows with server count)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Serverless or auto-scaling environments where connection count is unpredictable&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;High traffic where raw connection overhead is measurable&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;You are hitting or approaching &lt;code&gt;max_connections&lt;/code&gt;&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  How to size your pool correctly
&lt;/h2&gt;

&lt;p&gt;The most common mistake is setting &lt;code&gt;default_pool_size&lt;/code&gt; too high. More connections does not mean more throughput beyond a certain point.&lt;/p&gt;

&lt;h3&gt;
  
  
  The formula
&lt;/h3&gt;

&lt;p&gt;Postgres performance research (including the widely-cited PgCon paper) suggests:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;optimal_connections = (number of CPU cores × 2) + number of effective spindles
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a Postgres server with 4 CPU cores and SSD (treat as 1 spindle):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;optimal_connections = (4 × 2) + 1 = 9
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That seems very low. But it reflects a real phenomenon: beyond a certain number of concurrent connections, Postgres spends more time on context switching, lock contention, and shared buffer management than it does executing queries. Adding more connections actually reduces throughput.&lt;/p&gt;

&lt;p&gt;For most production systems, a reasonable starting range is:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Postgres server size&lt;/th&gt;
&lt;th&gt;Starting pool size&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;2 CPU cores&lt;/td&gt;
&lt;td&gt;5–10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4 CPU cores&lt;/td&gt;
&lt;td&gt;10–20&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8 CPU cores&lt;/td&gt;
&lt;td&gt;20–40&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;16 CPU cores&lt;/td&gt;
&lt;td&gt;40–80&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;These are starting points, not absolute rules. Profile under your actual load.&lt;/p&gt;

&lt;h3&gt;
  
  
  The &lt;code&gt;max_client_conn&lt;/code&gt; vs &lt;code&gt;default_pool_size&lt;/code&gt; relationship
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="py"&gt;max_client_conn&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;1000   ; clients (your app) can open up to 1000 connections to PgBouncer&lt;/span&gt;
&lt;span class="py"&gt;default_pool_size&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;25   ; PgBouncer uses at most 25 real Postgres connections&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The ratio here (1000:25 = 40:1) means 40 application connections share each Postgres connection. This works in transaction mode because each transaction is short — a connection is free within milliseconds and available to the next client.&lt;/p&gt;

&lt;p&gt;If your transactions are long (seconds), the ratio needs to be smaller. A connection held for 2 seconds cannot serve 40 clients in that window.&lt;/p&gt;

&lt;h3&gt;
  
  
  Reserve pool
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="py"&gt;reserve_pool_size&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;5&lt;/span&gt;
&lt;span class="py"&gt;reserve_pool_timeout&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;3&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the main pool (&lt;code&gt;default_pool_size&lt;/code&gt;) is fully in use, PgBouncer can open up to &lt;code&gt;reserve_pool_size&lt;/code&gt; additional connections. These are only used when a client has been waiting &lt;code&gt;reserve_pool_timeout&lt;/code&gt; seconds. This is a safety valve for traffic spikes, not a replacement for correctly sizing the main pool.&lt;/p&gt;




&lt;h2&gt;
  
  
  What happens when the pool is exhausted
&lt;/h2&gt;

&lt;p&gt;When all connections in the pool are in use and a new client request arrives, PgBouncer does not fail immediately. It queues the client and waits.&lt;/p&gt;

&lt;p&gt;The wait behavior is controlled by:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="c"&gt;; How long a client waits before receiving an error
&lt;/span&gt;&lt;span class="py"&gt;server_connect_timeout&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;15    ; timeout connecting to Postgres&lt;/span&gt;
&lt;span class="py"&gt;query_wait_timeout&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;120       ; how long client waits in queue before error&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;query_wait_timeout&lt;/code&gt; elapses before a connection becomes available, the client gets:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ERROR: pgbouncer cannot connect to server
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;or&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ERROR: no more connections allowed (max_client_conn)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From the application's perspective this looks like a database error. Monitor the PgBouncer queue length. If clients are regularly waiting for connections, your pool is undersized for your load — either increase &lt;code&gt;default_pool_size&lt;/code&gt; (and make sure Postgres can handle it) or optimize query duration so connections are freed faster.&lt;/p&gt;




&lt;h2&gt;
  
  
  Monitoring your pool
&lt;/h2&gt;

&lt;p&gt;PgBouncer exposes a virtual admin database called &lt;code&gt;pgbouncer&lt;/code&gt;. Connect to it using &lt;code&gt;psql&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;psql &lt;span class="nt"&gt;-h&lt;/span&gt; 127.0.0.1 &lt;span class="nt"&gt;-p&lt;/span&gt; 6432 &lt;span class="nt"&gt;-U&lt;/span&gt; pgbouncer_admin pgbouncer
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  SHOW POOLS
&lt;/h3&gt;

&lt;p&gt;The most important view:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SHOW&lt;/span&gt; &lt;span class="n"&gt;POOLS&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; database  |   user    | cl_active | cl_waiting | sv_active | sv_idle | sv_used | maxwait
-----------+-----------+-----------+------------+-----------+---------+---------+---------
 myapp     | myapp_user|        18 |          2 |        20 |       5 |       0 |       1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Column&lt;/th&gt;
&lt;th&gt;What it means&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;cl_active&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Clients currently executing a query&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;cl_waiting&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Clients waiting for a free server connection&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sv_active&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Server connections currently in use&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sv_idle&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Server connections open but idle, ready for next client&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sv_used&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Server connections that were used and returned but not yet tested&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;maxwait&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Longest wait time (seconds) for a client in the queue&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;cl_waiting &amp;gt; 0&lt;/code&gt; means clients are being queued — pool pressure. &lt;code&gt;maxwait&lt;/code&gt; climbing above 1-2 seconds means clients are noticeably delayed waiting for a connection.&lt;/p&gt;

&lt;h3&gt;
  
  
  SHOW STATS
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SHOW&lt;/span&gt; &lt;span class="n"&gt;STATS&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; database  | total_xact_count | total_query_count | total_wait_time | avg_xact_time | avg_query_time
-----------+------------------+-------------------+-----------------+---------------+---------------
 myapp     |          1284910 |           3921043 |           14280 |            12 |              4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;avg_query_time&lt;/code&gt; in microseconds. If this climbs, queries are getting slower — could be missing indexes, lock contention, or degraded Postgres performance, not necessarily a pooling issue.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;total_wait_time&lt;/code&gt; accumulating — clients spending time waiting for connections. Compare against &lt;code&gt;total_xact_count&lt;/code&gt; to get average wait per transaction.&lt;/p&gt;

&lt;h3&gt;
  
  
  SHOW CLIENTS
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SHOW&lt;/span&gt; &lt;span class="n"&gt;CLIENTS&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Shows each individual client connection — which database, which user, current state, how long it has been connected, and how long the current query has been running. Useful for finding stuck connections that are holding a server connection without doing anything.&lt;/p&gt;

&lt;h3&gt;
  
  
  SHOW SERVERS
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SHOW&lt;/span&gt; &lt;span class="n"&gt;SERVERS&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Shows each individual server (Postgres) connection PgBouncer is maintaining — state, age, bytes sent/received. A server connection in &lt;code&gt;active&lt;/code&gt; state for an unusually long time usually means a long-running transaction or a stuck query on the Postgres side.&lt;/p&gt;




&lt;h2&gt;
  
  
  Common mistakes
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Using transaction mode with session-level features
&lt;/h3&gt;

&lt;p&gt;Transaction mode breaks prepared statements, advisory locks, temp tables, and &lt;code&gt;SET&lt;/code&gt; variables. If your ORM uses prepared statements (most do by default), you must either:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Switch to session mode&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Disable prepared statements in your ORM config&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Use a library that is aware of PgBouncer transaction mode For Node.js &lt;code&gt;pg&lt;/code&gt;, disable prepared statements:&lt;br&gt;
&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// prepared statements (default) — breaks in transaction mode&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SELECT $1::text&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;value&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt; &lt;span class="c1"&gt;// uses prepared statement&lt;/span&gt;

&lt;span class="c1"&gt;// parameterized query without prepared statement — works in transaction mode&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SELECT $1::text&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;values&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;value&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="c1"&gt;// no 'name' field = not a prepared statement&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For Prisma, set &lt;code&gt;pgbouncer=true&lt;/code&gt; in the connection string:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;DATABASE_URL="postgresql://user:pass@localhost:6432/db?pgbouncer=true"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For SQLAlchemy (Python):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;engine&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;create_engine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;pool_pre_ping&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="c1"&gt;# disable prepared statements for PgBouncer transaction mode
&lt;/span&gt;    &lt;span class="n"&gt;connect_args&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;options&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-c statement_timeout=30000&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;execution_options&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;no_parameters&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2. Setting pool size too high
&lt;/h3&gt;

&lt;p&gt;A common first instinct: "queries are slow, add more connections." Beyond the optimal connection count, adding more connections increases contention on Postgres's internal lock manager, shared buffers, and WAL writer. Throughput plateaus then degrades.&lt;/p&gt;

&lt;p&gt;Profile first. If &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; shows slow queries, the problem is probably missing indexes or inefficient queries — not pool size.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Not handling connection errors in the application
&lt;/h3&gt;

&lt;p&gt;Connections in the pool can go stale — Postgres restarts, network blips, idle connection timeouts. Without &lt;code&gt;pool_pre_ping&lt;/code&gt; or equivalent, your application gets a dead connection from the pool and the query fails with a confusing error.&lt;/p&gt;

&lt;p&gt;Always configure your pool or ORM to validate connections before use:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// node-postgres: use pool.connect() which handles reconnection&lt;/span&gt;
&lt;span class="c1"&gt;// or set idleTimeoutMillis shorter than Postgres's idle timeout&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Pool&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;connectionString&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;idleTimeoutMillis&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;30000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;// close idle connections after 30s&lt;/span&gt;
  &lt;span class="na"&gt;connectionTimeoutMillis&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// error if no connection in 2s&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="c"&gt;; PgBouncer side: test server connections before giving to client
&lt;/span&gt;&lt;span class="py"&gt;server_check_query&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;SELECT 1&lt;/span&gt;
&lt;span class="py"&gt;server_check_delay&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;30&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  4. Not monitoring cl_waiting
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;cl_waiting &amp;gt; 0&lt;/code&gt; is a real-time signal that your pool is undersized. Most teams only discover this when users start complaining about slow requests or timeouts. Set up an alert on &lt;code&gt;cl_waiting&lt;/code&gt; in your monitoring stack before that happens.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Ignoring &lt;code&gt;server_lifetime&lt;/code&gt; and &lt;code&gt;server_idle_timeout&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;PgBouncer keeps server connections open for efficiency, but long-lived connections can accumulate bloat (Postgres's per-connection memory) and can get into bad state after schema changes or Postgres reloads.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="c"&gt;; Close and reopen server connections after this many seconds
&lt;/span&gt;&lt;span class="py"&gt;server_lifetime&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;3600      ; 1 hour&lt;/span&gt;

&lt;span class="c"&gt;; Close idle server connections that have been idle for this long
&lt;/span&gt;&lt;span class="py"&gt;server_idle_timeout&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;600   ; 10 minutes&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These ensure connections are periodically recycled without closing them constantly.&lt;/p&gt;




&lt;h2&gt;
  
  
  The mental model
&lt;/h2&gt;

&lt;p&gt;Think of PgBouncer as a front desk at a hotel.&lt;/p&gt;

&lt;p&gt;Postgres has 25 rooms (server connections). The hotel can have 1,000 guests (application clients). Not all 1,000 guests need a room simultaneously — most are out during the day. The front desk (PgBouncer) manages who has a room key at any given moment.&lt;/p&gt;

&lt;p&gt;In session mode: a guest keeps their room key the entire stay. 25 rooms = 25 simultaneous guests maximum.&lt;/p&gt;

&lt;p&gt;In transaction mode: a guest gets a key, drops off their bags (executes a transaction), returns the key. The next guest gets that same key immediately. 25 rooms can serve hundreds of guests throughout the day.&lt;/p&gt;

&lt;p&gt;The front desk does not create new rooms. It manages existing ones efficiently.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Without pooling:
  1,000 requests = 1,000 Postgres connections = Postgres overwhelmed

With PgBouncer (transaction mode):
  1,000 requests → 1,000 PgBouncer client connections
               → 25 real Postgres connections
               → Postgres handles it comfortably
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The number of Postgres connections is now a knob you control, independent of traffic volume.&lt;/p&gt;




&lt;h2&gt;
  
  
  Quick reference
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Key pgbouncer.ini settings
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setting&lt;/th&gt;
&lt;th&gt;What it controls&lt;/th&gt;
&lt;th&gt;Typical value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pool_mode&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;session / transaction / statement&lt;/td&gt;
&lt;td&gt;&lt;code&gt;transaction&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;default_pool_size&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;real Postgres connections per db+user&lt;/td&gt;
&lt;td&gt;20–40&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;max_client_conn&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;max app connections to PgBouncer&lt;/td&gt;
&lt;td&gt;500–1000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;min_pool_size&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;connections kept open when idle&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;reserve_pool_size&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;extra connections for spikes&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;server_lifetime&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;recycle connections after N seconds&lt;/td&gt;
&lt;td&gt;3600&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;server_idle_timeout&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;close idle connections after N seconds&lt;/td&gt;
&lt;td&gt;600&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;query_wait_timeout&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;client wait limit before error&lt;/td&gt;
&lt;td&gt;30–120&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Key monitoring queries
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- pool health: look for cl_waiting &amp;gt; 0 and maxwait &amp;gt; 1&lt;/span&gt;
&lt;span class="k"&gt;SHOW&lt;/span&gt; &lt;span class="n"&gt;POOLS&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- throughput and timing&lt;/span&gt;
&lt;span class="k"&gt;SHOW&lt;/span&gt; &lt;span class="n"&gt;STATS&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- individual stuck connections&lt;/span&gt;
&lt;span class="k"&gt;SHOW&lt;/span&gt; &lt;span class="n"&gt;CLIENTS&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- real Postgres connections&lt;/span&gt;
&lt;span class="k"&gt;SHOW&lt;/span&gt; &lt;span class="n"&gt;SERVERS&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- reload config without restart&lt;/span&gt;
&lt;span class="n"&gt;RELOAD&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- gracefully close idle server connections&lt;/span&gt;
&lt;span class="n"&gt;RECONNECT&lt;/span&gt; &lt;span class="n"&gt;myapp&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Further reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://www.pgbouncer.org/config.html" rel="noopener noreferrer"&gt;PgBouncer documentation&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://github.com/pgbouncer/pgbouncer" rel="noopener noreferrer"&gt;PgBouncer GitHub&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://www.postgresql.org/docs/current/runtime-config-connection.html" rel="noopener noreferrer"&gt;PostgreSQL docs — Server Configuration: Connections and Authentication&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://www.percona.com/blog/postgresql-connection-pooling-part-1-pros-cons/" rel="noopener noreferrer"&gt;Percona — PostgreSQL Connection Pooling&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://supabase.com/docs/guides/database/connecting-to-postgres#connection-pooler" rel="noopener noreferrer"&gt;Supabase — Connection Pooling in Postgres&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://github.com/darold/pgbadger" rel="noopener noreferrer"&gt;pgBadger — Postgres log analyzer&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>webdev</category>
      <category>postgres</category>
      <category>database</category>
      <category>sql</category>
    </item>
  </channel>
</rss>
