Repository navigation
Description
Versions: sqlalchemy-foundation-kit 0.4.0 (same code on master, adcd645), SQLAlchemy 2.0.54, asyncpg 0.31.0, Python 3.13.12, PostgreSQL 17, PgBouncer 1.25.2 (pool_mode=transaction)
create_async_session_manager defaults connection_class to AsyncCConnection, "which provides pgbouncer transaction-mode compatibility" (session/factories.py:47-48, 97). The class docstring says unique statement names are "required when using pgbouncer in transaction mode" (session/connection.py:9-17). The PgBouncer section of the configuration guide says the library is safe on old and new PgBouncer alike because the caches are 0 and AsyncCConnection names statements uniquely (docs/guide/configuration.md:468-474). docs/guide/advanced.md:656-658 and docs/agents.md:435-436 say the same. #23 also argued that AsyncCConnection "still earns its place for older PgBouncers". Measured across every combination of the two cache settings, with and without PgBouncer's prepared-statement tracking, the connection class changes nothing:
- With the kit's default caches (0/0),
_get_unique_idis never called. Nothing in asyncpg asks it for a name, because statements are unnamed:prepare(name=None)falls back toself._stmt_cache_enabled, which isFalsewhenstatement_cache_size=0(asyncpg/connection.py:656). - With
statement_cache_size > 0and tracking off, the workload fails withprepared statement "__asyncpg_stmt_..." does not exist, withAsyncCConnectionand with a plainasyncpg.Connectionalike. A UUID in the name does not make a statement exist on a server connection it was never prepared on. - With tracking on (
max_prepared_statements=200), every combination passes with either class.
The version numbers in those paragraphs are off as well. PgBouncer added protocol-level prepared statements in 1.21.0, opt-in with max_prepared_statements defaulting to 0. The default became 200 in 1.24.0 ("Enable prepared statement support by default, max_prepared_statements is now set to 200 by default", PgBouncer changelog). The docs say "Since 1.22 ... max_prepared_statements=200 by default" (docs/guide/configuration.md:439, 468-474, docs/agents.md:435-436, docs/guide/advanced.md:658). So a stock PgBouncer 1.21-1.23 has tracking off.
Reproduction
import asyncio
import re
from collections import Counter
import asyncpg
from pydantic import SecretStr
from sqlalchemy import text
from sqlalchemy_foundation_kit import create_async_session_manager
from sqlalchemy_foundation_kit.contrib.settings import BasePostgresConfig, ConnectionSettings, QuerySettings
from sqlalchemy_foundation_kit.session.connection import AsyncCConnection
from testcontainers.core.container import DockerContainer
from testcontainers.core.network import Network
from testcontainers.core.wait_strategies import LogMessageWaitStrategy
from testcontainers.postgres import PostgresContainer
names_generated = Counter()
class CountingAsyncCConnection(AsyncCConnection):
def _get_unique_id(self, prefix):
names_generated[prefix] += 1
return super()._get_unique_id(prefix)
class CountingPlainConnection(asyncpg.Connection):
def _get_unique_id(self, prefix):
names_generated[prefix] += 1
return super()._get_unique_id(prefix)
def start_pgbouncer(network: Network, **settings: object) -> DockerContainer:
container = DockerContainer("edoburu/pgbouncer:v1.25.2-p0").with_network(network).with_exposed_ports(5432)
env = {"DB_HOST": "db", "DB_USER": "test", "DB_PASSWORD": "test", "DB_NAME": "test",
"AUTH_TYPE": "scram-sha-256", "POOL_MODE": "transaction",
**{k.upper(): str(v) for k, v in settings.items()}}
for key, value in env.items():
container.with_env(key, value)
container.waiting_for(LogMessageWaitStrategy("process up").with_startup_timeout(30))
return container.start()
async def workload(bouncer: DockerContainer, caches: tuple[int, int], connection_class: type) -> str:
config = BasePostgresConfig(
connection=ConnectionSettings(host=bouncer.get_container_host_ip(), port=int(bouncer.get_exposed_port(5432)),
user="test", password=SecretStr("test"), database="test"),
application_name="probe",
query=QuerySettings(statement_cache_size=caches[0], prepared_statement_cache_size=caches[1]),
use_orjson_serialization=False,
)
manager = create_async_session_manager(config, connection_class=connection_class)
names_generated.clear()
outcomes = Counter()
async def worker(n: int) -> None:
for i in range(20):
try:
async with manager.get_transaction() as s:
await s.execute(text("SELECT CAST(:v AS integer) + 1"), {"v": n})
await s.execute(text("SELECT CAST(:v AS text) || 'x'"), {"v": str(i)})
outcomes["ok"] += 1
except Exception as exc:
cause = getattr(getattr(exc, "orig", None), "__cause__", None) or exc
message = re.sub(r'"__asyncpg_stmt_[^"]*"', '"__asyncpg_stmt_..."', str(cause).splitlines()[0])
outcomes[f"{type(cause).__name__}: {message}"] += 1
await asyncio.gather(*(worker(n) for n in range(4)))
await manager.aclose()
lines = [f"ok={outcomes.pop('ok', 0)}, statement names generated={sum(names_generated.values())}"]
lines += [f" {count:3} x {error}" for error, count in outcomes.most_common()]
return "\n".join(lines)
async def main(untracked: DockerContainer, tracking: DockerContainer) -> None:
print("PgBouncer 1.25.2, pool_mode=transaction, default_pool_size=2; 4 workers x 20 transactions x 2 statements")
for bouncer, label in ((untracked, "max_prepared_statements=0"), (tracking, "max_prepared_statements=200 (default)")):
print(f"\n{label}")
for caches in ((0, 0), (100, 0), (100, 100), (0, 100)):
for connection_class in (CountingAsyncCConnection, CountingPlainConnection):
name = "AsyncCConnection" if connection_class is CountingAsyncCConnection else "asyncpg.Connection"
result = await workload(bouncer, caches, connection_class)
print(f" caches {caches[0]:>3}/{caches[1]:<3} {name:18} -> {result}")
with Network() as network, PostgresContainer(
"postgres:17-alpine", username="test", password="test", dbname="test"
).with_network(network).with_network_aliases("db"):
untracked = start_pgbouncer(network, default_pool_size=2, max_client_conn=100, max_prepared_statements=0)
tracking = start_pgbouncer(network, default_pool_size=2, max_client_conn=100)
try:
asyncio.run(main(untracked, tracking))
finally:
untracked.stop()
tracking.stop()Actual
PgBouncer 1.25.2, pool_mode=transaction, default_pool_size=2; 4 workers x 20 transactions x 2 statements
max_prepared_statements=0
caches 0/0 AsyncCConnection -> ok=80, statement names generated=0
caches 0/0 asyncpg.Connection -> ok=80, statement names generated=0
caches 100/0 AsyncCConnection -> ok=65, statement names generated=152
15 x InvalidSQLStatementNameError: prepared statement "__asyncpg_stmt_..." does not exist
caches 100/0 asyncpg.Connection -> ok=74, statement names generated=162
6 x InvalidSQLStatementNameError: prepared statement "__asyncpg_stmt_..." does not exist
caches 100/100 AsyncCConnection -> ok=60, statement names generated=46
20 x InvalidSQLStatementNameError: prepared statement "__asyncpg_stmt_..." does not exist
caches 100/100 asyncpg.Connection -> ok=50, statement names generated=68
30 x InvalidSQLStatementNameError: prepared statement "__asyncpg_stmt_..." does not exist
caches 0/100 AsyncCConnection -> ok=80, statement names generated=0
caches 0/100 asyncpg.Connection -> ok=80, statement names generated=0
max_prepared_statements=200 (default)
caches 0/0 AsyncCConnection -> ok=80, statement names generated=0
caches 0/0 asyncpg.Connection -> ok=80, statement names generated=0
caches 100/0 AsyncCConnection -> ok=80, statement names generated=168
caches 100/0 asyncpg.Connection -> ok=80, statement names generated=168
caches 100/100 AsyncCConnection -> ok=80, statement names generated=16
caches 100/100 asyncpg.Connection -> ok=80, statement names generated=16
caches 0/100 AsyncCConnection -> ok=80, statement names generated=0
caches 0/100 asyncpg.Connection -> ok=80, statement names generated=0
Why
- With
statement_cache_size=0, asyncpg prepares unnamed statements and sends the Parse again with every execution (asyncpg/connection.py:483-492: "We always do this when stmt_cache_size is set to 0 assuming people are running PgBouncer"). A statement therefore never depends on what a server connection remembers, even when SQLAlchemy caches the statement object (the0/100rows). That, and not the connection class, is what makes the defaults safe behind any PgBouncer. - With
statement_cache_size > 0, asyncpg names statements and reuses them in later transactions, through its own cache and SQLAlchemy's. Through a transaction-mode pooler without tracking, a later transaction can land on a different server connection, which has never seen the name. Changing__asyncpg_stmt_12__into__asyncpg_stmt_<uuid4>__cannot fix that. A name collision between two clients (Using statement_cache_size asyncpg setting / prepared statement name for asyncpg w pgbouncer sqlalchemy/sqlalchemy#6467) is the only failure unique names prevent, and it needs this same untracked setup, which fails anyway with "does not exist". With tracking on, PgBouncer rewrites statement names per server connection itself. - The docstring's "unique per connection" also understates it:
uuid4()names are unique across every connection.
Proposal
- Rewrite the rule in the PgBouncer section and in
docs/agents.mdrule 16 as: "Withstatement_cache_size=0(the default) any PgBouncer works, because asyncpg then sends unnamed statements and re-parses them on every execution.statement_cache_size > 0needs PgBouncer >= 1.21 withmax_prepared_statements > 0, which is the default from 1.24. The connection class plays no part." Fix the 1.22 references. - Either stop defaulting
connection_classtoAsyncCConnection(keep the class exported, with a deprecation note), or keep it as a harmless default and change its docstring and thecreate_async_session_managerargument doc so they no longer claim it provides PgBouncer compatibility.
Activity
AlexeyShalaev commentedon Oct 16, 2026
Reproduced against PgBouncer 1.25.2, both with
max_prepared_statements=0and with the default 200: same pattern as your table, withAsyncCConnectionand plainasyncpg.Connectionbehaving the same in every row. The version numbers check out against the PgBouncer changelog: protocol-level prepared statements arrived in 1.21.0 behindmax_prepared_statements(off by default), and the default became 200 in 1.24.0.Plan, docs and docstrings only:
docs/agents.mdrule 16 and the advanced guide get the rule as you wrote it: withstatement_cache_size=0(the default) any PgBouncer works, because asyncpg sends unnamed statements and re-parses them every time.statement_cache_size > 0needs PgBouncer 1.21+ withmax_prepared_statements > 0, the default from 1.24. The connection class plays no part. The 1.22 references get fixed.connection_classkeeps defaulting toAsyncCConnection. It's harmless (a uuid per named statement, and none at all with the default caches), and changing a default that does nothing would be churn. The class docstring and thecreate_async_session_managerargument doc stop claiming it provides PgBouncer compatibility and describe what it does: uuid statement names, which only matter for name collisions on a shared server connection, a setup that fails with "does not exist" anyway.Shipping together with #58 in one PR, since both rewrite the same section.
AlexeyShalaev commentedon Oct 16, 2026
Docs are in #67, together with #58. The PgBouncer rule now credits
statement_cache_size=0, not the connection class, and the version boundary is fixed. I checked it on stock images: 1.21.0, 1.22.1 and 1.23.1 reportmax_prepared_statements=0, while 1.24.1 and 1.25.2 report 200. TheAsyncCConnectiondocstring and theconnection_classdoc no longer claim PgBouncer compatibility. The class stays the default because it's harmless.Nothing to change on your side.