Airflow has six distinct concurrency controls. They interact. Tuning the wrong one is the most common cause of "why is my DAG queueing even though workers are idle".

The six knobs

#KnobScopeWhat it limits
1parallelismWhole clusterTotal task instances across all DAGs
2max_active_tasks_per_dagOne DAGTasks of one DAG running simultaneously
3max_active_runs_per_dagOne DAGDAG runs live in parallel
4max_active_tis_per_dagrunOne taskTask instances within a single run (dynamic mapping)
5PoolsAcross DAGsGates against an external rate-limited resource
6pool_slotsTask-levelSlots this task consumes from its pool

Tune in order: cluster → DAG → run → mapping → pool. A slot consumed at any higher level blocks every lower one.

1. parallelism

Cluster-wide upper bound.

# airflow.cfg
[core]
parallelism = 64

Or via env:

AIRFLOW__CORE__PARALLELISM=64

2. max_active_tasks_per_dag

Per-DAG ceiling on concurrent tasks.

with DAG(
    dag_id="fat_dag",
    max_active_tasks=8,
    ...
) as dag:
    ...

3. max_active_runs_per_dag

How many runs of one DAG can be in flight at the same time.

with DAG(
    dag_id="stateful_dag",
    max_active_runs=1,
    ...
) as dag:
    ...

Warning

max_active_runs=1 is the most commonly-missed setting in the wild. Backfills with parallel run execution regularly race on shared state. If two runs of the same DAG write to the same table, you need max_active_runs=1 — always.

4. max_active_tis_per_dagrun

Limits the concurrency of a single task within one run. Important for dynamically mapped tasks.

@task(max_active_tis_per_dagrun=5)
def process_file(uri: str):
    ...

process_file.expand(uri=get_file_list())

If get_file_list returns 100 URIs, without this limit Airflow spawns 100 concurrent task instances. With max_active_tis_per_dagrun=5, at most 5 are active at once.

5. Pools

Cross-DAG gates against a shared external resource.

# Define the pool via UI, CLI, or a seeding DAG
# airflow pools set salesforce_api 5 "Max concurrent SF API calls"

# Reference from every task that hits the resource
extract = PythonOperator(
    task_id="extract_from_sf",
    pool="salesforce_api",
    pool_slots=1,
    python_callable=fetch,
)

Pool semantics:

When a pool, not per-DAG concurrency

GoalUse
"Do not run more than 5 concurrent tasks against Salesforce, across all DAGs"Pool salesforce_api with 5 slots
"Do not let this DAG run more than 5 tasks at once"max_active_tasks_per_dag=5 on the DAG
"Do not overlap runs of the stateful daily ETL"max_active_runs=1 on the DAG

6. pool_slots

A single task can claim multiple slots from a pool. Useful for heavy tasks that should count as multiple:

heavy_export = PythonOperator(
    task_id="heavy_export",
    pool="warehouse_slots",
    pool_slots=3,      # this task counts as 3 concurrent tasks
    python_callable=export,
)

With pool_slots=3 in a pool of 10, heavy_export occupies 3 of 10 slots; lighter tasks in the same pool share the remaining 7.

The interaction matrix

Tasks must satisfy every applicable limit to run. A task in DAG A that references pool P with 1 slot requires:

  1. A free slot in parallelism (cluster).
  2. A free slot in DAG A's max_active_tasks_per_dag.
  3. The run being within max_active_runs_per_dag.
  4. If dynamically mapped: a free slot in max_active_tis_per_dagrun.
  5. Enough slots in P for pool_slots.

Any bottleneck queues the task.

Schedule choices

Related to concurrency: what triggers the DAG at all.

ScheduleMeaningBest for
"@hourly", "@daily", cronTime-basedClock-driven pipelines
timedelta(minutes=30)Interval since last success"Every X time" with backpressure
[asset] or [asset_a, asset_b]Asset-basedCross-DAG event-driven
AssetWatcher(...)External eventOutside-world triggers
NoneManual onlyOn-demand workflows

See dependency types for which to pick.

Catchup

catchup=False  # always, in production

catchup=True replays every missed interval since start_date. A DAG paused for a week dumps 168 hourly runs into the queue the moment you unpause. Always catchup=False in production; handle intentional backfills explicitly with airflow dags backfill.

Sensor discipline

Sensors interact badly with concurrency if you get them wrong. The rules:

Danger

A synchronous sensor with timeout=None is a worker-starvation bomb. It holds a slot forever, and the first time it stalls, your Airflow cluster becomes a sensor-holding pen for every remaining DAG. Deferrable sensors are not optional; they are the baseline.

Tuning recipes

Symptom: tasks queued even though workers are idle

Diagnosis: one of the six limits is capping. Check in order:

  1. airflow config get-value core parallelism — cluster ceiling.
  2. Review the DAG's max_active_tasks and max_active_runs.
  3. Check pools: airflow pools list. Any at 100% utilization?
  4. Check for max_active_tis_per_dagrun on dynamically mapped tasks.

Symptom: one DAG monopolizing workers

Diagnosis: probably no max_active_tasks_per_dag set; one DAG bursts to fill the cluster.

Fix: add max_active_tasks=N to the greedy DAG.

Symptom: duplicate writes in a backfill

Diagnosis: max_active_runs > 1 on a stateful DAG.

Fix: set max_active_runs=1.

Symptom: API rate-limits hit during simultaneous DAG runs

Diagnosis: no pool; multiple DAGs independently exceed the rate limit.

Fix: create a pool for the API; every task referencing it shares the budget.

Symptom: async sensors never fire

Diagnosis: no Triggerer process.

Fix: deploy the Triggerer. Astronomer defaults it on; OSS installs must enable explicitly.

Config file reference

Common settings:

[core]
parallelism = 64
default_task_retries = 2
max_active_tasks_per_dag = 16
max_active_runs_per_dag = 16
dagbag_import_timeout = 120

[scheduler]
scheduler_heartbeat_sec = 5
min_file_process_interval = 60
dag_dir_list_interval = 300
parsing_processes = 4
schedule_after_task_execution = True

[celery]                              # for CeleryExecutor
worker_concurrency = 16
worker_autoscale = 16,4

[webserver]
rbac = True
web_server_worker_timeout = 120
expose_config = False

[logging]
remote_logging = True
remote_log_conn_id = aws_default
remote_base_log_folder = s3://airflow-logs/{env}/

On Astronomer, set via environment variables with the AIRFLOW__ prefix.

See also