Home DevOps Airflow HITL Approval: Stop Unreviewed DDL From Shipping
Advanced 4 min · September 04, 2026
Airflow Human in the Loop Approvals

Airflow HITL Approval: Stop Unreviewed DDL From Shipping

Airflow HITL approval gates stop unreviewed DDL with named approvers and audit trails.

N
Naren Founder & Principal Engineer

20+ years shipping production infrastructure and CI/CD at scale. Lessons pulled from things that broke in production.

Follow
Production
production tested
September 04, 2026
last updated
1,750
articles · all by Naren
Before you start⏱ 20 min
  • An Airflow 3.x deployment with the standard provider installed
  • A DAG with a step risky enough to deserve review (DDL, deploy)
  • Slack or email notifier access for approval pings
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • Airflow HITL approval gates pause the DAG for a named human decision before destructive steps like DDL or deploys
  • Key components: ApprovalOperator and HITLOperator, assigned_users, notifiers with UI links, response_timeout with Reject defaults
  • Performance insight: waiting approvals park in awaiting_input holding zero worker slots, so 100 pending gates cost nothing while they wait
  • Production insight: one unattended night-window migration broke downstream pipelines for hours; a gate would have held it for one review
  • Biggest mistake: approval gates with no notifiers, leaving decisions waiting in a UI the approvers never open
✦ Definition~90s read
What is Airflow Human in the Loop Approvals?

Airflow HITL approval is the Airflow 3 pattern of pausing a DAG at an approval task until a named human approves or rejects. It gates destructive DDL, prod deploys, and money movement with notifiers, timeouts, and audit trails.

Think of a demolition crew that needs a signed permit before knocking down a wall, not after.
Plain-English First

Think of a demolition crew that needs a signed permit before knocking down a wall, not after. The crew (your pipeline) prepares everything, then waits. A named inspector (your approver) reviews the plan, signs approve or deny, and the decision is filed permanently. No signature, no demolition.

Some pipeline steps shouldn't run themselves. Dropping a column, migrating a billion-row table, or moving money deserves a human looking at the plan before the machine executes it.

One team's DDL migration ran unattended in the night window. The schema change broke every downstream pipeline, and review happened after the damage, in the postmortem. Automation did exactly what it was told.

HITL gates insert a person at the dangerous step. Approve or deny, named approver, full audit trail. Machines propose. Humans dispose.

Why Human Gates Exist in Pipelines

Pipelines execute; humans judge. Data arrival is a condition a sensor can watch, but dropping a column is a decision carrying accountability no trigger can own. You'll draw the line at irreversibility: reversible steps automate, irreversible steps ask.

Regulated teams feel it first. Finance, health, and payments pipelines need a named person owning each dangerous action for auditors. But every team benefits the second a night-window migration breaks three downstream owners.

Count your irreversible steps. DDL, prod deploys, money movement, bulk deletes: each gets a gate. Everything else keeps flowing untouched, so the pipeline stays fast where it's safe.

📊 Production Insight
Night windows feel careful but remove witnesses. Gates add the witness back. Rule: irreversible steps ask, everything else flows.
🎯 Key Takeaway
Automate the reversible, gate the irreversible. If a step can't be undone by rerunning, a named human owns the decision before it executes.

HITLOperator: The Approve and Deny Task

ApprovalOperator is the approve-or-deny task. It renders a subject plus body, offers Approve and Reject, restricts responses to assigned_users, and records the decider with a timestamp. Place it directly before the dangerous task so nothing slips between review and execution.

Defaults carry the safety. defaults='Reject' means silence, timeout, or confusion all resolve to no. You'll pair it with a response_timeout measured in hours, not days, so forgotten gates fail safe instead of parking forever.

Assigned users carry the accountability. Named owners from the team that understands the migration, not a group alias. The audit trail names a person, which is exactly what the postmortem and the auditor both ask for.

