Home DevOps Airflow Performance: Scheduler Stalls at 2,000 DAGs
Advanced 3 min · August 1, 2026
Airflow Performance Tuning

Airflow Performance: Scheduler Stalls at 2,000 DAGs

Airflow performance tuning for a scheduler stalled at 2,000 DAGs: parsing processes and lag monitoring.

N
Naren Founder & Principal Engineer

20+ years shipping production infrastructure and CI/CD at scale. Everything here is grounded in real deployments.

Follow
Production
production tested
August 01, 2026
last updated
1,750
articles · all by Naren
Before you start⏱ 15-20 min read
  • Airflow in production with more than 200 DAGs.
  • Access to scheduler logs and the metadata database.
  • Basic familiarity with Prometheus/Grafana or statsd.
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer
  • The scheduler runs one loop: parse DAG files, create runs, dispatch tasks — and parse time dominates everything.
  • Three knobs control parsing: parsing_processes, min_file_process_interval, and dagbag caching.
  • One file processor per 100-200 DAGs is the rule of thumb; at 2,000 DAGs you need 10 or more.
  • Top-level DB calls in DAG files add seconds to every parse — they stalled our scheduler at 2,000 DAGs.
  • Monitor scheduler lag (time since last parse) before tuning; tune until lag stays under 60 seconds.
  • If lag grows after tuning, redesign: dynamic DAGs, fewer files, or move scheduling off-platform.
✦ Definition~90s read
What is Airflow Performance Tuning?

Airflow performance tuning is the practice of keeping the scheduler's parse loop, metadata database, and executor in balance so DAG runs start on time at scale, mostly by tuning parsing processes and removing slow top-level code.

The scheduler is a chef with one stove.
Plain-English First

The scheduler is a chef with one stove. Every DAG file is a recipe, and the chef must re-read every recipe before he can cook anything. A recipe with a slow step — like calling the bank before cooking — grinds the whole kitchen to a halt. When the menu hit 2,000 recipes, the chef fell so far behind that nobody got a meal.

The scheduler does three jobs in a loop: parse DAG files, create runs for what's due, and hand tasks to the executor. For most teams, step one eats 90% of the time. That's the whole performance story.

At 2,000 DAG files our scheduler stopped scheduling. Not crashed — stopped. The UI was fine, the database was fine, but the parse loop took so long that runs never got created. Every tuning knob we'd ignored for a year was suddenly the only thing that mattered.

We'll walk the parse loop, the three knobs that control it, the database side, and how to monitor scheduler lag. It ends with the honest question: when do you stop tuning and redesign?

Parsing isn't the price of using Airflow. It's the thing you manage.

1. The Scheduler's Loop and Where Time Goes

The scheduler loops forever: pick up DAG files, parse them into a dagbag, decide which runs are due, and hand tasks to the executor. Parse is the step that scales with your codebase.

Each DAG file is imported as Python. The import runs the whole module — including anything at top level. A clean file parses in 100-300ms; a file that opens a DB connection at import time takes seconds.

That difference compounds. With 2,000 files, one bad pattern in fifty files adds minutes to every loop.

Measure the folder before you touch a single knob.

Two loop constants worth knowing: each loop creates at most max_dagruns_to_create_per_loop DagRuns (default 10), and scheduler_idle_sleep_time (default 1s) paces the loop when there's nothing to do.

profile_parse.pyPYTHON
1
2
3
4
5
6
7
8
9
10
import time
from airflow.models.dagbag import DagBag

start = time.perf_counter()
bag = DagBag(dag_folder="/opt/airflow/dags", include_examples=False)

elapsed = time.perf_counter() - start
print(f"parsed {len(bag.dags)} DAGs in {elapsed:.2f}s")
for dag_id, dag in bag.dags.items():
    print(f"{dag_id}: {len(dag.tasks)} tasks")
