Skip to content
Published on

After Airflow 2 EOL — The Real Work List for Going 2 to 3, and Where 3.x Stands at 3.3

Share
Authors

Introduction — The EOL That Passed Quietly

On April 22, 2026, Apache Airflow 2 passed EOL (end of life). The official fact, as stated in the project README's version life-cycle table, is this: Airflow 2 entered limited maintenance (security and critical bug fixes only) on October 22, 2025, and its status became EOL as of April 22, 2026. In the README's own words, an EOL version receives no fixes and no support of any kind. Data from endoflife.date shows the same date.

As of today, when this post is written (2026-07-17), three months have passed. Laid out on a single timeline (all dates verified against GitHub Releases and the PyPI release history):

2020-12-17  Airflow 2.0.0 first release
2025-04-22  Airflow 3.0.0            (SLA, SubDAG, pickling, REST v1 removed)
2025-05-20  Airflow 2.11.0           (bridge release — transition tooling backported)
2025-09-25  Airflow 3.1.0            (Deadline Alerts arrives as experimental)
2025-10-22  Airflow 2.x enters limited maintenance (security, critical fixes only)
2026-03-14  Airflow 2.11.2           (last 2.x patch to date)
2026-04-07  Airflow 3.2.0            (asset partitioning, multi-team)
2026-04-22  Airflow 2.x EOL          (no further fixes after this point)
2026-07-06  Airflow 3.3.0            (state store, pluggable retries, Java/Go SDK)

For a major version that lived 5 years and 4 months, it was a quiet exit. And for teams still on 2.x right now, what these dates mean is simple: when the next CVE lands, there will, in principle, be no patch for 2.x. The problem is that the road from 2 to 3 is not short. This post lays out the work that actually takes hands-on time on that road, without exaggeration, based on the original text of the official upgrade guide and the release notes.

This Is Not an Upgrade — It's an Architecture Change

The difference between 2 and 3 boils down to one sentence: in 2, every component attached directly to the metadata DB; in 3, the API server is the sole point of contact for tasks and workers.

The problems the upgrade guide itself lists for 2.x are candid. Task code and task-execution code ran in the same process, workers attached directly to the DB to execute all user code, user code could import a DB session and make malicious modifications to the metadata DB, and an excessive number of DB connections made scaling hard. Airflow 3 cuts this off with the Task Execution API — state transitions, heartbeats, XCom, and resource lookups all now go through a dedicated API, and task code can no longer touch a DB session.

This shift is the root of the migration effort. Tooling handles about half of the import-path fixes in DAG files automatically, but "code in a worker that used to read the metadata DB directly" requires a design change. We cover this separately below.

One nuance carried straight from the docs: DAG-author code can still run with direct DB access inside the Dag file processor and the triggerer. The isolation applies to the task-execution path, not yet to every path.

DAG Code — Ruff Handles Half, Semantic Changes the Other Half

What the machine catches

The first tool the official guide points to is Ruff's AIR rules. AIR301 and AIR302 flag code that breaks on Airflow 3; AIR311 and AIR312 flag code that still runs for now but should be migrated. The docs require ruff 0.13.1 or later.

ruff check dags/ --select AIR301 --show-fixes    # preview what would change and how
ruff check dags/ --select AIR301 --fix           # safe auto-fix
ruff check dags/ --select AIR301 --fix --unsafe-fixes  # including import-path replacement

No need to be scared off by the word "unsafe" here — the docs explain that in the AIR rules, an unsafe fix usually just means "keep the imported member name as-is and only change the path."

The import moves all point in one direction. Everything used to author a DAG moves into the airflow.sdk namespace.

airflow.models.dag.DAG                    ->  airflow.sdk.DAG
airflow.models.baseoperator.BaseOperator  ->  airflow.sdk.BaseOperator
airflow.decorators.task                   ->  airflow.sdk.task
airflow.datasets.Dataset                  ->  airflow.sdk.Asset      (even the name changes)
airflow.models.variable.Variable          ->  airflow.sdk.Variable
airflow.models.connection.Connection      ->  airflow.sdk.Connection
airflow.hooks.base.BaseHook               ->  airflow.sdk.BaseHook

