In Airflow 3 one component owns your DAG files: the dag processor. It walks the dags folder, imports each .py file as a Python module, collects every DAG object that import produced, and writes a serialized copy of each DAG into the metadata database. The scheduler and the web UI read that serialized copy and never open the file. When the processor reaches the end of the folder it returns to the start and imports everything again, and it keeps doing that for as long as it runs. Everything in this lesson follows from that loop: what it costs, why a file can fall out of it, and why a test on your laptop can tell you what the processor will do.
The scheduler reads the serialized DAGs the processor writes, and so does the UI. A worker imports the file again when one of its tasks starts.
How often the loop comes back
The [dag_processor] section of the config has 3 settings that set the pace, and their defaults matter because most deployments keep them. A file is re-imported at most every 30 seconds, 2 processes import files side by side, and a single import that runs past 50 seconds is killed. On a small folder those numbers mean the top of your file runs around 2,880 times a day, and each of those runs has to finish inside 50 seconds or the DAG never reaches the database.
| Setting | Default | What it governs |
|---|---|---|
| `min_file_process_interval` | 30 seconds | Shortest gap between 2 imports of the same file |
| `parsing_processes` | 2 | How many files import at the same time |
| `dag_file_processor_timeout` | 50 seconds | How long one import may run before it is killed |
What top-level code costs
Every statement at module level runs on every import. The body of a @task function stays unexecuted, because constructing the task objects already gives the processor what it needs: which operator each task id uses, and what it depends on. So a file that opens a warehouse connection at module level, intending the task to reuse it, pays that handshake once per import, and the task never sees the connection anyway, because the task runs in a different process on a different machine.
- ▸The dag processor, on every sweep, on the processor's host
- ▸Each worker, once per task start, on that worker's host
- ▸Your laptop, once per
pythonrun or test command - ▸The scheduler and the UI read the database and import nothing
airflow dags report on the deployment. It prints every file with its parse duration, so the file eating your sweep is the top line, and the fix is usually a module-level statement that belongs inside a task.- Keep module level to imports from airflow.sdk plus DAG and task construction
- Open connections and clients inside the task function that uses them
- Import heavy libraries inside the task body
- Measure parse time with airflow dags report after each large change
- Call any network service at module level
- Read large files or walk directories while the module imports
- Treat a 2-second import as harmless when the folder holds 100 files
- Raise the 50-second timeout before removing the work that hits it
Cause 1: the import raised
An exception at module level stops the import, and the processor records it against the file path in the import error table. The UI shows an import error notice on the home page, and airflow dags list-import-errors prints the file path with the full traceback. Missing modules and mistyped names land here, as does a KeyError on an environment variable the processor's host lacks. A file that errors produces no DAG, so a new DAG from a broken file is absent from the list rather than shown in red.
Cause 2: the file was never imported
The processor can skip a file without ever opening it, and it records no error when it does. Before importing, it scans the file's text for the strings airflow and dag, case-insensitive, and skips any file lacking either one. A file that gets its DAG by calling a factory function from another module can fail this test and sit there in silence. A .airflowignore file in the folder or any parent folder also holds glob patterns, and a file matching one is skipped, along with everything under a matching directory. And the folder the processor scans is whatever airflow config get-value core dags_folder prints, so a file 1 directory outside it does not exist to Airflow.
Cause 3: the DAG object never reached the module
The processor collects DAG objects from the module's global namespace after the import. A @dag decorated function that is defined and never called creates no DAG, because the decorator turns the function into a factory, and the DAG exists only when the factory runs. A with DAG(...) block inside a function the module never calls has the same effect. In both cases the import succeeds, there is no error to list, and the file parses in milliseconds, so this cause hides behind a clean report.
Cause 4: another file owns the id
A dag_id is unique across the whole database. If 2 files define the same id, both import cleanly. The DAG then shows under whichever file the processor parsed last, with that file's tasks. The other file's tasks never appear, and the DAG's recorded file path can change from one sweep to the next. Copying a DAG file to start a new one and forgetting to change the id produces this exactly, and airflow dags list shows the id once, with a file path you did not expect.
Cause 5: the import ran out of time
An import that passes 50 seconds is killed and the DAG is never written. The dag processor's log records it with a line like Processor for dags/bucket_inventory.py with PID 4182 started 51 ago killing it., which names the file path and its age in seconds, past the 50-second limit. Causes 2 and 3 leave no line in that log at all, which is itself a clue. The usual source of a timeout is the module-level work from the previous section: a slow query, a listing of a large bucket, an import chain that pulls in a numerical library. The fix is the same as before, moving the work into a task, and the check is airflow dags report, which prints the file's parse duration next to its name.
| What you see | Run this | What a hit looks like |
|---|---|---|
| No DAG, red import notice | `airflow dags list-import-errors` | Your file path with a traceback |
| No DAG, no notice | `grep -il airflow <file>` and `grep -il dag <file>` | One of the 2 words is missing |
| No DAG, no notice | `cat .airflowignore` in the folder and its parents | A pattern matches the path |
| No DAG, no notice | Read the file for a `@dag` function with no call below it | Import succeeds, no DAG is built |
| DAG exists, wrong tasks | `airflow dags list` | Your id under another file's path |
| No DAG, timeout in the processor log | `airflow dags report` | Duration near 50 seconds or above |
- ▸1.
airflow dags list-import-errors: a traceback ends the search. - ▸2.
airflow dags list: your id under another path means a duplicate. - ▸3.
grepthe file forairflowanddag: a missing word means it was skipped. - ▸4. Check
.airflowignoreup the tree, thenairflow config get-value core dags_folder. - ▸5. Read the file for a
@dagfactory that is never called. - ▸6.
airflow dags report: a duration near 50 seconds means the timeout killed it.
A DAG that appears with its toggle off has arrived and is paused. New DAGs start paused by default, which is a different situation from a DAG that never arrived.
After fixing the file, you can wait for the next sweep or force one. airflow dags reserialize reparses every file in the folder and rewrites the serialized DAGs, which is the quickest way to confirm a fix on a deployment where sweeps run slow. If the DAG still fails to appear after a forced reserialize, the cause is one of the 5 above, and the order of checks will find it.
The apache-airflow package installs the scheduler and the dag processor, plus the API server behind the UI and the command line. It also installs the Task SDK: you import DAG from airflow.sdk, and dag, task and TaskGroup come from the same place. Operators live elsewhere, in provider packages named apache-airflow-providers-<name>, each released on its own schedule and installed with pip. Even BashOperator and PythonOperator live in a provider, apache-airflow-providers-standard. Core depends on that one, so it is always present, and its import paths show the pattern every other provider follows.
Each path's second segment names the package, so standard comes from apache-airflow-providers-standard. The same reading gives apache-airflow-providers-http for http and apache-airflow-providers-common-sql for common.sql. To reach a system the folder cannot reach today, you install its provider, and airflow providers list prints every installed provider with its version. The provider for a particular warehouse or object store carries the operators for that system plus the plumbing they use to connect, and most SQL providers build on common-sql, so installing one pulls the other in.
| Task you need | Provider package | Operator |
|---|---|---|
| Run a shell command | standard | `BashOperator` |
| Run a Python callable | standard, or none | `PythonOperator`, or `@task` from `airflow.sdk` |
| Call an HTTP endpoint | http | `HttpOperator` |
| Run SQL on a warehouse | common-sql plus the warehouse's own provider | `SQLExecuteQueryOperator` |
The extras on the core package are a shortcut for the same install: pip install "apache-airflow[http]" brings in core and the http provider together, and the extra's name matches the provider's. Either route ends with the same package in the environment, so pick one route and use it everywhere.
What installing changes
- The import raises on the processor
- The file lands in list-import-errors
- The DAG never appears
- Looks like a broken file
- The DAG appears and its graph looks right
- The task fails at start with ModuleNotFoundError
- Every attempt fails the same way
- Looks like a bug in the task
When to use a provider operator
A @task function runs any Python you write with the Task SDK alone, and for a transformation that is your own code it is the shortest route. A provider operator is the better choice when the work is a conversation with an outside system: it wraps that system's client with authentication and error handling already written, and it writes a task log that reads well, so a 20-line @task that hand-rolls an HTTP call usually loses to a 4-argument HttpOperator. Reading an unfamiliar operator starts with its constructor arguments, which are the knobs you set, and its execute method, which is what the worker runs. For BashOperator, execute runs the command and raises if the exit code is non-zero, so the task fails when the script fails.
airflow providers list on a worker and on the processor and compare the 2 lists line by line. Any provider that appears on one and not the other is a failure waiting for the next deploy.Depth 1: import the file
python dags/orders_health.py imports the module the way the processor does. Zero output and exit code 0 mean every module-level statement ran without raising. That alone does not prove a DAG exists: an uncalled @dag function imports cleanly and builds nothing, as cause 3 showed. airflow dags list naming orders_health, or the depth 3 run below, confirms the DAG was built. A traceback is the same one the processor would record, shown now rather than after the next sweep. Task bodies stay unexecuted during an import, so a passing import says nothing about whether check_orders works, and a slow import on your laptop predicts a slow parse on the deployment.
Depth 2: run one task
airflow tasks test orders_health check_orders 2026-11-03 runs that single task in your terminal. It ignores the task's dependencies, so upstream tasks do not run first. It prints the task log to the screen. And it records no state to the metadata database, so nothing appears in any grid afterwards. That makes it the command to iterate on one function: edit, run, read the log, repeat, on a 5-second loop. Its blind spot follows from the first point: anything the task expects to receive from an upstream task is absent, because upstream never ran.
Depth 3: run the whole DAG
airflow dags test orders_health 2026-11-03 builds the DAG, creates 1 run stamped with that date, and executes every task in dependency order inside the one process. No scheduler or worker takes part. Task logs stream to the terminal. This command does write to the metadata database: the run and its task states are saved, and the grid shows the run afterwards. A task that fails stops its downstream tasks from running, and the command exits non-zero, so a shell script or a CI job can act on the exit code.
The date 2026-11-03 is the date the run is stamped with. What that date means for the data a run reads belongs to the scheduling lessons. When testing, reuse one date so that 2 runs can be compared.
--mark-success-pattern takes a regular expression, and any task whose id matches is marked success without running, so airflow dags test orders_health 2026-11-03 --mark-success-pattern "load_.*" skips every load task while you check what comes after them. A 20-minute wait becomes 20 seconds, and the tasks you care about still run in their real order.
The processor imports the file as a module, so the __main__ block never runs there and costs nothing. Once the file carries that block, running it directly becomes depth 3 with your debugger attached, and python -c "import orders_health" from inside the dags folder gives you depth 1 again.
| Question | Command | Dependencies | State written |
|---|---|---|---|
| Does the file import? | `python dags/orders_health.py` | No tasks run | Nothing |
| Does one task work? | `airflow tasks test orders_health check_orders 2026-11-03` | Ignored | Nothing |
| Do the tasks work together? | `airflow dags test orders_health 2026-11-03` | Honored, one task at a time | The run and its task states |
What a green run proves
- The DAG builds with this machine's packages
- Each task completes against systems reachable from here
- The dependency order works end to end
- The task logs say what you meant them to
- What the deployment's processor and workers have installed
- How the tasks behave when several run at the same time
- Anything about when a scheduler would start a run
- Whether a worker can reach the same systems
AIRFLOW_HOME at a scratch directory and run airflow db migrate once before testing. Every dags test run lands in that local SQLite database, so the shared grid stays clean and you can delete the directory whenever you like.- Start with the import, then one task, then the whole DAG
- Use --mark-success-pattern to skip the slow tasks you are not debugging
- Give dags test an explicit date so 2 runs are comparable
- Keep the __main__ block, since the processor never runs it
- Treat tasks test output as a sign the DAG wires correctly
- Run dags test against the shared metadata database
- Read a green local run as proof the workers have the same packages
- Debug a parse error with a full run when the import shows it in a second
A DAG with 30 tasks draws a graph nobody reads. If 12 of those tasks pull from source systems, 12 clean what arrived and 6 publish, the graph has 3 shapes in it, and a TaskGroup lets you draw those 3 shapes as 3 nodes. The group is a naming and drawing device: it changes task ids and how the UI folds the graph, and leaves what each task runs untouched.
Each task inside a group got the group's name prefixed to its id, so the task you wrote as pull_orders is stored and shown as extract.pull_orders, and that full id is what every command takes, so testing it is airflow tasks test orders_daily extract.pull_orders 2026-11-03. extract >> clean also wired the groups: every task in extract with nothing downstream inside its group now points at every task in clean with nothing upstream inside its group. Here that is 3 tasks pointing at 1, since cast_types already has dedupe upstream, so the single arrow created 3 edges.
| Written as | Stored task id | Why |
|---|---|---|
| `pull_orders` inside `extract` | `extract.pull_orders` | Group id prefixed with a dot |
| `dedupe` inside `clean` | `clean.dedupe` | Same rule |
| `pull_orders` with `prefix_group_id=False` | `pull_orders` | Prefix switched off on the group |
| `pull` inside `region` inside `extract` | `extract.region.pull` | Nested groups prefix in order |
The decorator form
@task_group wraps a function the way @dag wraps a DAG: the group exists when the function is called, and the call returns an object you can wire with >>. The decorator takes the same arguments as TaskGroup. tooltip shows when the pointer rests on the collapsed node. prefix_group_id=False keeps the inner ids bare, at the cost of making you keep them unique across the whole DAG yourself.
Arrows need not stop at group borders. clean >> notify, with notify a plain task, hangs that task off every leaf of the group, and an inner task can be wired on its own: keep the return value of pull_orders() in a variable and write orders >> some_task to join 2 tasks in different groups without touching the rest of either group.
What the UI does with a group
The rename nobody expects
Putting existing tasks into a group renames them. pull_orders becomes extract.pull_orders, and the metadata database has task history under pull_orders and nothing under the new id. The UI shows the old id as a removed task and the new one with an empty history, and any script that calls airflow tasks test with the old id now fails to find it. For a DAG with a long history worth keeping, prefix_group_id=False on the group gives you the folded graph with the ids unchanged. For a new DAG, let the prefix happen and keep group names short, since every task id carries them from then on.
airflow tasks list orders_daily. Every command wants the stored id with its group prefixes.- Group by purpose: extract, clean, publish, each with a one-word id
- Wire groups with one >> and let Airflow fan the edges out
- Use the full dotted id in every command and search
- Nest when a group passes 10 tasks of its own
- Group tasks that share nothing except position in the file
- Switch off the prefix on a new DAG to save typing
- Rename live tasks by grouping them without telling whoever reads their history
- Expect a group to change how or when its tasks run
> Lyft has written publicly about running Airflow for its data platform. Picture a team there the week it takes on 60 DAG files from another group: the home page lags 5 minutes behind edits, 3 of the new DAGs never appear, 1 fails every task at start, and the largest DAG draws 70 tasks in a graph nobody can read.
airflow dags report names the files with module-level work, and moving that work into tasks brings a sweep back under 30 seconds.list-import-errors, 1 lacks the word airflow anywhere in its text, and 1 defines a @dag factory that nothing calls.airflow dags test against a scratch database shows the run green on a laptop, with the slow load group marked success to keep the loop short.airflow dags list-import-errors is always the first check.apache-airflow-providers-* packages that you import as airflow.providers.<name>. A provider has to be installed on the processor and on every worker.airflow tasks test one task, then airflow dags test the DAG: each depth answers one question, and only the last one writes a run to the database.TaskGroup folds tasks into one node, prefixes their ids with its name, and fans one >> into edges from every leaf of one group to every root of the next.Platform created by the community to programmatically author, schedule and monitor workflows.
airflow.apache.org
Apache Airflow, Apache, Airflow, the Airflow logo, and the Apache logo are either registered trademarks or trademarks of The Apache Software Foundation.
What the dag processor does with your file every 30 seconds, and why that decides whether your DAG shows up at all.
- Category
- Airflow
- Difficulty
- intermediate
- Duration
- 20 minutes
- Challenges
- 6 hands-on challenges
Topics covered: The Parse Loop, Why a DAG Goes Missing, Operators from Providers, Testing a DAG Locally, Grouping Tasks
Lesson Sections
- The Parse Loop (concepts: DAG parsing)
How often the loop comes back After an import finishes, the processor looks through the module for DAG objects, serializes each one to JSON and writes the row. The scheduler picks up the new row within seconds. So the clock between saving a file and seeing the change in the UI runs like this: wait for the sweep to reach the file, pay the import, then a few seconds more. On a quiet folder that is under a minute. On a slow folder it is however long a sweep takes, which is the number the rest of th
- Why a DAG Goes Missing (concepts: DAG parsing)
The file is in the folder, the processor has swept it a dozen times, and the home page shows no DAG with its id. Every cause leaves a different fingerprint, and a short, fixed order of checks finds it in a few minutes. The order matters because the first checks are cheap and rule out the common causes, and the later checks only make sense once the earlier ones have come back clean. Cause 1: the import raised Cause 2: the file was never imported Cause 3: the DAG object never reached the module Ca
- Operators from Providers (concepts: DAG definition)
What installing changes Installing a provider adds its import paths to one Python environment, but Airflow runs as several processes, and 2 of them import the DAG file. The dag processor imports it to build the DAG, and each worker imports it when a task starts. A provider installed in one environment and absent from the other produces 2 different failures, and both look like something else until you know what each process imports. The cure is one image or one requirements file for every Airflow
- Testing a DAG Locally (concepts: DAG parsing)
A DAG can be tried at 3 depths on a laptop, and each depth answers one question. Importing the file asks whether the DAG builds. Running one task asks whether that task's code works. Running the whole DAG asks whether the tasks work together in order. Each costs more than the one before, and knowing which question you are asking stops you paying for a 40-minute full run to find a typo that the import would have caught in a second. Depth 1: import the file Depth 2: run one task Depth 3: run the w
- Grouping Tasks (concepts: Task dependencies)
The decorator form What the UI does with a group In the graph, a group draws as one node that opens on a click to show the tasks inside, and closes again. In the grid, a group is one row with a chevron, and the row's color summarizes its tasks: the row goes red if any task inside failed, and expanding it shows which one. Nesting works the same way at each level, so a DAG of 60 tasks can open as 4 nodes, each opening into 3, each opening into 5. The rename nobody expects