Home DevOps Airflow High Availability: Scheduler That Double-Ran
Advanced 3 min · September 04, 2026
Airflow High Availability Setup

Airflow High Availability: Scheduler That Double-Ran

Airflow HA with two schedulers double-ran every DAG nightly.

N
Naren Founder & Principal Engineer

20+ years shipping production infrastructure and CI/CD at scale. Written from production experience, not tutorials.

Follow
Production
production tested
September 04, 2026
last updated
1,750
articles · all by Naren
Before you start⏱ 30 min
  • You run Airflow with one scheduler and feel the risk
  • You understand heartbeats, DB failover, and parse loops
  • You can configure healthchecks and metrics alerts
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer
  • Airflow HA runs multiple schedulers against an HA metadata DB so one crash doesn't halt scheduling
  • Key components: 2 schedulers, DAG processor pools, scheduler_heartbeat metrics, HA Postgres with replicas, health endpoints
  • Performance insight: a single scheduler stalled at 400 DAGs with 8-minute parse lag; 2 schedulers with tuned processor pools cut lag to 45 seconds
  • Production insight: overlapping parsers double-queue non-idempotent DAGs, so cap runs and monitor per-scheduler heartbeats
✦ Definition~90s read
What is Airflow High Availability Setup?

Airflow HA pairs multiple active schedulers with an HA Postgres primary, tuned processor pools, and per-scheduler heartbeat monitoring so one crash never halts scheduling or duplicates work.

Think of air traffic control with two towers and one runway.
Plain-English First

Think of air traffic control with two towers and one runway. If both towers clear planes to land without talking, you get collisions, not safety. Real HA connects the towers to one shared radar, staggers their shifts, and rings an alarm when either goes quiet.

High availability doubled every DAG instead of protecting them. Two schedulers meant two copies of each run writing the same tables.

Redundancy without coordination is just concurrency with confidence. The second scheduler did exactly what the first did, at the same time.

HA in Airflow is a tuned pair, not a spare. You'll learn that tuning here.

What HA Actually Means for Airflow

HA in Airflow covers four things: schedulers that survive crashes, a database that survives failover, webservers behind a load balancer, and parsing that keeps up with DAG count. Miss any one and the other three can't compensate. Astronomer's resilience checklist names the same set: redundant schedulers and webservers, replicated Postgres or MySQL, distributed executors, and idempotent DAGs.

Two schedulers provide active-active redundancy: both parse and queue, coordinated through the metadata DB with no Raft or ZooKeeper on the side. HA Postgres provides one writable primary with standbys. Processor pools provide parse throughput per scheduler, and extra webservers behind HAProxy or an ALB keep the UI up when one fails.

Don't call one scheduler plus hope HA. It's a single point of failure with a monitoring dashboard.

📊 Production Insight
One scheduler crashed and nothing scheduled for 9 hours overnight.
A second scheduler would have held the line in 60 seconds.
Rule: prod runs 2 schedulers or it isn't prod.
🎯 Key Takeaway
Schedulers, database, and parsing all need redundancy together.
Active-active schedulers coordinate through one writable DB.
Single scheduler is scheduled downtime.

Multiple Schedulers: Safe With Caveats

Multiple schedulers are safe when DAGs are idempotent and capped. Each scheduler parses independently and claims work through the DB; races resolve correctly when reruns are no-ops. Postgres 12+ and MySQL 8.0+ are ready out of the box with zero extra flags: just start more scheduler copies. MariaDB needs 10.6+ for SKIP LOCKED/NOWAIT support, and SQL Server HA remains untested, so don't stake prod on either.

The safety hinge is row-level locking. The critical section takes write locks across the Pool table (SELECT ... FOR UPDATE) so limits hold while task instances move to queued. Flip use_row_level_locking to False and you must run exactly one scheduler. Tune heartbeat intervals so failover detects death in tens of seconds without flapping. Ten-second heartbeats with a 30-second health threshold catch crashes fast while surviving GC pauses.

Verify with duplicate audits after enabling the pair. Two weeks of zero duplicate logical dates proves the tuning; assumptions prove nothing.

config/scheduler-ha.cfgYAML
1
2
3
4
5
6
7
8
9
10
11
12
13
[scheduler]
# two schedulers, coordinated heartbeats
scheduler_heartbeat_sec = 10
scheduler_health_check_threshold = 30
max_threads = 4

[dag_processor]
parsing_processes = 4
dag_dir_list_interval = 60