The official timeline is explicit: in 3.1, legacy imports still work but emit a warning, and they will be removed in a future version. Fixing this now is the right call.

One more thing — common operators that used to be bundled into core, like BashOperator, PythonOperator, ExternalTaskSensor, and FileSensor, have been split out into a separate package called apache-airflow-providers-standard. This package can also be installed on 2.x, so you can do the import switch ahead of time, before the upgrade itself.

What the machine can't catch — semantics change

More dangerous than the imports are the changes where code still runs, just differently. Pulling out only what actually bites in practice from the upgrade guide's Breaking Changes section:

execution_date and its derived keys have vanished from the context. execution_date, prev_execution_date, next_execution_date, prev_ds, next_ds, tomorrow_ds, yesterday_ds — the whole family is on the removal list. Referencing these keys in templates or task code causes a DAG error. The replacements are logical_date and the data-interval family.

The default semantics of cron strings have changed. With the create_cron_data_intervals setting now defaulting to False, a DAG that passes a cron string as-is, like schedule="0 0 * * *", is now interpreted by CronTriggerTimetable instead of CronDataIntervalTimetable. DAGs that explicitly pass a timetable instance are unaffected. The problem is that template values like data_interval_start, ds, and ts are derived from logical_date, and these values shift between the two timetables. If you depend on these values, the docs instruct you to explicitly set create_cron_data_intervals=True before upgrading; if you flip the flag back after 3.x runs have already been created, it will skip one scheduled run to avoid conflicting with the previous run's logical_date.

Do not assume the data interval of a manually triggered run. The docs explicitly state that in 3, you should not assume a manually triggered run's data_interval is derived from the logical_date the user passed in. If you need the date the user specified, you must read logical_date directly — this bites workflows that trigger sub-DAGs with TriggerDagRunOperator in particular.

The default behavior of xcom_pull() has changed. In 2, calling it without task_ids found the most recent value for that key across the whole DAG run; in 3, it only searches within the current task. If you need another task's XCom, you must now pass task_ids. Code like this starts silently receiving None, so it's safer to grep for it ahead of time.

catchup_by_default is now False. If you relied on catchup to backfill schedule gaps, you now need to set it explicitly on the DAG.

And the removal list — SubDAG (replaced by TaskGroup and asset scheduling), SequentialExecutor, CeleryKubernetesExecutor and LocalKubernetesExecutor (replaced by multi-executor configuration), DAG/XCom pickling, the CLI's --subdir argument (replaced by Dag bundles), and REST API v1 — all of these are gone. API clients need to move to the FastAPI-based v2.

The Most Painful Part — Custom Code That Read the Metadata DB Directly

If your team had custom operators or task code that read and wrote metadata through an Airflow DB session, this is the single biggest line item in your migration effort. This code does not work in 3, and the official guide offers two paths.

The recommended path is to use the REST API via the Airflow Python Client. Most use cases — DagRun, TaskInstance, Variable, Connection, XCom, and more — are covered by the API. That said, the docs are candid about the downsides too: you have to obtain a token via a call to /auth/token and rotate it, you become dependent on API server availability and network paths, and not every DB operation is exposed as an API endpoint. The community's stance is that if a feature is missing, you should request that the API add it rather than fall back to the DB directly.

The workaround is to open a plain DB connection to the metadata DB with a DbApiHook like PostgresHook, but the docs themselves flatly say this is not recommended. The reasons are spelled out too — this approach will break on versions after Airflow 3.2, the metadata DB schema is not a public API and can change without notice, it conflicts with task isolation, which is 3's core design, and it reverts to the 2-era performance characteristics of opening a DB connection per task. Even if used temporarily, it should be with a read-only account and paired with a migration plan. Real-world conversion examples are collected in issue #49187.

From an effort-estimation standpoint, it's worth counting two numbers before you scope the migration: the number of ruff AIR301 violations, and the number of custom operators that touch a DB session directly. The former is mostly solved mechanically; the latter is a design change, one at a time.

