Measured on 0.2.1 against PostgreSQL 17 and PgBouncer 1.25.2 in transaction mode, PgBouncer at its defaults. create_async_session_manager(BasePostgresConfig(...)) with nothing but host, credentials and an application name:
--- sqlalchemy-foundation-kit, its pgbouncer-safe settings ---
PgBouncer transaction mode, defaults ok=0 errors={'ProtocolViolationError': 800}
first error: unsupported startup parameter: jit
Every connection is refused. BasePostgresConfig.jit defaults to "off" and create_async_session_manager sends it as an asyncpg server_settings entry, which arrives at PgBouncer as a startup parameter, and PgBouncer only passes through the handful it tracks (client_encoding, datestyle, timezone, standard_conforming_strings, application_name). The configuration guide says jit="off" # Required for pgbouncer; measured, it is the one setting that makes the connection impossible through PgBouncer.
The usual admin-side workaround makes it worse. With ignore_startup_parameters = jit,search_path in PgBouncer the connections succeed, and PgBouncer drops both parameters on the floor:
direct to PostgreSQL jit='off' search_path='app' application_name='probe'
PgBouncer, ignore_startup_parameters=jit,search_path jit='on' search_path='"$user", public' application_name='probe'
So db_schema is silently not applied and every query lands in public. That is the dangerous half: a setting the library accepted and the database ignored.
For context, the reason this configuration existed is gone on current PgBouncer. Plain SQLAlchemy + asyncpg with the driver's default statement cache runs 800 of 800 statements through PgBouncer 1.25 in transaction mode without an error, because max_prepared_statements has defaulted to 200 since 1.22. Forcing max_prepared_statements=0 brings the classic failure back (320 of 800: prepared statement "__asyncpg_stmt_346__" does not exist), so the zero statement caches and AsyncCConnection still earn their place for older PgBouncers; jit never did.
What I think it needs: jit should default to None and never be sent as a startup parameter unless asked for; db_schema should not be delivered as a search_path startup parameter either, because under transaction pooling the only places a per-role setting can live are ALTER ROLE ... SET / ALTER DATABASE ... SET on the server, or PgBouncer's track_extra_parameters (1.18+), and inside a transaction as SET LOCAL. The unit of work owns the transaction, so SET LOCAL search_path at the start of each transaction() / query() block is the one form that is correct through a transaction pooler; that or schema-qualified metadata, documented as the choice. The configuration guide's PgBouncer section needs rewriting around what was measured: the statement caches, the connection class, and the parameters PgBouncer will and will not carry.
Scripts: pgbouncer_lab.py and jit_probe.py in https://github.com/bedrock-python/bedrock-python.github.io/tree/docs/production-python-series/docs/blog/lab/2026-09-07-pgbouncer-async-sqlalchemy.
Measured on 0.2.1 against PostgreSQL 17 and PgBouncer 1.25.2 in transaction mode, PgBouncer at its defaults.
create_async_session_manager(BasePostgresConfig(...))with nothing but host, credentials and an application name:Every connection is refused.
BasePostgresConfig.jitdefaults to"off"andcreate_async_session_managersends it as an asyncpgserver_settingsentry, which arrives at PgBouncer as a startup parameter, and PgBouncer only passes through the handful it tracks (client_encoding,datestyle,timezone,standard_conforming_strings,application_name). The configuration guide saysjit="off" # Required for pgbouncer; measured, it is the one setting that makes the connection impossible through PgBouncer.The usual admin-side workaround makes it worse. With
ignore_startup_parameters = jit,search_pathin PgBouncer the connections succeed, and PgBouncer drops both parameters on the floor:So
db_schemais silently not applied and every query lands inpublic. That is the dangerous half: a setting the library accepted and the database ignored.For context, the reason this configuration existed is gone on current PgBouncer. Plain SQLAlchemy + asyncpg with the driver's default statement cache runs 800 of 800 statements through PgBouncer 1.25 in transaction mode without an error, because
max_prepared_statementshas defaulted to 200 since 1.22. Forcingmax_prepared_statements=0brings the classic failure back (320 of 800:prepared statement "__asyncpg_stmt_346__" does not exist), so the zero statement caches andAsyncCConnectionstill earn their place for older PgBouncers;jitnever did.What I think it needs:
jitshould default toNoneand never be sent as a startup parameter unless asked for;db_schemashould not be delivered as asearch_pathstartup parameter either, because under transaction pooling the only places a per-role setting can live areALTER ROLE ... SET/ALTER DATABASE ... SETon the server, or PgBouncer'strack_extra_parameters(1.18+), and inside a transaction asSET LOCAL. The unit of work owns the transaction, soSET LOCAL search_pathat the start of eachtransaction()/query()block is the one form that is correct through a transaction pooler; that or schema-qualified metadata, documented as the choice. The configuration guide's PgBouncer section needs rewriting around what was measured: the statement caches, the connection class, and the parameters PgBouncer will and will not carry.Scripts:
pgbouncer_lab.pyandjit_probe.pyin https://github.com/bedrock-python/bedrock-python.github.io/tree/docs/production-python-series/docs/blog/lab/2026-09-07-pgbouncer-async-sqlalchemy.