Worker Queues
Worker queues let one Airflow instance run multiple pools of Celery
workers, each with its own machine size, node pool, concurrency, and
autoscaling range. DAG authors route tasks to a queue with queue="name" on
the operator; everything else keeps running on the default queue.
If you’ve used Astronomer’s worker queues, this is the same model.
When to use a queue
Every Airflow instance starts with a single default queue. Add a named queue when a subset of your tasks needs a different trade-off than the rest:
- Protect long-running tasks from spot preemption. If your default
workers run on a spot node pool, a 30-minute dbt build or dlt extraction
can get killed mid-run every time the cloud reclaims an instance — and
exhaust its retries without ever failing “for real”. Define a
stablequeue on an on-demand pool and route only the critical tasks there; the cheap fan-out work stays on spot. - Memory-hungry tasks. A
highmemqueue with 8 GiB workers and low concurrency keeps a handful of heavy tasks from OOMing workers sized for the other 95% of your DAGs. - Scale-to-zero for expensive shapes. Queues can set Min Workers to 0 — the KEDA autoscaler only spins workers up while tasks are queued, so a big machine shape costs nothing while idle.
Defining a queue
In the console, open your Airflow application and go to the Worker Queues tab:
- Click + Add Queue.
- Give it a name — 1–32 lowercase letters, digits, and hyphens (
highmem,stable,bulk-sync). The name is also the Celery queue tasks route to. - Pick a node pool. Spot pools automatically get the spot toleration, same as the default queue.
- Set CPU / Memory (per worker, request = limit), Concurrency (tasks per worker), and the Min / Max Workers autoscaling range.
- Save & Apply.
Each queue becomes its own worker Deployment
(airflow-worker-<name>; once any queue exists the default queue’s
Deployment is named airflow-worker-default-queue) with its own KEDA
autoscaler that watches only that queue’s queued + running tasks.
The default queue is shown as the first row. You can resize and place it like any other queue, but not delete it — tasks that don’t name a queue have to land somewhere. (Environment presets on the Execution tab also set the default queue’s size as part of the overall environment shape.)
Removing a queue removes its Deployment on the next apply. Running tasks get their normal termination grace period to finish; anything still queued on a removed queue will sit until a queue with that name exists again, so re-route the DAGs first.
Routing tasks (CeleryExecutor)
Set queue on any operator:
from airflow.providers.standard.operators.python import PythonOperator
heavy = PythonOperator(
task_id="build_features",
python_callable=build_features,
queue="highmem",
)Or for a whole DAG via default_args:
with DAG(
dag_id="dbt_nightly",
default_args={"queue": "stable"},
...
):
...Tasks without a queue run on the default queue.
Concrete example — Cosmos dbt on spot: a Cosmos DbtDag watcher task
lives for the whole dbt build. On a spot pool it can be preempted 2–3 times
in a row and burn all its retries. Route it to an on-demand queue:
DbtDag(
dag_id="dbt_prod",
operator_args={"queue": "stable"}, # on-demand pool — preemption-proof
...
)Routing tasks (KubernetesExecutor)
KubernetesExecutor tasks don’t use Celery queues — each task already gets
its own pod (see Executors for when to use which). To
place a KE task (including tasks opted into KE from
hybrid mode) on a specific node pool, override
the pod spec with executor_config:
from kubernetes.client import models as k8s
place_on_highmem = {
"pod_override": k8s.V1Pod(
spec=k8s.V1PodSpec(
containers=[
k8s.V1Container(
name="base",
resources=k8s.V1ResourceRequirements(
requests={"cpu": "2", "memory": "8Gi"},
limits={"cpu": "2", "memory": "8Gi"},
),
)
],
node_selector={
# GKE: cloud.google.com/gke-nodepool
# EKS / AKS: node-pool
"cloud.google.com/gke-nodepool": "highmem-pool"
},
)
)
}
heavy = PythonOperator(
task_id="build_features",
python_callable=build_features,
executor="KubernetesExecutor", # hybrid mode only; omit on KE-only
executor_config=place_on_highmem,
)If the target pool is a spot pool, also add the spot toleration to the pod
override (key: spot, value: "true", effect NoSchedule).
How autoscaling works
Each queue scales independently: the autoscaler polls the Airflow metadata database and targets
workers = ceil((queued + running tasks on this queue) / concurrency)clamped to the queue’s Min/Max range. A queue’s concurrency is enforced on
its workers directly, so a highmem queue with concurrency 4 really runs at
most 4 tasks per worker even if the instance-wide default is 16.
Limits
- Queues are per-application; two Airflow instances don’t share queues.
- Queue names must be unique within an instance, and
defaultis reserved. - If a queue references a node pool that has since been deleted, the config
still applies (with a warning in the logs) but that queue’s workers stay
Pendinguntil the pool exists again or you repoint the queue.