The Deployer Checklist — This Work Has an Order

The operations side of the work is mostly a matter of following the guide's steps, but a few things are worth knowing ahead of time so the schedule doesn't slip.

airflow config update           # check what needs to change (also backported to 2.11)
airflow config update --fix     # auto-fix
airflow db migrate              # schema migration — the longest-running step
airflow api-server              # there is no webserver command anymore
airflow dag-processor           # now must be started separately (local dev too)
  • Prerequisites. The minimum is Airflow 2.7, and the recommended path is through the latest 2.x on the way to 3. airflow dags reserialize must run without errors, and DAG-processing errors need to be resolved on the old version before you upgrade.
  • DB cleanup and backup. Schema-change time scales with DB size. The docs recommend starting by clearing out old XComs and the like with airflow db clean, and warn that if you proceed without a backup and the migration is interrupted midway, you can end up in a half-migrated state.
  • Component configuration changes. The webserver has become a general-purpose API server, and the Dag processor must always be launched as an independent process. If you use the Helm chart, all values under webserver need to move to apiServer, and quite a few key names changed between chart 1.16.0 and 1.18.0.
  • Authentication changes. The default auth manager has changed to Simple Auth; to keep FAB-based auth, you need to install the FAB provider and explicitly set auth_manager. OAuth redirect URLs now get an /auth prefix — meaning you also need to update the redirect URL registered on the IdP side.
  • Plugins. Plugins that depended on Flask-AppBuilder views and blueprints need to move to a FastAPI app or sit on the FAB provider's compatibility layer.

SLA's Five-Month Gap — Removal Arrived Before the Replacement

The single most instructive event in this major transition is SLA. 3.0.0 (2025-04-22) removed SLA callbacks and metrics, and its release notes stated that "a more flexible replacement mechanism, DeadlineAlerts, is planned for a future release." That Deadline Alerts feature (AIP-86) didn't arrive until 3.1.0 (2025-09-25), and even then it was marked experimental and supported only async callbacks. Synchronous callbacks (SyncCallback) were added in 3.2.0 (2026-04-07), and as of the 3.2.0 release notes it is still marked experimental. 3.3.0 added a Deadlines page to the Browse menu.

To sum up — users who moved to 3.0 early faced a five-month gap with no first-party feature, and the replacement, ten months after it arrived, is still maturing under an experimental label. The stopgap 3.0's release notes suggested was task-level success/failure hooks or external monitoring. The lesson for teams that lean heavily on a specific feature: build the habit of checking the release notes yourself for exactly what version, and what state, a given feature's replacement is in before you plan a major upgrade.

2.11 Was Designed As a Bridge — Start Here If You're Still on 2.x

2.11.0 (2025-05-20) is less a new-feature release than a transition device. What the release notes' "Ease migration to Airflow 3" section lays out:

  • airflow config lint and airflow config update were backported to 2.11, so you can check and fix configuration ahead of moving to 3.
  • A logical_date field was added alongside every model that used to have execution_date. 3 drops execution_date entirely.
  • Flipping the create_delta_data_intervals flag ahead of time (default True in 2.11, default False in 3.0) lets you rehearse the new interpretation of timedelta schedules (DeltaTriggerTimetable) while still on 2.x. It's the same kind of rehearsal device as create_cron_data_intervals on the cron side.
  • timer_unit_consistency, which unifies timing-metric units, follows the same pattern — try it on in 2.11, and it's always on in 3.0.

In other words, the official path is clear: move up to the latest 2.11.x, flip the flags ahead of time toward 3.0's defaults, work through the violations with ruff and config lint, and then move to 3. Since 2.11 supports Python 3.9 through 3.12, you can also settle your Python version alignment at this stage.

Where 3.x Stands Now — July 2026, at 3.3.0

The world on the other side deserves an honest look too. Two things matter.