Output
parsed 2042 DAGs in 41.3s
orders_daily: 7 tasks
marketing_etl: 12 tasks
📊 Production Insight
Parse time is the scheduler's real workload.
One slow file pattern can stall 2,000 DAGs.
Rule: profile the folder before changing config.
🎯 Key Takeaway
The loop is parse, schedule, dispatch — parse dominates.
Top-level code is the enemy of the loop.
Rule: measure parse time first, always.
The Scheduler Loop: Where Time Goes The Scheduler Loop: Where Time Goes Parse is the step that scales with your codebase Pick up DAG files every loop Parse into dagbag imports every file Create due runs from the dagbag Dispatch to executor hands off tasks Each file imported as Python top-level DB call = seconds per file One bad pattern in 50 files minutes added to every loop Measure the folder first profile before touching a knob THECODEFORGE.IO
thecodeforge.io
Airflow Performance Tuning

2. Parsing: The Three Knobs

Knob one: parsing_processes (max_threads in older Airflow 2.x) — how many processes parse DAG files in parallel. Default is 2. At 2,000 DAGs, that's 1,000 files per process per loop.

Knob two: min_file_process_interval — the minimum time between parsing attempts of the same file. Default 30s; too low thrashes the loop, too high delays new DAGs.

Knob three: dagbag caching. Airflow 3 caches parsed DAGs across loops so unchanged files skip re-import — the single biggest win at scale.

Raise processes to match cores, keep the interval at 30-60s, and let the cache do the heavy lifting.

The real ceiling on parsing_processes is memory, not CPU: each process is a full Python interpreter, and while Airflow uses forking and copy-on-write to share it, any import after the fork breaks the sharing and memory climbs. In Airflow 3 both knobs moved to the [dag_processor] section — AIRFLOW__DAG_PROCESSOR__PARSING_PROCESSES — and min_file_process_interval still exists there, default 30s.

scheduler.envBASH
1
2
3
export AIRFLOW__SCHEDULER__PARSING_PROCESSES=10
export AIRFLOW__SCHEDULER__MIN_FILE_PROCESS_INTERVAL=60
export AIRFLOW__CORE__DAGBAG_IMPORT_TIMEOUT=60
Output
scheduler re-reads config on restart; 10 parse processes active
🔥Airflow 3 Changed the Knobs
Airflow 2.0 renamed max_threads to parsing_processes; Airflow 3 moved the knobs to the [dag_processor] section, and min_file_process_interval still exists there. The lesson survives the rename: parallelize parsing, cache the dagbag, and keep files pure.
📊 Production Insight
Two processes cannot parse 2,000 files on time.
The cache is the cheapest speedup in Airflow.
Rule: one parse process per 100-200 DAGs.
🎯 Key Takeaway
Processes, interval, cache — the three knobs.
Airflow 3 renamed half of them; the physics didn't change.
Rule: raise parsing_processes with core count, keep interval sane.
Parsing: The Three Knobs Parsing: The Three Knobs Two processes cannot parse 2,000 files on time parsing_processes parallel parse processes — default is 2 2,000 DAGs ≈ 1,000 files per process min_file_process_interval minimum re-parse gap — default 30s too low thrashes, too high delays dagbag caching (Airflow 3) unchanged files skip re-import the single biggest win at scale One process per 100-200 DAGs 2,000 DAGs → 10+ processes THECODEFORGE.IO
thecodeforge.io
Airflow Performance Tuning

3. Database Saturation and Index Health

The scheduler reads the metadata DB on every loop: which runs exist, which tasks are due, which heartbeats are fresh. When the DB slows, the scheduler slows — even with perfect parse times.

Task instance tables grow without bound unless old runs are cleaned. Bloat means dead rows, bigger scans, and index pages that miss the cache.

The fix is boring: index the hot tables, clean old runs, and watch the slowest queries.