Know what each operator is. HITLOperator is the base: subject plus options (single, multiple=True for multi-select, defaults for the pre-checked choice, params_input for free-form human input that flows to downstream XCom). ApprovalOperator narrows it to Approve/Reject — and Reject terminates rather than continuing, so reach for HITLBranchOperator when a rejection should run cleanup tasks instead of stopping. HITLEntryOperator covers the data-entry shape. Lock sensitive gates with assigned_users=[{id, name}, ...] — only listed users can respond, and both fields are required.

The big 3.3 change: waiting HITL tasks now sit in a dedicated scheduler-managed awaiting_input state instead of deferring onto the triggerer. While parked they hold neither a worker slot nor triggerer capacity, so the triggerer can scale to zero with approvals still pending; tasks resume on human response or the scheduler's response-timeout sweep. On 3.1/3.2 the same operators use the older trigger-based deferral, which is why version-pinned docs disagree about triggerer sizing for HITL.

dags/orders_migration.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import datetime
import pendulum
from airflow.sdk import dag, task
from airflow.providers.standard.operators.hitl import ApprovalOperator

@dag(
    dag_id="orders_migration",
    schedule=None,
    start_date=pendulum.datetime(2026, 1, 1, tz="UTC"),
    catchup=False,
    tags=["ddl", "gated"],
)
def orders_migration():
    review = ApprovalOperator(
        task_id="review_migration",
        subject="Approve orders table migration for {{ ds }}",
        body="""
        Migration preview:
        ALTER TABLE serving.orders ADD COLUMN loyalty_tier TEXT;
        Backfill: UPDATE serving.orders SET loyalty_tier = 'none' WHERE loyalty_tier IS NULL;
        Dry-run on staging: 4.1M rows, 38s, zero lock waits.
        """,
        defaults="Reject",
        response_timeout=datetime.timedelta(hours=4),
        assigned_users=[{"id": "7", "name": "priya"}, {"id": "12", "name": "marcus"}],
    )

    @task
    def apply_migration() -> str:
        return "migration applied after approval"

    review >> apply_migration()

orders_migration()
⚠ Review the Real Artifact
Render the exact SQL or manifest in the gate body. Approvers review what executes, not a ticket describing it. What they see is what runs.
📊 Production Insight
Silence must mean no. Reject defaults turn forgotten gates into safe stops. Rule: named approvers, rendered artifact, bounded wait.
🎯 Key Takeaway
Approve-or-deny with named deciders, Reject defaults, and hour-scale timeouts. The gate sits directly before the danger with zero gap.

Wiring Notifications That Reach Humans

Approvers live in Slack and email, not in the Airflow UI. Notifiers bridge the gap by posting the gate's subject plus a direct UI response link the moment the task parks. You'll attach them on gate creation, success, and failure so no state change goes quiet.

Write the notification like a page. What needs review, where to click, how long before it auto-rejects. An approver triaging Slack at midnight decides in seconds with that format and ignores vague pings.

Test the path with a dry gate. Trigger a test approval on staging monthly and confirm the Slack message, the UI link, and the email all arrive. Rotated webhooks silently break this path, and silent gates recreate the incident with better intentions.

Wire notifiers for the request moment, not just success/failure: the notifiers=[...] list fires when the HITL request goes pending, and HITLOperator.generate_link_to_ui_from_context builds the direct response link for Slack/email. Humans answer on the Required Actions page or via REST — PATCH /api/v2/dags/{dag_id}/dagRuns/{run_id}/taskInstances/{task_id}/{map_index}/hitlDetails with {chosen_options, params_input} — and GET .../hitlDetails?response_received=false lists what's still waiting. Locally, airflow dags test parks at awaiting_input and waits for that same UI/API response (response_timeout isn't enforced without a scheduler, so the test waits indefinitely until you answer) — which also lets an agent drive the loop: watch for the waiting log line, ask the human, submit via API.