First, tracking the latest minor within 3.x is effectively required. Looking at the release history: 3.0.x's last patch was 3.0.6 (2025-08-29), right before 3.1.0; 3.1.x's was 3.1.8 (2026-03-11), right before 3.2.0; and 3.2.x's was 3.2.2 (2026-05-29), right before 3.3.0. Patches for the previous minor effectively stop once the next minor ships, and the README also recommends running the latest minor of whichever major you're on. There is no LTS. That means a roughly half-year cadence of minor upgrades needs to be on your operating calendar.

Second, code is still on the move even within 3.x. 3.2.0 moved the exceptions used by tasks into airflow.sdk.exceptions (the old airflow.exceptions import stays as a proxy that emits a warning), and also moved serialization (serde) logic to the Task SDK — the docs state flatly that both compatibility layers are slated for removal in Airflow 4. Getting from 2 to 3 doesn't mean the move is over; the migration to airflow.sdk is still ongoing. It's realistic to budget for this follow-up work as part of the migration.

Briefly on the feature side: 3.1 (2025-09-25) brought Deadline Alerts, Human-in-the-Loop, and UI localization; 3.2's (2026-04-07) headline was asset partitioning — triggering downstream DAGs off a specific partition change rather than the whole asset, a good fit for date-partitioned data lakes — and multi-team deployment (per-team DAG/connection/pool isolation within a single Airflow instance, marked experimental). 3.3.0 (2026-07-06) extended that partitioning with mappers like RollupMapper and FanOutMapper plus a wait_policy, made retry policy pluggable (AIP-105), added a task/asset state store (AIP-103), and shipped a Coordinator layer for writing tasks in non-Python languages (AIP-108) as experimental — the Java SDK was tagged 1.0.0-beta1 on 2026-07-13. It's worth reading plainly that a good number of the new features carry an experimental label. These features are a weak reason to hurry; the only strong reason to hurry is 2.x's EOL.

So What Should You Do Right Now

Broken down by situation:

If you're still running 2.x. You are running software with no security patches under scheduler privileges. The first step isn't 3 — it's 2.11.2. From there, take three measurements first: airflow config lint, ruff AIR301/AIR302, and an inventory of code with direct DB access. Without these measurements, you can't even scope this migration. Work you can front-load on 2.x, like switching standard operators to the provider package, can happen at this same point.

If you depend heavily on SLA, SubDAG, or direct DB access. Check the state of each feature's replacement directly in the release notes, and design the transition first. SLA needs a redesign toward Deadline Alerts (including the fact that it's still marked experimental), SubDAG needs to become TaskGroup/asset scheduling, and DB access needs to be rewritten on the Python Client. Without these three, the migration is more mechanical than it sounds.

If you're already on 3.x. Build a half-year cadence calendar for tracking the latest minor, and work through the DeprecatedImportWarnings coming from airflow.exceptions and the old serde path now — these are items already announced for removal in Airflow 4.

One last layer distinction. Airflow is the control plane for batch data pipelines; durable execution of application code is a problem for a different layer — that story is covered in the Temporal Worker Versioning GA post. For recent changes on the execution-engine side that the orchestrator directs, see PySpark 4.2 defaulting to the Arrow UDF; for a comparison across the whole orchestration-tool landscape, see the Data Orchestration Guide and Workflow Engines in 2026.

Closing

To sum up: Airflow 2 ended as of April 22, 2026, and its last patch was 2.11.2 (2026-03-14). The road from 2 to 3 is not an import substitution — it's an architecture change. Removing workers' direct DB access is the core of it, and on top of that sit semantic landmines like the removal of the execution_date family, the change in cron semantics, and the change in xcom_pull behavior. The official tooling (ruff's AIR rules, config lint/update, the 2.11 bridge) cuts down the mechanical part quite a bit, but direct DB-access code and SLA dependence can only be resolved by redesigning.

And 3.x is not a destination — it's a moving train. Minors ship on a roughly half-year cadence, patches for the previous minor stop, and the migration to airflow.sdk continues alongside an announced removal in 4.0. So you need two plans: the one-time project of crossing from 2 to 3, and the ongoing operating rhythm of tracking the latest minor after you've crossed. The time you had to put off the former ended this past April.

References