db_health.sqlSQL
1
2
3
4
SELECT relname, n_live_tup, n_dead_tup, last_vacuum
FROM pg_stat_user_tables
WHERE relname IN ('task_instance', 'dag_run', 'xcom')
ORDER BY n_dead_tup DESC;
Output
task_instance | 12,431,002 | 3,204,119 | 2026-07-29 03:00:00
dag_run | 2,104,330 | 412,008 | 2026-07-29 03:00:00
📊 Production Insight
Dead rows are invisible until the loop slows.
Vacuum and cleanup are scheduler tuning.
Rule: prune old runs before buying bigger hardware.
🎯 Key Takeaway
The DB is on the scheduler's critical path.
Bloat in task_instance slows every loop.
Rule: monitor dead tuples; prune old runs quarterly.
The DB Sits on the Critical Path The DB Sits on the Critical Path Slow metadata reads slow every loop Scheduler reads the DB each loop runs, due tasks, fresh heartbeats Metadata DB task_instance, dag_run, xcom tables Bloat: dead rows, cold pages bigger scans on every loop DB slows → scheduler slows even with perfect parse times Index hot tables, clean old runs watch the slowest queries first THECODEFORGE.IO
thecodeforge.io
Airflow Performance Tuning

4. Monitoring Scheduler Lag

Lag is the gap between now and the last completed parse cycle. Airflow exposes it as dag_processing.last_run.seconds_ago; Prometheus makes it alertable.

Lag under 60 seconds is healthy. Above 120 seconds, runs start firing late. Above 300, you're in the stall zone — runs may never be created at all.

Every tuning change you make should be validated against this single number. Before, during, and after.

scheduler_lag.ymlYAML
1
2
3
4
5
6
7
8
9
10
11
groups:
  - name: airflow_scheduler
    rules:
      - alert: SchedulerLagHigh
        expr: max(time() - airflow_dag_processing_last_run_seconds_ago{})
          > 120
        for: 5m
        labels:
          severity: page
        annotations:
          summary: "Scheduler parse lag over 120s"
Output
alert SchedulerLagHigh fires when parse lag exceeds 120s for 5 minutes
📊 Production Insight
Lag is the only number that proves tuning works.
Dashboards without thresholds are souvenirs.
Rule: alert on lag at 120s, page at 300s.
🎯 Key Takeaway
Lag under 60s is healthy; past 300s is a stall.
Measure it before and after every change.
Rule: make lag the headline metric of the platform.

5. The 2,000-DAG Failure Story

Month one: 400 DAGs, one parse process, parse times under 200ms each. Nobody checked; nothing broke.

Month nine: 2,000 DAGs. Four legacy files still opened DB connections at import time — each added 8-12 seconds. With a single processor, one loop took 40+ minutes. Runs were due every 5 minutes.

The scheduler was drowning and the queue looked like a backlog, so the first instinct was to raise task parallelism. It changed nothing; the bottleneck was upstream of the queue.

The actual fix took an afternoon: processes to 10, the four files cleaned, caching enabled. Lag dropped from 2,400 seconds to 12.

2,400 to 12. Same hardware.

📊 Production Insight
2,400 seconds of lag hid behind a green process.
Task parallelism never helps a parse stall.
Rule: fix parse first; the queue is a symptom.
🎯 Key Takeaway
The stall grew one slow file at a time.
Cleaning four files beat adding a hundred slots.
Rule: when runs are late, suspect parse before workers.

6. When to Move Scheduling Elsewhere

Tuning has a ceiling. Beyond it, the honest answer is redesign: fewer DAG files via config-driven generation, or fewer DAGs per platform.

If your folder has 5,000 near-identical DAGs, generated-from-config DAGs replace thousands of files with dozens. If a team ships 50 small DAGs a day, a second Airflow instance per domain beats one giant shared one.

And if DAGs are many but tasks are few — hundreds of one-task DAGs — you don't have an Airflow problem. You have an architecture problem.

Know which phase you're in before you spend another hour tuning.