Don't confuse this with agentic HITL Review on AgentOperator (common-ai provider): that loop polls XCom with time.sleep and holds its worker slot for the whole review (30+ minutes of occupied slot at a 10s poll). Standard-provider HITL parks slot-free. Pick standard HITL for gates; pick Review only when an LLM output needs conversational approve/reject/regenerate rounds.

dags/pricing_gate.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import datetime
from airflow.providers.standard.operators.hitl import HITLOperator

notify_data_team = HITLOperator.generate_link_to_ui_from_context  # link builder for notifiers

review_with_ping = HITLOperator(
    task_id="review_pricing_change",
    subject="Approve pricing backfill for {{ ds }}",
    options=["Approve", "Reject"],
    defaults=["Reject"],
    response_timeout=datetime.timedelta(hours=2),
    assigned_users=[{"id": "7", "name": "priya"}],
    notifiers=[],  # attach Slack/email notifier posting the UI response link
)
# approver picks in UI, email, or Slack link; choice lands in XCom as chosen_options
# downstream: ti.xcom_pull(task_ids='review_pricing_change')['chosen_options']
📊 Production Insight
Gates without pings wait on notification gaps, not decisions. Page-format messages decide in seconds. Rule: test the notify path monthly.
🎯 Key Takeaway
Post subject plus UI link to Slack and email on every gate event. Test monthly, because rotated webhooks silently convert gates into parking lots.

Timeout and Expiry of Approval Tasks

Timeouts bound the wait; expiry defines the default. Four hours fits a business-day review, overnight windows need morning deadlines, and money movement may deserve minutes. You'll set each gate's window from the decision's real urgency.

Defaults decide what silence means. Reject is the safe answer for destructive steps: the migration waits for explicit approval or it doesn't run. Narrow informational inputs may default differently, but danger always defaults to no.

Escalate on expiry, don't just stop. A timed-out gate should page the secondary owner with the same review packet, not vanish into a skipped task. The first timeout teaches you the real review latency; adjust windows with that data.

Name the timeout right for your version: response_timeout is the 3.3+ waiter (defaults → scheduler sweep), while 3.1/3.2-era examples show execution_timeout on the same operators. Either way, pair every gate with defaults (what happens when nobody answers) so expiry is a decision, not a hang. DDL-until-approved, deploy-until-signed, money-movement-until-two-eyes — each gets subject, body with the XCom-rendered diff, assigned_users, response_timeout, and defaults, all five or the gate isn't prod.

📊 Production Insight
Infinite waits park pipelines; silent expiries hide them. Bounded waits with escalation stay visible. Rule: danger defaults to no, always.
🎯 Key Takeaway
Bound every wait in hours, default danger to Reject, escalate timeouts to the secondary owner. Tune windows from measured review latency.

Where HITL Fits: DDL, Deploys, Money

HITL fits where judgment outweighs checkability. Destructive DDL needs a human who understands blast radius. Prod deploys need an owner accepting the release. Money movement needs a signer auditors can name. Each gate buys accountability automation cannot.

You'll skip gates where conditions suffice. Data arrival, partition completeness, and row-count bands are sensor and gate-task territory. Humans decide what only humans can own; everything checkable stays automated.

Review the gate list quarterly. Steps that grew safe automation graduate out; new irreversible steps graduate in. A gate inventory that never changes is either perfect or unexamined.

📊 Production Insight
Gates on checkable conditions waste human attention. Judgment-only gates stay respected. Rule: if a query can decide it, don't page a person.
🎯 Key Takeaway
Gate irreversible, human-owned decisions; automate checkable conditions. Revisit the gate inventory quarterly as automation matures.

HITL Versus Sensors and External Waits

Sensors watch the world; HITL watches humans. A sensor resumes when a file lands whether or not anyone cares. A gate resumes when a named person accepts responsibility. You'll pick by asking who owns a wrong outcome.

