Twenty days from now, Apache Airflow 2.x reaches end of life. After April 22, no more security patches, no more backports, no more pretending you'll "get to it next quarter." If you're still running Airflow 2, the clock is loud.
I've spent the last few weeks watching teams scramble through this migration, and the pattern is always the same: they assume it's a version bump, discover it's closer to a rewrite, and then spend the next sprint arguing about whether to migrate or jump ship entirely. Here's what you actually need to know.
The Headline Breaking Changes
Airflow 3 is architecturally different from 2.x in ways that matter. Not "we renamed some parameters" different — "your tasks can no longer talk to the database" different. Three changes cause 90% of the migration pain:
Direct metadata database access is gone
Scheduling semantics flipped
Standard operators moved to a separate package
Everything else — the new React UI, DAG versioning, the FastAPI backend — is gravy. The three items above are what will break your DAGs at import time.
Direct Database Access Is Dead
This is the big one. In Airflow 2, it was common (if not exactly encouraged) for tasks to reach directly into the metadata database via SQLAlchemy. Custom operators that checked DAG state, tasks that queried run history, anything using @provide_session or from airflow.settings import Session — all of it stops working.
Airflow 3 routes everything through an API server. The metadata DB is no longer accessible from worker nodes. Try the old pattern and you get:
# This worked in Airflow 2. In 3, it throws RuntimeError.
@task
def check_previous_runs():
from airflow.settings import Session
with Session() as session:
runs = session.execute(
text("SELECT * FROM dag_run WHERE dag_id = 'my_dag'")
).fetchall()
return len(runs)
The fix is to use the Airflow Python client or hit the REST API v2 directly. Not hard per task, but if you've got hundreds of DAGs with this pattern scattered around, you're looking at a systematic refactor. One team I followed reported 252 files changed in their first pass — and needed three additional passes to catch the stragglers.
Grep your codebase for these strings before you start: @provide_session, create_session, from airflow.settings import Session, from airflow.models import. If you get zero hits, congratulations — you're in the minority.
Scheduling Logic Got Quietly Inverted
This one bites people who don't read changelogs carefully. Two defaults changed:
| Setting | Airflow 2 | Airflow 3 |
|---|---|---|
Default schedule |
timedelta(days=1) |
None |
Default catchup |
True |
False |
That first one means any DAG that relied on the implicit daily schedule now does nothing until you explicitly set schedule. The second means backfills won't happen automatically for missed intervals.
But the subtler change is timestamp behavior. In version 2, logical_date pointed to the start of the data interval. In 3, it represents the actual trigger time. If your DAGs do date math against logical_date — and many ETL pipelines do exactly this — your partition logic is now wrong. Silently wrong, which is the worst kind.
The escape hatch: set create_cron_data_intervals=True in your config to preserve the old behavior. But treat that as a temporary bridge, not a permanent solution.
The Operator Shuffle
BashOperator, PythonOperator, and friends moved out of the core package into apache-airflow-providers-standard. Miss this and your DAGs fail at import with a confusing ModuleNotFoundError. The fix is one line in your requirements file, but it's the kind of thing that wastes an afternoon in debugging if you don't know it's coming.
Other renames that'll get you: DummyOperator is now EmptyOperator. Database-specific operators collapsed into SQLExecuteQueryOperator. Connection property access switched from method calls (conn.get_password()) to attributes (conn.password). None of these are conceptually difficult — they're just tedious across a large codebase.
Ruff Does the Heavy Lifting
The single best investment before starting the migration: run ruff with the AIR3 rule set.
ruff check --preview --select AIR3 /path/to/dags/
AIR301/AIR302 flags mandatory changes — things that will crash. AIR311/AIR312 flags recommended updates that won't break immediately but will in a future release. Add --fix --unsafe-fixes and ruff handles the mechanical renaming automatically. One team reported this caught about 70% of their required changes in a single pass.
Wire it into CI so nothing regresses while you're mid-migration.
The "Should I Just Leave" Question
Real talk: if your team isn't Python-native, if you've been fighting Airflow's multi-tenancy limitations, if event-driven workflows are more important than scheduled batch jobs — this migration deadline is a natural off-ramp. Dagster's asset-centric model, Prefect's dynamic workflows, Kestra's language-agnostic YAML approach — they're all viable alternatives and the switching cost might not be much higher than a full Airflow 3 migration for a large codebase.
But if you're invested in the ecosystem, running a managed service like MWAA or Astronomer, and your pain is mostly "we need to update some imports" — just do the migration. The recommended path is Airflow 2.11 → 3.0.x → 3.1.x, with Python bumped to 3.12 somewhere in between. Budget two weeks for dev validation and a week for staging. The metadata schema migration alone can take 30 minutes to 2+ hours depending on your database size, so schedule the cutover during a maintenance window.
April 22 isn't moving. Your DAGs shouldn't be standing still either.