Two scale-out moves change the shape instead of the settings. A standalone DAG processor separates parsing from scheduling — in 2.x opt in with AIRFLOW__SCHEDULER__STANDALONE_DAG_PROCESSOR=True and run airflow dag-processor; in Airflow 3 it's a first-class split that scales independently and keeps the scheduler from executing author code. And the scheduler scales almost linearly, so a second scheduler is a real multiplier — but every additional scheduler multiplies metadata-DB connection traffic, which is why PgBouncer in front of Postgres is the standard companion.

📊 Production Insight
Tuning ends where architecture begins.
Many DAGs with few tasks is a design smell.
Rule: measure the phase before choosing the lever.
🎯 Key Takeaway
There is a ceiling to every knob.
Redesign beats heroic tuning at 5,000 DAGs.
Rule: if it stalls after tuning, change the shape, not the settings.
Tune vs Redesign: Where Do You Go From Here?
IfParse lag under 60s and DAG count under 1,000
UseTune incrementally: raise parsing_processes, keep dagbag caching on.
IfPer-file parse times above 1 second
UseFix the files first: remove top-level DB and network calls, lazy-import inside tasks.
IfOver 2,000 DAGs with tiny task counts
UseRedesign: config-driven DAG generation, fewer files, or split the platform by domain.
IfQueue depth grows while parse lag stays flat
UseTune the executor and DB side: parallelism, worker slots, index health.
IfLag persists after tuning every knob to the ceiling
UseScale out: multiple schedulers with HA settings, or a second Airflow instance.
IfScheduler memory climbs continuously
UseBound dagbag caching, shrink oversized files, and audit plugins for leaks.

7. A Tuning Checklist for Production

Tune in this order and stop when lag is under 60 seconds.

  1. Profile the folder — find files parsing over 1 second.
  2. Fix slow files — top-level DB calls are the usual culprit.
  3. Raise parsing_processes to match cores.
  4. Keep min_file_process_interval at 30-60s; rely on caching.
  5. Check DB bloat and prune old runs.
  6. Alert on lag and re-run the checklist after every deploy.

And once the folder passes ~1,000 files, set file_parsing_sort_mode to modified_time so recently changed DAGs parse before the long tail of stable ones.

Boring, measurable, reversible. That's what production tuning looks like.

📊 Production Insight
Six steps, one metric, zero guesswork.
Reversible changes keep production calm.
Rule: re-run the checklist after every deploy.
🎯 Key Takeaway
Profile, fix files, raise processes, prune DB.
Every step is validated by the lag metric.
Rule: tuning is a checklist, not an art.
● Production incidentPOST-MORTEMseverity: high

The 2,000-DAG Stall

Symptom
New DAG runs stopped appearing on time; scheduled runs fired hours late; scheduler logs showed per-file parse times climbing past 30 seconds.
Assumption
The team assumed the scheduler was keeping up because the process was alive — no lag metric existed to prove otherwise.
Root cause
Default parsing parallelism (one file processor) saturated on a large DAG folder. Top-level DB calls in several DAG files made each parse slow, the single processor fell behind, and the scheduler stopped creating runs on schedule.
Fix
Raised parsing_processes, added dagbag caching, removed top-level DB calls from DAG files, and started alerting on parse lag — scheduling returned to normal past 3,000 DAGs.
Key lesson
  • An alive scheduler process is not a scheduling scheduler.
  • Parse time is the scheduler's real workload; measure it before tuning anything.
  • Top-level DB or network calls in DAG files are the classic parse-time killer.
  • Tune one knob at a time and verify with the lag metric, never by feel.