External waits without gates are the legacy pattern: a human watches a dashboard, then clicks something elsewhere. HITL folds the click into the DAG with identity, timeout, and audit. Migration means replacing chat approvals with gate tasks one pipeline at a time.

Never stack both blindly. A gate followed by a sensor (approve, then wait for the file) models reality; a sensor followed by a redundant gate just slows mornings. Each wait should answer a different question.

📊 Production Insight
Chat approvals lack identity, timeout, and audit. Gates add all three in-DAG. Rule: each wait answers a different question or it gets deleted.
🎯 Key Takeaway
Sensors answer did-it-arrive, gates answer do-you-own-it. Migrate chat approvals into gate tasks, and never stack waits that answer the same question.
● Production incidentPOST-MORTEMseverity: high

The Schema Change That Skipped Review

Symptom
Morning brought cascading downstream failures: broken queries, failed dbt models, and stale dashboards across three teams. The migration task glowed green; everything after it glowed red. Hours passed before anyone connected the wreckage to the overnight DDL, because nothing in the run history showed a review step.
Assumption
The team assumed automation plus scheduling equaled safety. The migration ran in the night window because off-hours felt careful, tests had passed on staging, and every previous migration succeeded. Review lived in pull requests, and merged meant approved for execution too. Nobody distinguished approving code from approving a live run.
Root cause
The DDL task executed automatically on schedule with no human gate between plan and execution. Staging success substituted for production review, and the night window meant no engineer watched the run. No approval task, notifier, or assigned reviewer existed anywhere in the DAG.
Fix
An ApprovalOperator now gates every destructive migration, with the exact SQL rendered in the gate body and assigned_users naming the owning team. Notifiers push the response link to Slack on gate creation, response_timeout with defaults='Reject' bounds the wait, and every decision lands in the audit trail. Unreviewed DDL can no longer execute no matter the hour.
Key lesson
  • Approving code is not approving execution. Destructive steps need a live gate at run time, not just a merged pull request from last week.
  • Gates need named deciders and safe defaults. Anyone-can-approve is theater, and waiting forever is a parked pipeline, not caution.
  • Notify the decider where they work. An approval visible only in the Airflow UI waits on a notification gap, not a judgment gap.
Production debug guideFour approval-gate failures, with the exact checks that unstick each one.4 entries
Symptom · 01
Pipeline stalls at an approval nobody knows about
Fix
List tasks parked in awaiting_input in the Grid view and check each gate's age. For stale gates, confirm notifiers fired by searching Slack for the subject line; if no notification exists, fix the notifiers argument before chasing approvers.
Symptom · 02
The right person cannot approve the gate
Fix
Inspect assigned_users on the gate task: only listed id-plus-name pairs may respond. If the intended approver isn't listed, update the DAG, and verify their UI login matches the listed name exactly.
Symptom · 03
An approval waits for days with no decision
Fix
Read the gate's response_timeout and defaults in the DAG file. Add response_timeout plus defaults='Reject' so silence rejects safely, and wire timeout escalation to the secondary owner via on-failure callbacks. List parked requests with GET hitlDetails?response_received=false, then answer via the Required Actions page or PATCH hitlDetails — dags test resumes on the next poll once the response lands.
Symptom · 04
Auditors ask who approved a past migration
Fix
Pull the response record via the HITL REST API (PATCH hitlDetails history) or the UI's Required Actions page. Confirm actor, choice, and timestamp exist for the incident window; re-export the trail for the audit ticket.
HITL vs Sensor vs External Wait Compared
Wait styleHolds worker slot?DecidesUse when
HITLOperator approval gateNo, parks in awaiting_inputA named human approves or rejectsDestructive DDL, deploys, money movement
HITLBranchOperatorNo, parks in awaiting_inputA human picks the branchOne human choice routes the DAG
Deferrable sensorNo, triggerer watchesAn automated condition firesFiles, partitions, upstream data
Poke sensorYes, full slotAn automated condition firesWaits under 2 minutes only
External polling scriptYes, wherever it runsA human checks a dashboardLegacy flows you plan to migrate
awaiting_input (3.3+)Triggerer sized for human waitsNo worker or triggerer held; scheduler sweep resumestasks
Agentic HITL ReviewReview rounds billed as gatesXCom poll, holds worker slot — LLM loops onlytasks
⚙ Quick Reference
2 commands from this guide
FileCommand / CodePurpose
dagsorders_migration.pyfrom airflow.sdk import dag, taskHITLOperator
dagspricing_gate.pyfrom airflow.providers.standard.operators.hitl import HITLOperatorWiring Notifications That Reach Humans

