Skip to content

Disable psycopg3 auto-prepare by default behind connection poolers - #74581

Merged
vatsrahul1001 merged 11 commits into
mainfrom
psycopg3-disable-auto-prepare
Oct 13, 2026
Merged

vatsrahul1001 merged 11 commits into
mainfrom
psycopg3-disable-auto-prepare

Conversation

@vatsrahul1001

@vatsrahul1001 vatsrahul1001 commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Since 3.4 a bare postgresql:// metadata URL is rewritten to postgresql+psycopg://, so psycopg3 is now the default driver for both the sync and async engines. psycopg3 prepares a statement server-side once it has been executed prepare_threshold times, which defaults to 5.

Under a transaction-mode pooler the next transaction can land on a different server connection, so unless the pooler tracks prepared statements itself, the scheduler starts failing once it crosses that threshold:

sqlalchemy.exc.ProgrammingError: (psycopg.errors.InvalidSqlStatementName)
prepared statement "_pg3_69" does not exist
[SQL: SELECT task_instance.pool, task_instance.state, sum(task_instance.pool_slots) ...]

This is not an exotic setup. The Helm chart sets pool_mode = transaction (chart/templates/_helpers.yaml) and pins PgBouncer 1.23.1 (chart/values.yaml), and PgBouncer only enables prepared statements by default from 1.24. So the deployment we recommend is one of the affected ones.

This sets prepare_threshold = None for psycopg3 URLs in _get_connect_args. Anything in [database] sql_alchemy_connect_args is merged over the top and wins, so operators whose pooler does track prepared statements — or who run without one — can set it back and keep the optimisation.

related: #68496, #69469

Reproducing

Postgres plus PgBouncer in transaction mode with max_prepared_statements = 0, eight concurrent clients running one parameterised query each:

psycopg3 defaults      3/8 workers failed, DuplicatePreparedStatement
prepare_threshold=None 0/8

It only shows up under concurrency, which is why a single-threaded check passes. The same harness against PgBouncer 1.26 passes without this change, since max_prepared_statements defaults to 200 there.

Why this wasn't caught earlier

The driver switch landed in two parts. #68496 moved the async default and was validated against a transaction-mode PgBouncer. #69469 moved the sync default and describes itself as mirroring that change; pooling isn't mentioned. Neither covered a pooler with prepared statements turned off, and the async conclusion doesn't carry over — the async engine reports prepare_threshold=5 too, so the fix applies to both.

Also updated

config.yml described both connect-args options as defaulting to an empty dict, and told operators the async psycopg3 driver needed no extra args. set-up-database.rst made the same claim and now documents the default and how to opt out of it.


Was generative AI tooling used to co-author this PR?
  • Yes — Claude Code (Opus 5)

Generated-by: Claude Code (Opus 5) following the guidelines

Airflow 3.4 makes psycopg3 the default Postgres driver for both the sync and
async metadata engines. psycopg3 prepares a statement server-side after
prepare_threshold executions, which defaults to 5.

Transaction pooling routes consecutive transactions on one client connection to
different server connections, so a pooler that does not itself track prepared
statements cannot honour that. PgBouncer only gained the ability in 1.24; the
chart here pins 1.23.1 and sets pool_mode = transaction, so the deployment the
project recommends is one of the affected ones. Under concurrency the scheduler
crosses the threshold and tasks start failing with DuplicatePreparedStatement.

The driver switch landed in two parts. The async half was validated against a
transaction-mode PgBouncer; the sync half mirrored it without re-testing, and
neither covered a pooler that declines to track prepared statements.

Operators whose pooler does track them, or who run no pooler at all, can set
prepare_threshold back through sql_alchemy_connect_args and keep the speedup.
The newsfragment PR-number check rejects edits to 68496.significant.rst from
this PR, and rightly so. The stale claim is corrected where it outlives the
release notes -- in set-up-database.rst -- and this release note says plainly
that it supersedes the earlier one.
PgBouncer has supported prepared statements since 1.21, not 1.24 -- 1.24 only
turned them on by default. The distinction matters: an operator on 1.21 to 1.23
can set max_prepared_statements and keep preparation on both sides rather than
changing Airflow config, and the text sent them the wrong way.