Production debug guideSymptom to Action5 entries
Symptom · 01
Scheduler lag growing past 60 seconds
Fix
Check airflow_dag_processing_last_run_seconds_ago and scheduler logs; the parse loop is falling behind.
Symptom · 02
Per-file parse times over 1 second
Fix
Profile the DAG folder locally with a DagBag load; hunt for top-level DB calls and lazy-import violations.
Symptom · 03
Queue depth rising while the scheduler looks idle
Fix
Check the metadata DB for saturation: pg_stat_user_tables bloat, slow queries, lock contention.
Symptom · 04
Runs firing hours late
Fix
Compare heartbeat time and last parse time; restart the scheduler if it is stuck, then fix the parse bottleneck.
Symptom · 05
Scheduler container memory climbing continuously
Fix
Look for dagbag cache growth and plugin leaks; bound the cache and shrink oversized DAG files.
The Concurrency and Parse Knobs
SettingDefaultWhat it capsUnits
parsing_processes (Airflow 3)2DAG files parsed per loopprocesses
max_threads (Airflow 2.x)2DAG files parsed per loopthreads
min_file_process_interval (Airflow 2.x)30Minimum time between re-parses of one fileseconds
dagbag_import_timeout30Import budget for a single DAG fileseconds
parallelism32Total concurrent task instancestasks
max_active_tasks_per_dag16Concurrent tasks per DAGtasks
⚙ Quick Reference
4 commands from this guide
FileCommand / CodePurpose
profile_parse.pyfrom airflow.models.dagbag import DagBag1. The Scheduler's Loop and Where Time Goes
scheduler.envexport AIRFLOW__SCHEDULER__PARSING_PROCESSES=102. Parsing
db_health.sqlSELECT relname, n_live_tup, n_dead_tup, last_vacuum3. Database Saturation and Index Health
scheduler_lag.ymlgroups:4. Monitoring Scheduler Lag

Key takeaways

1
Parse time is the scheduler's real workload; profile the DAG folder before changing any config.
2
parsing_processes, min_file_process_interval, and dagbag caching are the three knobs that control parsing.
3
Top-level DB or network calls in DAG files are the classic parse-time killer.
4
Scheduler lag (time since last parse) is the one metric that proves tuning works; alert at 120s.
5
When tuning hits its ceiling, redesign
config-driven DAGs, fewer files, or a platform split.

Common mistakes to avoid

4 patterns
×

Maxing max_active_tasks instead of parse capacity

Symptom
More tasks are enabled but lag gets worse
Fix
Raise parsing_processes and measure lag first; task slots don't help a stalled scheduler.
×

DB calls at DAG import time

Symptom
Parse time jumps per DAG and the scheduler falls behind
Fix
Keep top-level code pure; move DB and network work inside task functions with lazy imports.
×

Setting min_file_process_interval too low

Symptom
The scheduler re-parses the same files constantly and CPU is pegged
Fix
Set the interval to 30-60s and rely on dagbag caching; unchanged files skip re-import.
×

Tuning without a lag metric

Symptom
Random knob changes produce no measurable improvement
Fix
Alert on dag_processing.last_run.seconds_ago before and after every change.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
What does the Airflow scheduler do in a loop?
Q02SENIOR
Your scheduler parses 2,000 DAGs and is falling behind. Walk me through ...
Q03SENIOR
When would you stop tuning and move scheduling off Airflow, or split the...
Q01 of 03JUNIOR

What does the Airflow scheduler do in a loop?

ANSWER
Three things: parse DAG files into a dagbag, decide which runs are due and create them, and dispatch task instances to the executor. The parse step dominates at scale because it imports every Python module in the DAG folder.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
How many parsing processes do I need?
02
What is min_file_process_interval?
03
Why do my DAGs take seconds to import?
04
How do I measure scheduler lag?
05
Does the DAG count matter if the tasks are few?
N
Naren Founder & Principal Engineer

20+ years shipping production infrastructure and CI/CD at scale. Everything here is grounded in real deployments.

Follow
Verified
production tested
August 01, 2026
last updated
1,750
articles · all by Naren
🔥

That's Airflow. Mark it forged?

3 min read · try the examples if you haven't

Previous
Airflow Monitoring and Logging
30 / 37 · Airflow
Next
Airflow Security