Key takeaways

1
Gate DDL, deploys, and money movement with subject, rendered body, assigned_users, response_timeout, and defaults
all five or it isn't prod.
2
Use ApprovalOperator when Reject means stop; use HITLBranchOperator when rejection should route to cleanup instead of terminating.
3
On Airflow 3.3, waiters park in scheduler-managed awaiting_input holding no worker or triggerer
the triggerer can scale to zero mid-approval.
4
Notify on the request moment with notifiers plus a generated UI link, and answer via Required Actions or PATCH hitlDetails from API or dags test.
5
Keep agentic HITL Review (XCom polling, holds a worker slot) separate from standard HITL gates (slot-free park)
different cost, different use.

Common mistakes to avoid

4 patterns
×

Running destructive DDL with no human gate

Symptom
A breaking schema migration ships in the night window unreviewed; downstream pipelines fail for hours before anyone connects the deploy to the outage.
Fix
Gate every destructive DDL, prod deploy, and money-movement task with an ApprovalOperator listing assigned_users and defaults='Reject'. The approver sees the exact migration body in the UI before choosing.
×

Approval gates with no timeout or default

Symptom
The migration waits three days for an approver on vacation; the DAG run parks, SLAs miss, and nobody is paged because nothing failed.
Fix
Set response_timeout plus defaults='Reject' so silence rejects safely. An approval that waits forever is a DAG run parked indefinitely, not a safety measure.
×

Letting anyone in the org approve anything

Symptom
A well-meaning outsider approves a migration they don't understand; the gate provides theater instead of review and the audit trail impresses nobody.
Fix
Restrict assigned_users to the owning team and require named approvers, not groups nobody reads. The audit trail records who approved what, which is the entire point of the gate. Match the timeout name to your version (response_timeout on 3.3+, execution_timeout in 3.1/3.2 examples) and confirm which waiter your install actually enforces.
×

Gates that notify nobody

Symptom
The approval sits in the Airflow UI for hours while approvers work in Slack; the pipeline stalls on a notification gap, not a decision gap.
Fix
Attach notifiers that post the UI response link to Slack and email on gate creation. An approval nobody hears about waits exactly like no approval at all.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
Design a human gate before a destructive migration. What makes it safe v...
Q02SENIOR
Compare the four HITL operators.
Q03JUNIOR
When do you use HITL instead of a sensor?
Q01 of 03SENIOR

Design a human gate before a destructive migration. What makes it safe versus theater?

ANSWER
Place an ApprovalOperator with assigned_users and defaults='Reject' directly before the migration task, with the migration body rendered in the gate so approvers review the exact SQL. Notifiers push the UI link to Slack; response_timeout bounds the wait; the audit trail records the decider. The incident's unattended DDL becomes a request that cannot execute without a named approval.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
Which HITL operator do I use for a deploy approval?
02
How do I restrict who can approve?
03
Does a waiting approval burn a worker slot?
04
What happens if the approver never responds?
05
How do approvals satisfy auditors?
N
Naren Founder & Principal Engineer

20+ years shipping production infrastructure and CI/CD at scale. Lessons pulled from things that broke in production.

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

That's Airflow. Mark it forged?

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

Previous
Airflow Object Storage Workflows
35 / 37 · Airflow
Next
Airflow vs Prefect vs Dagster