A high-performance multithreaded PostgreSQL connection pooler built in Rust. Does one thing and does it well — pools connections so your PostgreSQL handles thousands of clients without breaking a sweat.
Drop-in replacement. No app changes. PgDoorman caches and remaps prepared statements transparently across server connections in transaction mode — just point your connection string at it and go. No DISCARD ALL, no DEALLOCATE, no driver hacks. PgBouncer added similar support in 1.21, but remains single-threaded; Odyssey added it in 1.3, but has known reliability issues in edge cases.
Battle-tested with real drivers. Two years of production use with Go (pgx), .NET (Npgsql), Python (asyncpg, SQLAlchemy), Node.js. Protocol edge cases — pipelined batches, async Flush, Describe flow, cancel requests over TLS — are covered by comprehensive multi-language BDD tests.
Natively multithreaded. PgBouncer is single-threaded. Running multiple instances via SO_REUSE_PORT leads to unbalanced pools: clients connect evenly but disconnect unpredictably, leaving some instances overloaded while others sit idle. PgDoorman uses a single shared pool across all worker threads, ensuring correct connection distribution at any scale.
Full extended query protocol support. Benchmarks show Odyssey is up to 61% slower with the extended query protocol in transaction mode. PgDoorman handles simple, extended, and prepared protocols equally well — including pipelined batches and async Flush flow that cause issues in other poolers.
Automated benchmarks on AWS Fargate (16 vCPU, pool size 40, pgbench 30s per test):
| Scenario | vs PgBouncer | vs Odyssey |
|---|---|---|
| Extended protocol, 500 clients + SSL | x3.5 | +61% |
| Prepared statements, 500 clients + SSL | x4.0 | +5% |
| Simple protocol, 10,000 clients | x2.8 | +20% |
| Extended + SSL + Reconnect, 500 clients | +96% | ~0% |
PgBouncer is single-threaded — these ratios reflect a single PgBouncer instance vs a single PgDoorman instance. Full benchmark results.
| PgDoorman | PgBouncer | Odyssey | |
|---|---|---|---|
| Multithreaded | Yes | No | Yes |
| Prepared statements in transaction mode | Yes | Since 1.21 | Since 1.3 |
| Full extended query protocol | Yes | Yes | Partial |
| Zero-downtime binary upgrade | Yes | Yes | Yes |
Deferred BEGIN (lazy server acquire) |
Yes | No | No |
| Auto-config from PostgreSQL | Yes | No | No |
| YAML / TOML config | Yes | No (INI) | No (own format) |
| Human-readable durations & sizes | Yes | No | No |
Native pg_hba.conf format |
Yes | Yes | Since 1.4 |
| Auth query (dynamic users) | Yes | Yes | Yes |
| PAM auth | Yes | Yes | Yes |
| LDAP auth | No | Since 1.25 | Yes |
| PAUSE / RESUME / RECONNECT | Yes | Yes | Yes |
| TLS: minimum TLS 1.2, Mozilla ciphers | Yes | Yes | No (allows TLS 1.0, weak ciphers) |
| Prometheus metrics | Built-in | External | Built-in |
general:
host: "0.0.0.0"
port: 6432
admin_username: "admin"
admin_password: "change_me"
pools:
mydb:
server_host: "127.0.0.1"
server_port: 5432
pool_mode: "transaction"
users:
- username: "app"
password: "md5..." # hash from pg_shadow / pg_authid
pool_size: 40Passthrough authentication (default): When
server_usernameandserver_passwordare omitted, PgDoorman reuses the client's cryptographic proof (MD5 hash or SCRAM ClientKey) to authenticate to PostgreSQL automatically. This is the recommended setup when the pool username matches the backend PostgreSQL user — no plaintext passwords in config needed.Set
server_username/server_passwordonly when the backend user differs from the pool user (e.g., username mapping) or for JWT authentication where there is no password to pass through.
Instead of listing every user in the config, pg_doorman can look up credentials directly from PostgreSQL. The query must return a column named passwd or password containing the MD5 or SCRAM hash. Any extra columns are ignored.
Quickstart — using pg_shadow directly (requires superuser):
pools:
mydb:
server_host: "127.0.0.1"
server_port: 5432
pool_mode: "transaction"
auth_query:
query: "SELECT passwd FROM pg_shadow WHERE usename = $1"
user: "postgres"
password: "postgres_password"By default auth_query runs in passthrough mode: each dynamic user gets their own backend pool and authenticates as themselves. To force all users through a single backend role, set server_user / server_password (dedicated mode).
Static users (defined in
users) are checked first. auth_query is only consulted when the username is not found among static users.
Production: don't use superuser for auth queries. Create a
SECURITY DEFINERfunction with a dedicated low-privilege role instead.
Or generate a config automatically:
pg_doorman generate --host your-db-host --output pg_doorman.yaml# Start
pg_doorman pg_doorman.yaml
# Connect — same as you would to PostgreSQL directly
psql -h localhost -p 6432 -U app mydbYour application connection string changes only the host and port:
postgresql://app:secret@localhost:6432/mydb
PgDoorman supports two pooling modes, configured per pool or per user:
Transaction mode (default, recommended) — server connection is acquired when a transaction starts and released back to the pool when it ends. One backend serves many clients, giving the best connection utilization.
Session mode — server connection is held for the entire client session. Use this when your application relies on session-level features like LISTEN/NOTIFY, temporary tables, or advisory locks.
pools:
mydb:
pool_mode: "transaction" # or "session"What works in each pooling mode:
| Feature | Transaction | Session |
|---|---|---|
| Regular queries (SELECT, INSERT, ...) | Yes | Yes |
| Prepared statements (Parse/Bind/Execute) | Yes (transparent caching) | Yes |
| SET / RESET | Yes (auto-RESET ALL on checkin) | Yes |
| Cursors (DECLARE / FETCH / CLOSE) | Yes (auto-CLOSE ALL on checkin) | Yes |
| LISTEN / NOTIFY | No — use session mode | Yes |
| Temporary tables | No — use session mode | Yes |
Advisory locks (pg_advisory_xact_lock) |
Yes (transaction-scoped) | Yes |
| Session-level advisory locks | No — use pg_advisory_xact_lock |
Unreliable with pooling |
| DISCARD ALL | Yes | Yes |
| COPY | Yes | Yes |
In transaction mode, PgDoorman automatically cleans up server state (RESET ALL, CLOSE ALL) when returning a connection to the pool, so the next client gets a clean connection.
Advisory locks and connection pooling: Session-level advisory locks (
pg_advisory_lock) are unreliable with any connection pooler — the lock is tied to a backend connection, not to your application session, so another client may inherit or release it unexpectedly. Use transaction-levelpg_advisory_xact_lock()instead, which is automatically released at transaction end and works correctly in transaction mode.
Connect to the admin console and manage pools at runtime:
-- Block new connections (active transactions continue)
PAUSE mydb;
PAUSE; -- all pools
-- Unblock waiting clients
RESUME mydb;
RESUME; -- all pools
-- Force backend connection rotation (epoch-based, no downtime)
RECONNECT mydb;
RECONNECT; -- all poolsFull connection rotation pattern: PAUSE → RECONNECT → RESUME.
See admin commands documentation for details.
PgDoorman supports TLS encryption on both the client-facing and server-facing sides.
Encrypt connections between your application and PgDoorman:
general:
tls_certificate: "/path/to/server.crt"
tls_private_key: "/path/to/server.key"
tls_mode: "require" # disable | allow | require | verify-full
# tls_ca_cert: "/path/to/ca.crt" # required for verify-full
# tls_rate_limit_per_second: 500 # limit TLS handshakes (0 = unlimited)| Mode | Behavior |
|---|---|
disable |
TLS not allowed |
allow |
TLS accepted but not required (default when cert is configured) |
require |
TLS required, client certificates not verified |
verify-full |
TLS required, client certificate verified against CA |
Encrypt connections from PgDoorman to PostgreSQL:
general:
server_tls: true
verify_server_certificate: true # verify PostgreSQL server certificatePgDoorman enforces strict TLS defaults out of the box:
- TLS 1.2 minimum — TLS 1.0/1.1 are rejected (deprecated per RFC 8996)
- Mozilla Intermediate cipher suites — only modern AEAD ciphers (AES-GCM, ChaCha20-Poly1305) with forward secrecy (ECDHE/DHE); no RC4, DES, or CBC
- Full hostname verification —
verify-fullchecks both Subject Alternative Names (SANs) and Common Name (CN) via OpenSSL'sverify_hostname(); Odyssey only checks CN, which is obsolete practice - Startup validation — certificates and keys are loaded and verified at startup, not at first connection
Built-in Prometheus metrics endpoint — no external exporters needed.
prometheus:
enabled: true
host: "0.0.0.0"
port: 9127Scrape http://host:9127/ to collect metrics. Key metrics:
| Metric | Labels | Description |
|---|---|---|
pg_doorman_pools_clients |
status, user, database | Clients by status (active / idle / waiting) |
pg_doorman_pools_servers |
status, user, database | Servers by status (active / idle) |
pg_doorman_pools_queries_count |
user, database | Total queries executed |
pg_doorman_pools_queries_percentile |
percentile, user, database | Query time p50 / p90 / p95 / p99 (ms) |
pg_doorman_pools_transactions_count |
user, database | Total transactions executed |
pg_doorman_pools_avg_wait_time |
user, database | Avg client wait for server (ms) |
pg_doorman_pools_bytes |
direction, user, database | Bytes sent / received |
pg_doorman_pool_prepared_cache_entries |
user, database | Prepared statement cache entries |
pg_doorman_total_memory |
— | Process memory usage (bytes) |
pg_doorman_connection_count |
type | Connections by type (plain / tls / total) |
| Signal | Effect |
|---|---|
SIGHUP |
Reload configuration without restart |
SIGUSR2 |
Start binary upgrade + graceful shutdown of old process |
SIGTERM |
Immediate shutdown |
Replace the pg_doorman binary while clients stay connected:
# Replace the binary on disk, then:
kill -USR2 $(cat /tmp/pg_doorman.pid)
# Or from the admin console:
UPGRADE;PgDoorman validates the new binary's configuration (-t flag) before starting it. If validation fails, the upgrade is aborted and the old process continues. Active clients experience no interruption — new connections are served by the new process, existing ones drain gracefully.
For systemd services:
ExecReload=/bin/kill -SIGUSR2 $MAINPIDPre-built binaries: Download from GitHub Releases.
# Ubuntu/Debian
sudo add-apt-repository ppa:vadv/pg-doorman && sudo apt-get install pg-doorman
# Fedora/RHEL/Rocky
sudo dnf copr enable vadvya/pg-doorman && sudo dnf install pg-doorman
# Docker
docker pull ghcr.io/ozontech/pg_doorman# Recommended: build with jemalloc tuning for optimal memory management
JEMALLOC_SYS_WITH_MALLOC_CONF="dirty_decay_ms:30000,muzzy_decay_ms:30000,background_thread:true,metadata_thp:auto" \
cargo build --release
# Binary will be at target/release/pg_doormanPgDoorman uses YAML instead of INI, but the concepts are the same:
| PgBouncer (INI) | PgDoorman (YAML) | Notes |
|---|---|---|
pool_mode = transaction |
pool_mode: "transaction" |
Same semantics |
max_client_conn = 1000 |
general.max_connections: 1000 |
|
default_pool_size = 20 |
users[].pool_size: 20 |
Set per-user, not globally |
server_lifetime = 3600 |
general.server_lifetime: "1h" |
Human-readable durations |
server_idle_timeout = 600 |
general.idle_timeout: "10m" |
|
auth_query = ... |
pools.<db>.auth_query.query: ... |
Same concept, YAML structure |
listen_addr = * |
general.host: "0.0.0.0" |
|
listen_port = 6432 |
general.port: 6432 |
|
admin_users = admin |
general.admin_username: "admin" |
Key differences:
- Prepared statements work out of the box — no
DEALLOCATErequired, transparent caching across connections - Multithreaded — one process, one pool, all CPU cores; no need for
SO_REUSE_PORThacks - Auto-config — run
pg_doorman generate --host your-dbto create a config from PostgreSQL - Human-readable durations —
"30s","5m","1h"instead of raw seconds
This repository also includes patroni_proxy — a TCP proxy for Patroni-managed PostgreSQL clusters. Zero-downtime failover: existing connections are preserved during cluster topology changes.
- pg_doorman deploys on the same host as PostgreSQL — connection pooling and prepared statement caching benefit from low latency to the database
- patroni_proxy deploys as a sidecar in the application pod — TCP routing and role-based failover (leader/sync/async) with least-connections balancing
See patroni_proxy documentation for details.
Full documentation, configuration reference, and tutorials: ozontech.github.io/pg_doorman
make pull # pull test image
make test-bdd # run all integration tests (Docker-based, fully reproducible)See the Contributing Guide for details.