The async half of _get_connect_args had no coverage, and nothing proved the
default survived into create_engine; both are tested now, and the whole set
fails if the default is removed. Writing the engine-level test surfaced that
conf_vars re-runs configure_vars and rebinds SQL_ALCHEMY_CONN, so a test that
patched the URL first was asserting against the environment's sqlite conn.

config.yml described both connect-args options as defaulting to an empty dict
and told operators psycopg3 needed no extra args, which is the claim this
change exists to refute.

Also names the Celery result backend as out of scope: it builds its own engine
from a derived psycopg3 URL and does not read these options.
Airflow docs are spellchecked against a US English dictionary.
test_sql_alchemy_connect_args compared create_engine's connect_args against the
module-level dict the config points at, and passed because _get_connect_args
handed that very object back. configure_orm then set check_same_thread on it, so
the expectation mutated alongside the actual value.

Merging driver defaults returns a copy instead, which breaks that coincidence on
the sqlite and postgres jobs while mysql still passes. The expectation is now
built from the merge, and the copy also stops Airflow permanently mutating a
dict defined in someone's airflow_local_settings.

Also says what happens when the configured value is not a mapping -- engine
creation fails rather than quietly skipping the default -- and records that
disabling preparation costs executemany its single prepared statement.
The fallback it describes requires a Celery deployment that never set
[celery] result_backend, on 3.4, behind a transaction-mode pooler. Both
the Helm chart and most deployments set it, so giving the case its own
paragraph in a significant note overstates it and makes the fix read as
partial.
@vatsrahul1001
vatsrahul1001 requested a review from kaxil October 12, 2026 13:01
Comment thread airflow-core/src/airflow/settings.py Resolved
…facts

Resolve the driver with make_url().get_driver_name() instead of matching
the URL prefix, so the check agrees with the dialect that actually loads:
sql_alchemy_conn_async is taken verbatim from config with no scheme
normalization, and a bare postgresql:// resolves to psycopg on
SQLAlchemy 2.1.

Correct the PgBouncer requirements in the docstring, the docs and the
newsfragment. psycopg puts the floor at PgBouncer 1.22 and libpq 17, not
the 1.21 taken from PgBouncer's own NEWS, so anyone opting back in from
that paragraph could land right back on the failure this fixes. Say
explicitly that opting back in needs both the pooler and
prepare_threshold, since the default forces it off regardless of what
the pooler supports.

Lead the newsfragment with the deployments that actually change: nothing
released defaults to psycopg3, so it is the ones already setting
postgresql+psycopg:// explicitly that silently lose preparation.

Tests: point sql_alchemy_connect_args at real module-level dicts rather
than patching getimport out, assert the default through configure_orm()
and _configure_async_session() instead of hand-wiring create_engine, and
cover the unparseable-URL branch.
@kaxil
kaxil requested a review from ashb October 12, 2026 16:28
Per review, 69469's driver-default note and this one describe the same
change for the same unreleased version, so they read better as a single
entry than as two. Folds 69469.improvement.rst in and removes it; the
PR-number CI gate skips removed files, so this needs no label.
Per review, the psycopg3 driver note belongs where the default was
introduced. Towncrier types other than 'significant' are single-line
only, so holding the combined body means 69469.improvement.rst becomes
69469.significant.rst.

Keeping a foreign newsfragment in the diff trips
check-newsfragment-pr-number, so this PR carries the
'skip newsfragment check' label.
@vatsrahul1001 vatsrahul1001 added the skip newsfragment check Skip the newsfragment PR number check label Oct 12, 2026
@vatsrahul1001 vatsrahul1001 reopened this Oct 12, 2026
The label landed a second after the previous push, so that run's event
payload predated it and the gate evaluated its 'if' against a PR with no
label. Re-running replays the same payload, so it needs a fresh push.
@vatsrahul1001
vatsrahul1001 requested a review from ashb October 12, 2026 17:12
@vatsrahul1001
vatsrahul1001 merged commit 9fc4143 into main Oct 13, 2026
79 checks passed
@vatsrahul1001
vatsrahul1001 deleted the psycopg3-disable-auto-prepare branch October 13, 2026 04:29
@Dev-iL

Dev-iL commented Oct 13, 2026

Copy link
Copy Markdown
Collaborator

Thanks for fixing! Valuable lesson about sync/async testing....

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

kind:documentation skip newsfragment check Skip the newsfragment PR number check

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants