The first real task in an Airflow 2 to Airflow 3 migration is boring: run the static checks, read the output, and sort it. On a 2.x estate of a few hundred DAGs the first run usually produces thousands of lines. Almost all of it collapses into ten findings repeated across files.
The tooling is two things. ruff with the Airflow rules (the AIR30 family for removals, AIR31 for things moved or renamed) catches import and API changes in your DAG code, and many of them have autofixes. The airflow config lint command catches configuration options that were removed or renamed. Neither one runs your DAGs, so neither will tell you whether the output is still correct — that is what a dual run is for. These checks only tell you whether the code will import and the deployment will start.
Here is what comes back, in roughly the order of frequency we see it.
1. Imports that moved out of core into provider packages
The single largest block of findings. Operators, hooks and sensors that used to live under airflow.operators.*, airflow.hooks.* and airflow.sensors.* now live in provider packages, and the Airflow 2 compatibility shims are gone.
Fix: let ruff autofix the mechanical rewrites, then pin the provider versions in your image build. The second half matters more than the first: if your requirements file says apache-airflow-providers-amazon with no constraint, your next image rebuild changes operator behaviour without a code change. Pin, and upgrade providers deliberately.
2. schedule_interval and timetable arguments
schedule_interval is gone; so is passing a timetable through a separate argument. Everything goes through schedule.
Fix: mechanical. schedule_interval="0 3 * * *" becomes schedule="0 3 * * *"; a timetable object is passed to schedule directly. Watch for DAG factories that build the keyword argument dynamically — string-built kwargs are invisible to the linter and will fail at import on the new version.
3. execution_date and the old date context keys
execution_date, next_ds, prev_ds, tomorrow_ds and friends are gone from the task context and from templates.
Fix: logical_date, data_interval_start and data_interval_end. This is the finding to slow down on. The replacement is not always one-for-one: code written against execution_date often meant "the day this run is for", and on a daily schedule that is data_interval_start, not logical_date as people assume at a glance. Every SQL template with {{ ds }} built on an assumption about which boundary it refers to should be checked against a run near a month end. This is the most common source of silent data differences later.
4. SubDAGs
SubDAGs are removed. Estates that are old enough to have them usually have several, often wrapping a loop someone wrote in 2019.
Fix: task groups for pure visual grouping, dynamic task mapping where the SubDAG existed to fan out over a list. Expect to rewrite rather than translate; the concurrency semantics were different and the rewrite is usually shorter than the original.
5. Direct metadata-database access in task code
Sessions opened inside tasks, callbacks querying DagRun, housekeeping DAGs issuing SQL against task_instance. Tasks in Airflow 3 run through the Task Execution API and have no database session.
Fix: covered at length in Removing Metadata-Database Access from Airflow Task Code. The checks will not find all of it, because the access is usually in a shared internal package the linter never scans. Point the linter at that package too.
6. Removed and renamed configuration options
airflow config lint reports options that no longer exist, moved section, or changed default. The usual ones concern the DAG processor, which is now a separate component with its own section, and scheduler settings that were folded into it.
Fix: apply the renames, then re-read the defaults you were relying on implicitly. Also check where the config actually comes from. On MWAA and Cloud Composer, many of these are environment-level settings rather than lines in airflow.cfg, and the lint output will not see them at all.
7. PythonOperator arguments that no longer exist
provide_context has been unnecessary since Airflow 2 and is now rejected. Code that accepted **kwargs purely to receive the old context keys needs its signature revisited.
Fix: remove provide_context; take the context values your function actually uses as named parameters. Functions that take **kwargs and reach into it for removed keys fail at run time rather than import, so grep for the key names, not just the argument.
8. Deprecated BaseOperator arguments in custom operators
In-house operators written against older base-class signatures, and custom operators that override execute with the wrong signature or call removed helpers.
Fix: these are the slowest items on the list because each is bespoke. Budget real time for them. Before you port one, check how many DAGs still use it — in most estates at least one custom operator has no live callers and should be deleted rather than migrated.
9. Plugins that register the wrong things
The plugin interface no longer accepts operators, hooks or sensors registered as plugins, and the Flask-based UI extension points have changed with the new API server.
Fix: move operators and hooks into a normal Python package that your DAGs import. For UI plugins, expect a rewrite and ask first whether the plugin is still used. Custom views written for the old UI are frequently abandoned.
10. default_args that carry removed keys
A shared default_args dict at the top of every DAG file, copied around for years, carrying keys that no longer apply. Because the dict is often imported from a helper module, one fix repairs hundreds of findings.
Fix: fix the shared module first, then re-run the checks. Doing this before you triage anything else will often cut the finding count by half and make the real list readable.
What not to fix
Two categories.
Findings in DAGs that should not come with you. Every estate has DAGs that have not produced consumed data in a year. Checking who reads the output is faster than porting the DAG, and a migration is the one moment when switching something off is politically easy.
Findings that are stylistic rather than blocking. The rules include suggestions that are reasonable practice but do not affect whether the DAG runs on 3.x. Record them, do not let them into the cutover scope, and keep them for the cleanup pass after you are stable.
How to run it so the output stays useful
Run the checks in CI from the first day of the project, with the finding count published per team or per DAG folder. A one-time run produces a spreadsheet that goes stale in a fortnight; a CI job produces a number that goes down. Fail the build on new findings before you try to fix the existing ones, otherwise the backlog refills behind you.
The real-estate caveat
A clean check run means your DAGs import. It does not mean they are correct, it does not mean the scheduler will keep up, and it does not mean the deployment will come up on the new version. The date-semantics findings in particular pass the linter and fail the data. Treat the check output as the inventory for the work, not the definition of done, and plan a dual run before cutover on anything whose output someone bills, reports or trains a model on.