[{"data":1,"prerenderedAt":33},["ShallowReactive",2],{"$fkCCmNFA07qs4MAxERbDhDGGUv6kk8H46SxNYw7R6Vlg":3},{"slug":4,"title":5,"description":6,"date":7,"tags":8,"draft":12,"readingMinutes":13,"short":14,"html":19,"related":20},"pgbouncer","PgBouncer: What Transaction Mode Breaks, and the Fix","PgBouncer in transaction mode lets 1,000 clients share 20 Postgres connections. What it breaks (SET, LISTEN, advisory locks), and what 1.24 fixed.","2026-11-01",[9,4,10,11],"postgres","connection-pooling","devops",false,4,{"id":15,"title":16,"date":17,"duration":18},"RFxMH7Y_kB8","PgBouncer: 1,000 clients, 20 Postgres connections, one leaky SET","2026-11-03T17:05:29Z","PT57S","\u003Cp>PgBouncer is a small connection pooler that sits between your app and PostgreSQL. Your app opens as many connections as it likes to PgBouncer on port 6432, and PgBouncer hands each one a real Postgres connection only while it needs it. That&#39;s the whole idea, and it&#39;s why a box with \u003Ccode>max_connections = 100\u003C/code> can serve a few thousand serverless functions without falling over.\u003C/p>\n\u003Cp>The catch is that the useful setting, transaction pooling, quietly changes what a &quot;connection&quot; means. Code that was fine on a direct connection can start doing strange things once two clients share one server connection. Everything here is checked against the \u003Ca href=\"https://www.pgbouncer.org/config.html\">PgBouncer config docs\u003C/a> and the \u003Ca href=\"https://www.pgbouncer.org/changelog.html\">changelog\u003C/a> on November 1, 2026 (latest release 1.26.1).\u003C/p>\n\u003Ch2>Why Postgres needs a pooler at all\u003C/h2>\n\u003Cp>Every Postgres connection is a separate operating system process with its own memory. A hundred of them is normal. A thousand is a server spending its time on processes instead of queries. MySQL handles lots of connections far more cheaply, which is why people coming from Laravel and MySQL are often surprised by this.\u003C/p>\n\u003Cp>You don&#39;t notice it with one app server holding a pool of 10. You notice it the week you move to Lambda, Vercel functions or a queue with 50 workers, each opening its own connections, and Postgres starts answering with \u003Ccode>FATAL: sorry, too many clients already\u003C/code>.\u003C/p>\n\u003Cfigure class=\"short-embed\">\u003Cbutton type=\"button\" class=\"short-play\" data-short=\"RFxMH7Y_kB8\" data-title=\"PgBouncer: 1,000 clients, 20 Postgres connections, one leaky SET\" aria-label=\"Play the video: PgBouncer: 1,000 clients, 20 Postgres connections, one leaky SET\">\u003Cimg src=\"/blog/pgbouncer/short-cover.webp\" alt=\"Cover of the YouTube Short: PgBouncer: 1,000 clients, 20 Postgres connections, one leaky SET\" width=\"540\" height=\"960\" loading=\"lazy\" decoding=\"async\">\u003Cspan class=\"short-icon\" aria-hidden=\"true\">\u003C/span>\u003C/button>\u003Cfigcaption>This post as a 57-second video. \u003Ca href=\"https://www.youtube.com/shorts/RFxMH7Y_kB8\">Watch it on YouTube\u003C/a>\u003C/figcaption>\u003C/figure>\u003Ch2>The three pool modes\u003C/h2>\n\u003Cp>\u003Ccode>pool_mode\u003C/code> decides when PgBouncer takes a server connection back:\u003C/p>\n\u003Cul>\n\u003Cli>\u003Cstrong>session\u003C/strong> (the default): when the client disconnects. Safe, and almost pointless for the problem above, because a client that stays connected keeps its server connection the whole time.\u003C/li>\n\u003Cli>\u003Cstrong>transaction\u003C/strong>: when the transaction ends. This is the mode everyone actually wants.\u003C/li>\n\u003Cli>\u003Cstrong>statement\u003C/strong>: after every statement, which means multi-statement transactions are refused. I&#39;ve never seen a good reason to use it for an application.\u003C/li>\n\u003C/ul>\n\u003Cp>With transaction mode, \u003Ccode>max_client_conn\u003C/code> (default 100, raise it) is how many clients can connect to PgBouncer, and \u003Ccode>default_pool_size\u003C/code> (default 20) is how many real connections it opens per user and database pair. A thousand clients on twenty server connections is a normal setup, as long as transactions are short.\u003C/p>\n\u003Ch2>What transaction mode breaks\u003C/h2>\n\u003Cp>The docs put it in one line: clients &quot;must not use any session-based features, since each transaction ends up in a different connection.&quot; In practice that means:\u003C/p>\n\u003Cul>\n\u003Cli>\u003Ccode>SET\u003C/code> outside a transaction. You run \u003Ccode>SET search_path = tenant_42\u003C/code> or \u003Ccode>SET statement_timeout = &#39;5s&#39;\u003C/code>, and your next query may land on a different server connection that never saw it. Worse, the server connection you set it on now carries it for whoever gets it next. PgBouncer only tracks parameters Postgres reports back to the client, and \u003Ccode>search_path\u003C/code> is reported only from Postgres 18 on. On 17 and older, a \u003Ccode>SET search_path\u003C/code> isn&#39;t tracked at all, so it leaks to other clients unless you turn on \u003Ccode>server_reset_query_always\u003C/code>. Use \u003Ccode>SET LOCAL\u003C/code> inside a transaction instead, or put the setting on the role.\u003C/li>\n\u003Cli>\u003Ccode>LISTEN\u003C/code>/\u003Ccode>NOTIFY\u003C/code> listeners. A listener needs one connection that stays open. Give that one process a direct connection to Postgres.\u003C/li>\n\u003Cli>Session-level advisory locks (\u003Ccode>pg_advisory_lock\u003C/code>). The lock belongs to a server connection your client may not have a second later. \u003Ccode>pg_advisory_xact_lock\u003C/code> inside a transaction works.\u003C/li>\n\u003Cli>Temporary tables that outlive a transaction, and cursors declared \u003Ccode>WITH HOLD\u003C/code>.\u003C/li>\n\u003Cli>SQL-level \u003Ccode>PREPARE\u003C/code> and \u003Ccode>EXECUTE\u003C/code>. PgBouncer passes these straight through without tracking them.\u003C/li>\n\u003C/ul>\n\u003Cimg src=\"/blog/pgbouncer/pgbouncer-transaction-mode-set-leak.webp\" alt=\"Two clients sharing server connection 7 in transaction mode: client A sets search_path, then client B lands on the same connection and reads tenant_42\" width=\"1600\" height=\"900\" loading=\"lazy\">\u003Cp>That \u003Ccode>search_path\u003C/code> one is the scary one, because nothing errors. Plenty of production databases are still on 16 or 17, and managed providers move slowly. A multi-tenant app that switches schema with a plain \u003Ccode>SET\u003C/code> can read the wrong tenant&#39;s rows. If you inherit an app that does this, fix it before you put a pooler in front of it.\u003C/p>\n\u003Ch2>Prepared statements: fixed, mostly\u003C/h2>\n\u003Cp>For years the most common PgBouncer error was some form of \u003Ccode>prepared statement &quot;pdo_stmt_00000001&quot; does not exist\u003C/code>. Drivers like PHP&#39;s PDO for Postgres, and plenty of Node and Go libraries, prepare a statement on one server connection and execute it on another.\u003C/p>\n\u003Cp>Version 1.21 (October 2023) added \u003Ccode>max_prepared_statements\u003C/code>, which tracks protocol-level prepared statements and re-prepares them on whichever server connection the client gets. You had to switch it on. Since 1.24 (January 2025) it defaults to 200, so a current PgBouncer handles this on its own. If your distro still ships an older one, check the version before you start debugging the driver.\u003C/p>\n\u003Cp>Prisma has its own rules: its docs still have you add \u003Ccode>pgbouncer=true\u003C/code> to the pooled connection string, and migrations need a \u003Ccode>directUrl\u003C/code> that skips the pooler, because \u003Ccode>prisma migrate\u003C/code> uses session features.\u003C/p>\n\u003Ch2>Managed versions\u003C/h2>\n\u003Cp>If you&#39;re on Supabase you already have a pooler: Supavisor, with transaction mode on port 6543 and session mode on 5432. The same rules about \u003Ccode>SET\u003C/code>, \u003Ccode>LISTEN\u003C/code> and advisory locks apply there. Amazon RDS has RDS Proxy, which does the same job with its own &quot;pinning&quot; behaviour when it sees session state, and it bills per vCPU of the database. Neon puts PgBouncer in front of every project. On a plain VPS, PgBouncer from the distro packages, configured with \u003Ccode>auth_type = scram-sha-256\u003C/code>, is a ten-minute job.\u003C/p>\n\u003Cp>My default: run transaction mode, give migrations, cron jobs with advisory locks and any \u003Ccode>LISTEN\u003C/code> process a direct connection, and grep the codebase for \u003Ccode>SET \u003C/code> before switching. That last grep has the best effort-to-trouble ratio of anything on this list.\u003C/p>\n\u003Cp>If you&#39;re moving an app to serverless functions and the database is the part you&#39;re worried about, that&#39;s work I take on as part of \u003Ca href=\"/services/full-stack-development\">full-stack development\u003C/a>. The \u003Ca href=\"https://calendly.com/director-norsky/meeting\">first call is free\u003C/a>.\u003C/p>\n",[21,25,29],{"slug":22,"title":23,"description":24},"self-host-supabase","Self-Host Supabase: What You Take On","How to self-host Supabase with Docker in 2026: server size, the one-command setup, what you lose (PITR, branching, backups) and when it's worth it.",{"slug":26,"title":27,"description":28},"cloudflare-tunnel","Cloudflare Tunnel: Put a Server Online With No Open Ports","Cloudflare Tunnel checked November 2026: quick tunnels vs named ones, config.yml ingress rules, running cloudflared as a service, 1033 vs 502, and the limits.",{"slug":30,"title":31,"description":32},"node-cron","node-cron in Production: Overlaps, Replicas and DST","node-cron 4 in Node.js, checked November 2026: noOverlap, running on one replica, the day-of-week AND rule, DST gaps, and when systemd or a queue is better.",1794181975034]