Okay, this is going to sound dumb, but I spent two hours yesterday explaining to a junior engineer why our connection pool was a Rust process and not the PgBouncer they expected from every other system at the company. The answer was that we’d hit a load profile PgBouncer couldn’t handle without prepared-statement-cache contortions, and PgCat had quietly solved it. That conversation is the post.
I’ve run PgBouncer on production Postgres clusters since around 2017. It works. It is one of the most boring, reliable pieces of infrastructure I own. I would not have switched any of it if not for one specific workload that started melting it.
The workload that pushed me off PgBouncer
A Node service was hitting Postgres with a high rate of small, fast queries from pg’s prepared-statement mode. PgBouncer in transaction-pooling mode does not support prepared statements out of the box. We’d been working around that with a per-connection prepare-on-first-use pattern that mostly worked until the connection-recycling churn kicked in and our P99 latency started doing things I didn’t like.
The options at that point were:
- Switch the driver to session pooling (gives up most of the connection-multiplexing win).
- Use PgBouncer’s newer prepared-statement support, which exists but had specific constraints in our version that didn’t match our query pattern.
- Try PgCat, which advertises prepared-statement support in transaction mode natively.
We tried PgCat for one week on one service. It stuck.
What PgCat does that I actually use
Prepared statements in transaction mode
This is the headline feature for us. PgCat tracks prepared statements per-server-connection and rewrites incoming statements so the pool can multiplex aggressively without the application caring. Our pg driver’s prepare: true path now works on PgCat the way I always assumed it should work on PgBouncer.
Sharding without the application knowing
Our old PgBouncer setup had no sharding awareness, so the application owned routing. PgCat reads a configured sharding function and routes queries based on a SET shard hint or based on hashing a column. We don’t shard our main DB yet, but a side database that does has gotten much simpler. The application sends SET shard = N once per transaction and PgCat handles the rest.
Built-in failover with leader sensing
PgCat can be told about a primary plus replicas and will round-robin reads to replicas while sending writes to the primary. We previously did this with HAProxy in front of two PgBouncers, which worked but required keeping two layers consistent. One layer instead of two is one fewer thing to break at 3am.
A configuration model I find easier to reason about
PgBouncer’s ini-style config with a separate userlist.txt for credentials is fine, and I have written it from memory. PgCat’s TOML is also fine, and I find pool definitions easier to scan because every pool’s settings are co-located. This is taste, not a real feature. I mention it because configuration ergonomics is the kind of thing you live with for years.
Where PgBouncer is still better
I’d be lying if I said PgCat won everywhere:
- Memory footprint. PgBouncer sits at about 20MB for the workloads I run. PgCat sits at about 90MB for comparable pool sizes. Not a problem on a real server. Annoying on a tiny VPS.
- Documentation. PgBouncer’s docs are old, complete, and battle-tested. PgCat’s docs are improving but still have gaps you’ll find by reading the source or asking in their Discord.
- Operational track record. PgBouncer has been running everywhere for over a decade. PgCat has been running everywhere serious for maybe two years. If your shop’s risk tolerance is low, that delta matters.
- Auth modes. PgBouncer’s
scram-sha-256story is mature and works against any modern Postgres. PgCat supports it too, but I have hit one auth edge case that PgBouncer would have handled silently.
If I were starting a new boring service tomorrow and didn’t have the prepared-statement pain, I’d reach for PgBouncer first. It’s still the default answer for most Postgres deployments and probably will be for years.
The migration that worked
The order I went in for the one service I migrated:
- Stand up PgCat next to PgBouncer, pointing at the same Postgres. No app changes.
- Connect one engineer’s laptop to PgCat and run the test suite against it.
- Add a 1% traffic split via a feature flag in the connection-string selection. Watch error rates and P99 for a day.
- Ramp to 10%, 50%, 100% over a week.
- Once 100% has been stable for two weeks, remove PgBouncer from this pool’s deployment.
Don’t do step 3 without metrics. You need to see error rates split by which pool served the request. Without that signal you’re flying blind and PgCat’s prepared-statement handling, while good, is not bug-free.
While we’re on the topic of database hygiene, the Drizzle vs Prisma post I wrote ended up touching some of the same pooling decisions, because the driver you pick affects whether you can use transaction pooling at all.
What I would do differently
Two things, in retrospect:
- Set up the PgCat admin console early and learn its query syntax before you need it. The admin DB (documented here) is how you’d debug a stuck pool, and it is meaningfully different from PgBouncer’s
SHOW POOLSstyle. - Run synthetic load against PgCat in a staging environment that matches production query shape. I did smoke tests and missed the fact that one of our endpoints uses extended-query-protocol with bind parameters in an unusual way. Caught it in staging, but I should have caught it before staging.
When you should probably ignore this post
If you’re not currently in pain over prepared statements, sharding, or read-replica routing, PgBouncer is the answer. Don’t switch tools to switch tools. The cost of running unfamiliar infrastructure is real and is paid mostly in 2am minutes.
If you are in one of those pain points, PgCat is worth a week of evaluation. I’ve helped a few teams run this kind of database-tier evaluation and the answer often comes out as “PgBouncer for these services, PgCat for these two, never both for the same pool.” Mixing them in the same pool is asking for split-brain in your monitoring.
One concrete thing to do this week
Pick your highest-QPS Postgres-backed service and check whether you’re paying a hidden cost for prepared-statement workarounds. The cheapest signal is grep your driver config for prepare: false or equivalent flags that were added because of PgBouncer. Every one of those is a feature you turned off to keep the pool happy. PgCat may let you turn them back on. Or it may not. Either way, you’ll have a much clearer picture than you do now.
PgBouncer is still the default. PgCat is a good second instrument to learn for the specific cases where the default starts hurting.