# run two scheduler replicas (compose or k8s)
# docker compose up -d --scale airflow-scheduler=2
# airflow dags list-jobs -o table  # both heartbeats must stay fresh
📊 Production Insight
Uncapped DAGs double-wrote 14 partitions under dual schedulers.
Caps plus idempotent loads cut duplicates to zero.
Rule: prove safety with audits, not optimism.
🎯 Key Takeaway
Idempotent plus capped DAGs make scheduler pairs safe.
Heartbeat 10s with 30s threshold balances speed and flapping.
Audit duplicates for two weeks post-enable.

The DAG Processor Pool

The DAG processor pool parses files into the DB so schedulers queue from metadata, not files. parsing_processes sets parallel parsers; dag_dir_list_interval sets rescan cadence. Under-provisioned pools lag identically on every scheduler. The scheduling loop itself batches: max_dagruns_to_create_per_loop locks how many DAGs one scheduler claims for run creation (lower it for 10k-task monsters), max_dagruns_per_loop_to_schedule bounds examined runs per pass, and max_tis_per_query caps task rows per query at or below core.parallelism.

Slow imports hurt pools most: a 12-second top-level DB connection times parser count stalls the whole fleet. Keep top-level code pure and lazy-load clients inside tasks. Orphaned-task checks (orphaned_tasks_check_interval) plus dead-scheduler adoption mean a crashed scheduler's running tasks get supervised by survivors instead of drifting.

Scale pools with DAG count, not scheduler count. Doubling schedulers over a slow file set doubles cost while lag stands still. When the scheduler process is CPU-bound, extra schedulers scale nearly linearly until the shared DB becomes the ceiling, so watch connection counts and consider PgBouncer on Postgres fleets.

📊 Production Insight
12-second imports on 200 DAGs stalled both schedulers equally.
Lazy imports cut p95 parse from 12s to 400ms.
Rule: pools scale with file speed, not scheduler count.
🎯 Key Takeaway
Pools parse files so schedulers queue from metadata.
Pure top-level code keeps parsers fast.
Fix imports before adding schedulers.

Database HA: Replicas and Failover

Schedulers need one writable primary; the UI and audits can read from replicas. Managed Postgres with automated failover plus connection pooling handles the pattern without heroics. Postgres fleets of any size should front the DB with PgBouncer: Airflow is connection-hungry and process-based Postgres accounting runs out long before MySQL-style thread pools do. The Helm chart ships PgBouncer out of the box.

During failover, schedulers pause writes and resume on the new primary. Tasks already running continue; new scheduling waits seconds, not hours. Test failover quarterly so the runbook works when it matters.

Never multi-master the metadata DB. Split-brain writes corrupt run state in ways no backfill can repair. Idle-loop pacing via scheduler_idle_sleep_time keeps quiet schedulers from hot-spinning while letting busy ones chain loops back-to-back.

📊 Production Insight
Single-node DB outage halted dual schedulers for 3 hours.
Managed failover cut the next outage to 90 seconds.
Rule: HA schedulers demand HA Postgres.
🎯 Key Takeaway
One writable primary, replicas for reads, pooled connections.
Failover pauses scheduling for seconds when rehearsed.
Multi-master metadata is corruption with extra steps.

Detecting Double-Scheduling

Duplicates announce themselves as identical logical dates with minute-apart starts. Audit weekly with list-runs output piped through a counter. Any duplicate on a non-idempotent DAG is a data incident, not a curiosity.

Prevent with caps and idempotency: max_active_runs bounds concurrency per DAG, partition-keyed writes absorb races. Alert on duplicate creation so the next race pages within minutes.

Keep the audit after tuning. Parser timing drifts as DAGs grow, and today's safe overlap becomes next quarter's race.

ops/ha_audit.shBASH
1
2
3
4
5
6
7
8
9
10
11
# duplicate-run audit (run weekly after enabling HA)
airflow dags list-runs sales_daily -o json > /tmp/runs.json
python3 -c "
import json, collections
runs = json.load(open('/tmp/runs.json'))
keys = [r.get('logical_date') or r.get('run_id') for r in runs]
dups = [k for k, c in collections.Counter(keys).items() if c > 1]
print('duplicate logical dates:', dups if dups else 'none')
"
# heartbeat freshness
# airflow dags list-jobs -o table  # scheduler heartbeats < 60s
📊 Production Insight
Weekly audits caught 3 duplicate dates before downstream noticed.
Caps plus alerts held zero doubles for 6 months after.
Rule: audit duplicates like you audit money.
🎯 Key Takeaway
Duplicate logical dates prove the race; audits catch it weekly.
Caps plus idempotency prevent the next one.
Alert on creation, not on finance's restatement.

