Skip to content

Switch the default async Postgres driver from asyncpg to psycopg3 (core) - #68496

Merged
shahar1 merged 1 commit into
apache:mainfrom
Dev-iL:2606/psycopg3-async-default
Jul 4, 2026
Merged

Switch the default async Postgres driver from asyncpg to psycopg3 (core)#68496
shahar1 merged 1 commit into
apache:mainfrom
Dev-iL:2606/psycopg3-async-default

Conversation

@Dev-iL

@Dev-iLDev-iL commented Jun 13, 2026

Copy link
Copy Markdown
Collaborator

depends on:

related:

closes:


What

When [database] sql_alchemy_conn_async is not set, Airflow derives the async metadata-database URL from sql_alchemy_conn. For PostgreSQL, the derived URL now uses psycopg3 (postgresql+psycopg_async://) instead of asyncpg (postgresql+asyncpg://).

Packaging follows: apache-airflow-providers-postgres now installs psycopg[binary] by default, and asyncpg moves to a new opt-in asyncpg extra. psycopg2-binary is unchanged — the sync engine still uses psycopg2.

Why

Airflow recommends running PgBouncer in front of PostgreSQL in production. asyncpg uses named server-side prepared statements, which break under transaction-mode PgBouncer unless prepared-statement caching is explicitly disabled. psycopg3 is safe behind transaction-mode PgBouncer with zero configuration, so the default async engine now works out of the box in recommended production deployments. A safe default beats a documentation note operators miss.

asyncpg was originally chosen only because Airflow was pinned to SQLAlchemy 1.4; that constraint is gone (airflow-core requires sqlalchemy[asyncio]>=2.0.48), and psycopg3 serves both sync and async from a single driver.

Keeping asyncpg

asyncpg remains fully supported as a throughput opt-in:

pip install 'apache-airflow-providers-postgres[asyncpg]'
[database]sql_alchemy_conn_async = postgresql+asyncpg://<user>:<password>@<host>/<db>

Behind transaction-mode PgBouncer, also disable asyncpg's prepared-statement caching via sql_alchemy_connect_args_async (a dict defined in airflow_local_settings.py):

# airflow_local_settings.pyconnect_args_async= {"statement_cache_size": 0, "prepared_statement_cache_size": 0}

Notes for reviewers

Validation

Validated end to end against a real transaction-mode PgBouncer in front of Postgres (dev/pgbouncer_e2e/): the default-derived psycopg_async engine runs repeated single-row and row-returning queries with no named prepared statements left on the backend, and the documented asyncpg opt-in recipe behaves the same.

Appendix: E2E tests

docker-compose.yaml

# Throwaway stack for validating the default async metadata engine behind# transaction-mode PgBouncer (issue #67801 acceptance check). Not for production.services:
postgres:
image: postgres:16environment:
POSTGRES_USER: airflowPOSTGRES_PASSWORD: airflowPOSTGRES_DB: airflowports:
- "55432:5432"healthcheck:
test: ["CMD-SHELL", "pg_isready -U airflow"]interval: 2stimeout: 2sretries: 30pgbouncer:
image: edoburu/pgbouncer:latestenvironment:
DB_HOST: postgresDB_USER: airflowDB_PASSWORD: airflowDB_NAME: airflowPOOL_MODE: transactionAUTH_TYPE: scram-sha-256LISTEN_PORT: "6432"ports:
- "56432:6432"depends_on:
postgres:
condition: service_healthy

pgbouncer_e2e.py

"""E2E validation for issue #67801: the default-derived async Postgres engine must workbehind transaction-mode PgBouncer with no extra configuration, and the documentedasyncpg opt-in recipe must also work.Run after `docker compose -f dev/pgbouncer_e2e/docker-compose.yaml up -d`: uv run --project airflow-core python dev/pgbouncer_e2e/run_e2e.py"""from __future__ importannotationsimportasyncioimportosimportsysPGBOUNCER_SYNC_URL=os.environ.get(
"E2E_SYNC_URL", "postgresql+psycopg2://airflow:airflow@localhost:56432/airflow"
)
BACKEND_DIRECT_URL=os.environ.get(
"E2E_BACKEND_URL", "postgresql+psycopg2://airflow:airflow@localhost:55432/airflow"
)
NUM_QUERIES=50defcount_backend_prepared_statements(samples: int=20) ->int:
""" Count named prepared statements visible on PgBouncer's pooled backend connections. ``pg_prepared_statements`` is connection-local, so the leftovers from a previous client are only visible when PgBouncer hands us the same backend connection. Sample many short transactions through PgBouncer (transaction mode rotates backends) and report the maximum seen on any backend. """fromsqlalchemyimportcreate_engine, textfromsqlalchemy.poolimportNullPoolpgbouncer_url=PGBOUNCER_SYNC_URLworst=0engine=create_engine(pgbouncer_url, poolclass=NullPool)
for_inrange(samples):
withengine.connect() asconn:
count=conn.execute(text("SELECT count(*) FROM pg_prepared_statements")).scalar()
worst=max(worst, int(countor0))
engine.dispose()
returnworstasyncdefexercise_async_engine(async_url: str, connect_args: dict) ->None:
fromsqlalchemyimporttextfromsqlalchemy.ext.asyncioimportcreate_async_engineengine=create_async_engine(async_url, connect_args=connect_args, pool_pre_ping=True)
foriinrange(NUM_QUERIES):
asyncwithengine.connect() asconn:
value= (awaitconn.execute(text("SELECT 1"))).scalar()
ifvalue!=1:
raiseRuntimeError(f"query {i} returned {value!r}")
ti= (
awaitconn.execute(text("SELECT typname FROM pg_type WHERE typname = 'int4' LIMIT 1"))
).scalar()
ifti!="int4":
raiseRuntimeError(f"row-returning query {i} returned {ti!r}")
awaitengine.dispose()
defderive_default_async_url() ->str:
os.environ["AIRFLOW__DATABASE__SQL_ALCHEMY_CONN"] =PGBOUNCER_SYNC_URLos.environ.pop("AIRFLOW__DATABASE__SQL_ALCHEMY_CONN_ASYNC", None)
fromairflowimportsettingssettings.configure_vars()
derived=settings.SQL_ALCHEMY_CONN_ASYNCprint(f"derived async URL: {derived}")
ifnotderived.startswith("postgresql+psycopg_async://"):
raiseRuntimeError(f"unexpected derived async URL: {derived}")
returnderiveddefmain() ->int:
# Step 1+2: default derivation, then 50 queries through PgBouncer with the derived enginederived=derive_default_async_url()
asyncio.run(exercise_async_engine(derived, connect_args={}))
print(f"step 2 OK: {NUM_QUERIES} queries via default psycopg_async engine through PgBouncer")
# Step 3: no named prepared statements may remain on the backendleftover=count_backend_prepared_statements()
print(f"step 3: backend prepared statements after psycopg run: {leftover}")
ifleftover!=0:
print("FAIL: psycopg run left prepared statements on the backend")
return1# Step 4: explicit asyncpg opt-in with the documented PgBouncer-safe recipeasyncpg_url=PGBOUNCER_SYNC_URL.replace("postgresql+psycopg2://", "postgresql+asyncpg://")
asyncio.run(
exercise_async_engine(
asyncpg_url,
connect_args={"statement_cache_size": 0, "prepared_statement_cache_size": 0},
)
)
leftover=count_backend_prepared_statements()
print(f"step 4: backend prepared statements after asyncpg recipe run: {leftover}")
ifleftover!=0:
print("FAIL: asyncpg recipe run left prepared statements on the backend")
return1print("E2E PASS")
return0if__name__=="__main__":
sys.exit(main())


Was generative AI tooling used to co-author this PR?
  • Yes (please specify the tool below)

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


  • Read the Pull Request Guidelines for more information. Note: commit author/co-author name and email in commits become permanently public when merged.
  • For fundamental code changes, an Airflow Improvement Proposal (AIP) is needed.
  • When adding dependency, check compliance with the ASF 3rd Party License Policy.
  • For significant user-facing changes create newsfragment: {pr_number}.significant.rst, in airflow-core/newsfragments. You can add this file in a follow-up commit after the PR is created so you know the PR number.

@Dev-iLDev-iL added the full tests needed We need to run full set of tests for this PR to merge label Jun 13, 2026
@Dev-iL
Dev-iLforce-pushed the 2606/psycopg3-async-default branch 2 times, most recently from d317847 to 07bb2e6CompareJune 13, 2026 12:27
@Dev-iL
Dev-iL marked this pull request as ready for review June 13, 2026 15:12
@Dev-iL
Dev-iL requested a review from ashbJune 13, 2026 15:39
@Dev-iL
Dev-iLforce-pushed the 2606/psycopg3-async-default branch from 07bb2e6 to 602f3cfCompareJune 14, 2026 01:24
Comment threadairflow-core/docs/howto/set-up-database.rst
@Dev-iL
Dev-iLforce-pushed the 2606/psycopg3-async-default branch from 602f3cf to 9e7ad60CompareJune 14, 2026 05:26
@Dev-iL

Copy link
Copy Markdown
CollaboratorAuthor

Caveat: psycopg3 uses server-side prepared statements (via protocol-level PQsendPrepare/PQsendQueryPrepared), not client-side. Its default prepare_threshold=5 means it will create server-side prepared statements after 5 executions of the same query on a connection. Neither Airflow nor SQLAlchemy sets prepare_threshold=None.

The psycopg3 docs explicitly warn that poolers are not compatible with prepared statements unless:

  • PgBouncer >= 1.22 (with max_prepared_statements > 0)
  • libpq from PostgreSQL >= 17 (client-side)
  • psycopg >= 3.2

Our Helm chart ships PgBouncer 1.23.1 and pins psycopg >= 3.2.9, so the first and third conditions are met for chart users. But CI tests Postgres 14–18 (default 14), so the libpq requirement (>= 17) is not always satisfied. Users running their own PgBouncer < 1.22 would also hit this.

Should we consider:

  1. Adding PgBouncer to the integration test matrix (at least for the async engine path) to catch regressions across the version combinations?
  2. Setting prepare_threshold=None as the safe default in settings.py for the async engine when no explicit connect_args_async is configured — making the "no extra configuration" claim actually hold for all versions?

@github-actions

github-actionsBot commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

uv.lock on main just moved via #68856 ("Revert airflowctl dependency from airflow-core"), commit 6877aea and this PR currently conflicts.

Quickest fix:

git fetch upstream main && git rebase upstream/main
rm uv.lock && uv lock
git add uv.lock && git rebase --continue
git push --force-with-lease

Automated nudge — ignore if you're not ready to rebase. This comment is updated in place on future uv.lock bumps.

@Dev-iL
Dev-iLforce-pushed the 2606/psycopg3-async-default branch from 9e7ad60 to 36c8d3cCompareJune 18, 2026 04:36
@Dev-iLDev-iL added the ready for maintainer review Set after triaging when all criteria pass. label Jun 18, 2026
@Dev-iL
Dev-iLforce-pushed the 2606/psycopg3-async-default branch 2 times, most recently from b6b1156 to a2fbceaCompareJune 21, 2026 14:54
@Dev-iL

Copy link
Copy Markdown
CollaboratorAuthor

@ashb@uranusjr what do you say?

Should we consider:

  1. Adding PgBouncer to the integration test matrix (at least for the async engine path) to catch regressions across the version combinations?
  2. Setting prepare_threshold=None as the safe default in settings.py for the async engine when no explicit connect_args_async is configured, making the "no extra configuration" claim actually hold for all versions?

@Dev-iL
Dev-iLforce-pushed the 2606/psycopg3-async-default branch 2 times, most recently from 99fa906 to 70babcbCompareJune 24, 2026 15:07

@shahar1shahar1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall LGTM, one comment to address from my prespective.
Also, could you please rebase onto latest main? (there seems to be a drift in some packages within uv.lock)

Edit: when the order of PR merges matters, it's important also to state it at the top of the description to avoid mistakes by committers :) Added it for you.

@Dev-iL
Dev-iLforce-pushed the 2606/psycopg3-async-default branch from 70babcb to f4d0b0eCompareJune 27, 2026 19:00
@Dev-iLDev-iL changed the title Switch the default async Postgres driver from asyncpg to psycopg3Switch the default async Postgres driver from asyncpg to psycopg3 (core)Jun 27, 2026
Comment threadairflow-core/src/airflow/settings.py Outdated
@Dev-iL
Dev-iLforce-pushed the 2606/psycopg3-async-default branch 2 times, most recently from f4179f6 to 3ca6f90CompareJuly 2, 2026 11:19
Comment threadairflow-core/src/airflow/settings.py Outdated
The derived async metadata-DB URL now prefers postgresql+psycopg_async://, which is safe behind transaction-mode PgBouncer with no configuration, and falls back to asyncpg when psycopg3 is not installed — so a newer core keeps working with an older postgres provider that ships asyncpg only.
The matching driver ships in the postgres provider (>=7.0.0), which keeps asyncpg available via an opt-in extra and an explicit sql_alchemy_conn_async URL. This default takes effect in Airflow 3.4.0.
@Dev-iL
Dev-iLforce-pushed the 2606/psycopg3-async-default branch from 3ca6f90 to 1d6296dCompareJuly 4, 2026 17:17
@shahar1
shahar1 merged commit f10ebb5 into apache:mainJul 4, 2026
146 checks passed
@Dev-iL
Dev-iL deleted the 2606/psycopg3-async-default branch July 4, 2026 18:46
Dev-iL added a commit to Dev-iL/airflow that referenced this pull request Jul 6, 2026
Mirrors the async default switch (apache#67801/apache#68496): a bare postgresql://
or legacy postgres:// / postgres+psycopg2:// sql_alchemy_conn is now
rewritten to postgresql+psycopg:// instead of postgresql+psycopg2://
when the psycopg (v3) package is installed, falling back to
postgresql+psycopg2:// otherwise so environments without psycopg3
keep working. The psycopg2-specific executemany tuning in
prepare_engine_args now follows the actually-configured driver
instead of whether psycopg happens to be importable. An explicit
postgresql+psycopg2:// URL is never rewritten.
Also updates hardcoded postgresql+psycopg2:// example connection
strings across docs and reference deployment configs that would
otherwise break for anyone following them as-is now that the
postgres provider no longer bundles psycopg2-binary by default.
Part of the migration tracked in apache#68453.
Dev-iL added a commit to Dev-iL/airflow that referenced this pull request Jul 6, 2026
Mirrors the async default switch (apache#67801/apache#68496): a bare postgresql://
or legacy postgres:// / postgres+psycopg2:// sql_alchemy_conn is now
rewritten to postgresql+psycopg:// instead of postgresql+psycopg2://
when the psycopg (v3) package is installed, falling back to
postgresql+psycopg2:// otherwise so environments without psycopg3
keep working. The psycopg2-specific executemany tuning in
prepare_engine_args now follows the actually-configured driver
instead of whether psycopg happens to be importable. An explicit
postgresql+psycopg2:// URL is never rewritten.
Also updates hardcoded postgresql+psycopg2:// example connection
strings across docs and reference deployment configs that would
otherwise break for anyone following them as-is now that the
postgres provider no longer bundles psycopg2-binary by default.
Part of the migration tracked in apache#68453.
Dev-iL added a commit to Dev-iL/airflow that referenced this pull request Jul 7, 2026
Mirrors the async default switch (apache#67801/apache#68496): a bare postgresql://
or legacy postgres:// / postgres+psycopg2:// sql_alchemy_conn is now
rewritten to postgresql+psycopg:// instead of postgresql+psycopg2://
when the psycopg (v3) package is installed, falling back to
postgresql+psycopg2:// otherwise so environments without psycopg3
keep working. The psycopg2-specific executemany tuning in
prepare_engine_args now follows the actually-configured driver
instead of whether psycopg happens to be importable. An explicit
postgresql+psycopg2:// URL is never rewritten.
Also updates hardcoded postgresql+psycopg2:// example connection
strings across docs and reference deployment configs that would
otherwise break for anyone following them as-is now that the
postgres provider no longer bundles psycopg2-binary by default.
Part of the migration tracked in apache#68453.
Dev-iL added a commit to Dev-iL/airflow that referenced this pull request Jul 11, 2026
Mirrors the async default switch (apache#67801/apache#68496): a bare postgresql://
or legacy postgres:// / postgres+psycopg2:// sql_alchemy_conn is now
rewritten to postgresql+psycopg:// instead of postgresql+psycopg2://
when the psycopg (v3) package is installed, falling back to
postgresql+psycopg2:// otherwise so environments without psycopg3
keep working. The psycopg2-specific executemany tuning in
prepare_engine_args now follows the actually-configured driver
instead of whether psycopg happens to be importable. An explicit
postgresql+psycopg2:// URL is never rewritten.
Also updates hardcoded postgresql+psycopg2:// example connection
strings across docs and reference deployment configs that would
otherwise break for anyone following them as-is now that the
postgres provider no longer bundles psycopg2-binary by default.
Part of the migration tracked in apache#68453.
Dev-iL added a commit to Dev-iL/airflow that referenced this pull request Jul 14, 2026
Mirrors the async default switch (apache#67801/apache#68496): a bare postgresql://
or legacy postgres:// / postgres+psycopg2:// sql_alchemy_conn is now
rewritten to postgresql+psycopg:// instead of postgresql+psycopg2://
when the psycopg (v3) package is installed, falling back to
postgresql+psycopg2:// otherwise so environments without psycopg3
keep working. The psycopg2-specific executemany tuning in
prepare_engine_args now follows the actually-configured driver
instead of whether psycopg happens to be importable. An explicit
postgresql+psycopg2:// URL is never rewritten.
Also updates hardcoded postgresql+psycopg2:// example connection
strings across docs and reference deployment configs that would
otherwise break for anyone following them as-is now that the
postgres provider no longer bundles psycopg2-binary by default.
Part of the migration tracked in apache#68453.
Dev-iL added a commit to Dev-iL/airflow that referenced this pull request Jul 15, 2026
Mirrors the async default switch (apache#67801/apache#68496): a bare postgresql://
or legacy postgres:// / postgres+psycopg2:// sql_alchemy_conn is now
rewritten to postgresql+psycopg:// instead of postgresql+psycopg2://
when the psycopg (v3) package is installed, falling back to
postgresql+psycopg2:// otherwise so environments without psycopg3
keep working. The psycopg2-specific executemany tuning in
prepare_engine_args now follows the actually-configured driver
instead of whether psycopg happens to be importable. An explicit
postgresql+psycopg2:// URL is never rewritten.
Also updates hardcoded postgresql+psycopg2:// example connection
strings across docs and reference deployment configs that would
otherwise break for anyone following them as-is now that the
postgres provider no longer bundles psycopg2-binary by default.
Part of the migration tracked in apache#68453.
Dev-iL added a commit to Dev-iL/airflow that referenced this pull request Jul 15, 2026
Mirrors the async default switch (apache#67801/apache#68496): a bare postgresql://
or legacy postgres:// / postgres+psycopg2:// sql_alchemy_conn is now
rewritten to postgresql+psycopg:// instead of postgresql+psycopg2://
when the psycopg (v3) package is installed, falling back to
postgresql+psycopg2:// otherwise so environments without psycopg3
keep working. The psycopg2-specific executemany tuning in
prepare_engine_args now follows the actually-configured driver
instead of whether psycopg happens to be importable. An explicit
postgresql+psycopg2:// URL is never rewritten.
Also updates hardcoded postgresql+psycopg2:// example connection
strings across docs and reference deployment configs that would
otherwise break for anyone following them as-is now that the
postgres provider no longer bundles psycopg2-binary by default.
Part of the migration tracked in apache#68453.
Dev-iL added a commit to Dev-iL/airflow that referenced this pull request Jul 15, 2026
Mirrors the async default switch (apache#67801/apache#68496): a bare postgresql://
or legacy postgres:// / postgres+psycopg2:// sql_alchemy_conn is now
rewritten to postgresql+psycopg:// instead of postgresql+psycopg2://
when the psycopg (v3) package is installed, falling back to
postgresql+psycopg2:// otherwise so environments without psycopg3
keep working. The psycopg2-specific executemany tuning in
prepare_engine_args now follows the actually-configured driver
instead of whether psycopg happens to be importable. An explicit
postgresql+psycopg2:// URL is never rewritten.
Also updates hardcoded postgresql+psycopg2:// example connection
strings across docs and reference deployment configs that would
otherwise break for anyone following them as-is now that the
postgres provider no longer bundles psycopg2-binary by default.
Part of the migration tracked in apache#68453.
Dev-iL added a commit to Dev-iL/airflow that referenced this pull request Jul 20, 2026
Mirrors the async default switch (apache#67801/apache#68496): a bare postgresql://
or legacy postgres:// / postgres+psycopg2:// sql_alchemy_conn is now
rewritten to postgresql+psycopg:// instead of postgresql+psycopg2://
when the psycopg (v3) package is installed, falling back to
postgresql+psycopg2:// otherwise so environments without psycopg3
keep working. The psycopg2-specific executemany tuning in
prepare_engine_args now follows the actually-configured driver
instead of whether psycopg happens to be importable. An explicit
postgresql+psycopg2:// URL is never rewritten.
Also updates hardcoded postgresql+psycopg2:// example connection
strings across docs and reference deployment configs that would
otherwise break for anyone following them as-is now that the
postgres provider no longer bundles psycopg2-binary by default.
Part of the migration tracked in apache#68453.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:providersfull tests neededWe need to run full set of tests for this PR to mergekind:documentationprovider:postgresready for maintainer reviewSet after triaging when all criteria pass.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@Dev-iL@uranusjr@shahar1