Health Endpoints and Liveness

Liveness probes should hit scheduler health endpoints and restart dead instances automatically. In Kubernetes that's a livenessProbe on the scheduler deployment; on compose it's a healthcheck plus restart policy.

Expose scheduler_heartbeat age, queued-task age, and parse duration to your metrics system. Page when any scheduler exceeds 60 seconds stale or queue age passes 10 minutes.

Document the runbook: which scheduler died, how to restart, how to verify re-election. Dead schedulers at 3 AM shouldn't require design meetings.

⚠ Average Heartbeats Lie
Monitor each scheduler's heartbeat separately. An averaged heartbeat hides one dead scheduler behind one healthy one for exactly as long as it takes to miss every SLA.
📊 Production Insight
Liveness probes cut scheduler outages from hours to 2 minutes.
Heartbeat paging caught the next death before queue age moved.
Rule: automate restart, page on staleness.
🎯 Key Takeaway
Probes restart dead schedulers before humans wake.
Heartbeat age plus queue age page early and accurately.
Runbooks beat design meetings at 3 AM.
● Production incidentPOST-MORTEMseverity: high

The HA Scheduler That Double-Ran Every DAG. Two schedulers queued the same intervals twice.

Symptom
Row counts doubled on 14 nightly partitions over 5 days while both schedulers reported healthy. Engineers blamed upstream retries for a week before airflow dags list-runs showed duplicate logical dates with start times 3 minutes apart. Dropping back to 1 scheduler stopped the doubles in 20 minutes, proving the race.
Assumption
The billing-platform team assumed adding a second scheduler was pure redundancy: one active, one standby, never both acting at once. They left max_active_runs uncapped on 12 nightly DAGs, kept append-only loads, and watched a single aggregate heartbeat that couldn't show one scheduler racing.
Root cause
Both schedulers parsed and queued the same DAG intervals with overlapping timing and no concurrency caps. Non-idempotent loads executed twice per interval 3 minutes apart, doubling rows across 14 nightly partitions. The aggregate heartbeat looked healthy because one scheduler's liveness masked the other's racing behavior.
Fix
They set max_active_runs=1 on the 12 unsafe DAGs, rewrote loads as delete-then-insert per ds partition, and set scheduler_heartbeat_sec to 10 with a 30-second health threshold so the pair coordinated instead of racing. They added per-scheduler heartbeat alerts paging past 60 seconds stale and ran airflow dags list-runs audits for 2 weeks to prove zero duplicates.
Key lesson
  • Redundant schedulers need idempotent DAGs and run caps, or redundancy becomes duplication.
  • Monitor each scheduler's heartbeat separately; averages hide a single dead scheduler for hours.
Production debug guideSeparate dead schedulers, double-runs, parse overload, and DB failover.4 entries
Symptom · 01
Scheduling stalls with two schedulers running
Fix
Query scheduler heartbeats: airflow dags list-jobs -o table shows scheduler job states and ages. If one heartbeat exceeds 60s, restart that scheduler container and check its logs for OOM or DB disconnects. Alert per scheduler, never on an average.
Symptom · 02
Duplicate runs for the same logical date
Fix
Run airflow dags list-runs <dag_id> -o table and look for duplicate logical dates with overlapping queued times. Cap max_active_runs=1 on the affected DAG, make loads partition-idempotent, then re-enable and audit for 48 hours.
Symptom · 03
Both schedulers lag identically under load
Fix
Time DAG parsing: airflow dags list-import-errors plus scheduler logs showing parse durations. If p95 parse exceeds 5s, move DB calls out of top-level code and raise parsing_processes. Check max_dagruns_per_loop_to_schedule and max_tis_per_query for hogging: one scheduler claiming every run starves its peer. Adding schedulers before fixing imports doubles cost without cutting lag.
Symptom · 04
Both schedulers healthy but nothing schedules during DB failover
Fix
Check DB primary health and replica lag, then scheduler DB connection errors. Fail over to the standby per your managed-DB runbook, verify writes resume, and confirm both schedulers re-elect against the new primary.
★ Airflow HA Survival Cheat SheetProve which scheduler is alive, stop duplicate runs, and survive DB failover.
Scheduling stalls with HA pair running
Immediate action
List scheduler jobs and heartbeat ages
Commands
airflow dags list-jobs -o table
airflow config list | grep -i heartbeat
Fix now
Restart the dead scheduler, check OOM and DB logs, confirm heartbeat resumes under 30s.
Same interval runs twice+
Immediate action
Hunt duplicate logical dates
Commands
airflow dags list-runs sales_daily -o table | sort | uniq -d
airflow dags list-runs sales_daily --state running -o table
Fix now
Cap max_active_runs=1 and make loads partition-idempotent, then audit 48h.
Both schedulers lag identically+
Immediate action
Check parse errors and durations
Commands
airflow dags list-import-errors
airflow config list | grep -iE 'parsing|dag_dir_list'
Fix now
Remove top-level DB calls, raise parsing_processes, re-time parsing.
Nothing schedules during DB failover+
Immediate action
Verify metadata DB writability
Commands
airflow db check
airflow dags list-jobs --state running -o table
Fix now
Fail over DB per runbook, verify writes, confirm schedulers re-elect.
Single vs HA Scheduler Compared
SetupSurvivesCostUse when
1 scheduler, 1 DBNothing: any crash haltsCheapestDev and throwaway staging
1 scheduler, HA DBDB failover onlyManaged DB costSmall teams, off-hours tolerance
2 schedulers, HA DBScheduler crash + DB failover2x scheduler + HA DBProduction default
2+ schedulers, sharded parsingParse overload + crashHigher CPU + tuningFleets past 500 DAGs
2 schedulers + PgBouncer + LBScheduler, DB-conn, UI faultsFull prod costSerious prod default
3+ schedulers, tuned loopsCPU-bound schedulingDB must keep up1000+ DAG fleets
⚙ Quick Reference
2 commands from this guide
FileCommand / CodePurpose
configscheduler-ha.cfg[scheduler]Multiple Schedulers
opsha_audit.shairflow dags list-runs sales_daily -o json > /tmp/runs.jsonDetecting Double-Scheduling

Key takeaways

1
HA means 2+ schedulers plus HA Postgres plus PgBouncer plus load-balanced webservers; Postgres 12+/MySQL 8+ just works.
2
Overlapping parsers double-queue non-idempotent DAGs without caps.
3
Size DAG processor pools to parse load; more schedulers can't fix slow imports.
4
Monitor per-scheduler heartbeat age and queued-task age continuously.
5
Health endpoints and liveness probes restart dead schedulers before humans notice.

Common mistakes to avoid

4 patterns
×

Running a single scheduler in production

Symptom
One crash halts all scheduling for hours; no failover exists until a human restarts the process.
Fix
Run 2 schedulers with tuned ha heartbeat intervals, single-writer DB with read replicas, and DAG processor pools sized to parse load. Monitor scheduler_heartbeat age per scheduler, not just one.
×

Assuming multiple schedulers can double-run any DAG safely

Symptom
Non-idempotent loads write twice when two schedulers race the same interval.
Fix
Keep DAG files parse-safe and idempotent, cap max_active_runs per DAG, and alert on duplicate logical dates. Overlapping parsers double-queue only when DAGs allow concurrent runs.
×

Adding schedulers instead of fixing slow DAG parsing

Symptom
Two schedulers both lag; queue age unchanged while CPU doubles.
Fix
Size parsing_processes to DAG count, raise dag_dir_list_interval sanely, and keep top-level DAG code side-effect free. A 12-second import on 200 DAGs stalls every scheduler equally.
×

HA schedulers on a single-node metadata DB

Symptom
DB outage halts both schedulers; HA exists everywhere except the actual single point of failure.
Fix
Put Postgres behind HA (managed failover or standby), pool connections, and alert on failover events. Schedulers without a writable DB elect nothing and schedule nothing.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
Why did the HA scheduler double-run every DAG?
Q02SENIOR
How do you set up Airflow HA in production?
Q03SENIOR
Explain the double-scheduling race and how you prevent it.
Q01 of 03JUNIOR

Why did the HA scheduler double-run every DAG?

ANSWER
Two schedulers with overlapping parsing and no concurrency caps double-queued tasks on non-idempotent DAGs. The fix is multiple schedulers with HA tuning, idempotent partition-keyed writes, capped runs, and scheduler_heartbeat monitoring.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
Can Airflow run multiple schedulers?
02
Heartbeat vs DAG processor: what's the difference?
03
What causes double-scheduling with HA?
04
What database setup backs HA schedulers?
05
Which metrics prove HA is healthy?
N
Naren Founder & Principal Engineer

20+ years shipping production infrastructure and CI/CD at scale. Written from production experience, not tutorials.

Follow
Verified
production tested
September 04, 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 Configuration and Environment
26 / 37 · Airflow
Next
Airflow Testing with Pytest