This page is for data engineers, analytics engineers and platform engineers facing an Airflow round. Most interviews start with DAGs, operators, sensors and hooks, then test how scheduling really works: data intervals, catchup, backfills and cross-DAG dependencies. Next come XCom, templating, retries, timeouts and alerts, then executors, pools and why tasks get stuck. Senior rounds add dynamic DAGs, a failed-midnight-run scenario and stories from real production work. Each question shows what the interviewer is checking, the shape of a strong answer and a short answer to say out loud. Airflow changes between major versions, so check details against the version you use.
Search all questions by round, difficulty and level, or save the ones you want to practise.
Definition: a directed acyclic graph of tasks, defined in a Python file, with a schedule and default settings.
Directed: edges say which task must finish before the next one can start.
Acyclic: no loops, so the scheduler can always work out a valid run order and know when a run is finished.
"A DAG is a directed acyclic graph, and in Airflow it's the workflow itself: a Python file that declares a set of tasks, how they depend on each other, when the whole thing should run, and shared settings like retries. Directed means each edge has a direction, so task B only starts once task A has done its part. Acyclic means you can never follow the arrows and end up back where you started. That rule is what makes scheduling possible. If extract depended on load and load depended on extract, the scheduler would have no task it could start first, and a run could never finish. Airflow checks for cycles when it parses the file and refuses a DAG that has one. If I genuinely need to repeat something, I rerun the DAG on its next schedule or loop inside a single task, not across tasks."
Describing a DAG as a script that runs top to bottom, or not being able to say what goes wrong if there's a cycle.
Scheduler: decides which DAG runs and task instances are due and hands ready tasks to the executor.
DAG parsing: reads the DAG files and stores their structure; newer versions run it as its own DAG processor.
Metadata database: the source of truth for runs, task states, connections and variables.
Executor and workers: the executor decides how tasks run, workers actually run them; the web UI shows it all.
"There are a few moving parts. The DAG files are parsed on a loop, either by the scheduler or, in newer versions, by a separate DAG processor, and the parsed structure goes into the metadata database. The scheduler reads that, creates DAG runs when they're due, and works out which task instances have their dependencies met. It hands those to the executor, which decides how they run: as local processes, through a queue to Celery workers, or as pods on Kubernetes. Workers run the task code and report the state back; in Airflow 3 they do that through an API server rather than writing to the database directly. The metadata database is the heart of it, holding every run, task state, connection and variable. The web UI reads the same database so I can see runs, logs and clear tasks. If I use deferrable operators there's also a triggerer, which waits on many async events cheaply."
Mixing up the scheduler and the executor, or saying the scheduler runs the task code itself.
Parsing loop: DAG files are re-parsed every short interval, not once per run.
Cost: top-level calls run on every parse, slow parsing down and can hit the parse timeout.
Fix: keep top-level code to building the graph; do real work inside tasks and use templates for variables.
"Airflow doesn't read a DAG file once and remember it. It re-parses every file on a loop, every few seconds to a minute depending on settings, to pick up changes. So any code at the top level, outside a task, runs on every single parse. If that code queries a database or calls an API, I'm hitting that system thousands of times a day even when nothing is scheduled, and parsing gets slow. Slow files delay scheduling for every DAG, and if a file takes too long it hits the import timeout, shows up as a broken DAG, and my changes stop being picked up. The same goes for Variable.get at the top level and heavy imports like big ML libraries. My rule is that top-level code only builds the graph. Anything that talks to the outside world goes inside a task, and variables come in through templates so they're read at run time."
Thinking a DAG file only executes when the DAG runs, so top-level code is harmless.
Wiring: use the bitshift operators or chain; a list fans out or fans in.
Default rule: all_success, so a task runs only when every direct upstream task succeeded.
Other rules: all_done for cleanup, one_failed for alerts, none_failed_min_one_success after a branch.
"I set dependencies with the bitshift operators, so extract >> transform >> load, and a list on either side fans out or fans in. For long straight lines I use chain. Each task then has a trigger rule that decides when it may start, and the default is all_success: every direct upstream task must have succeeded. I change it in a few cases. A cleanup task that drops temp tables should run whatever happened, so it gets all_done. An alert task that should only fire when something breaks gets one_failed. The common trap is branching. After a branch operator, the path not taken is skipped, and with the default rule the join task would be skipped too. So the join gets none_failed_min_one_success, which means run if nothing failed and at least one branch actually ran."
extract >> transform >> [load_orders, load_customers] >> publish
cleanup = PythonOperator(
task_id="cleanup_temp",
python_callable=drop_temp_tables,
trigger_rule="all_done",
)
publish >> cleanup
Not knowing the default is all_success, or being surprised that the task after a branch gets skipped.
Decorators: @dag and @task turn plain Python functions into a DAG and its tasks.
Data passing: a return value is pushed to XCom, and passing it into another task pulls it and sets the dependency.
Mixing: classic operators still work alongside, and their output can feed a TaskFlow task.
"TaskFlow lets me write tasks as normal Python functions with a @task decorator, and the DAG itself with @dag. The big difference is how data and dependencies flow. With classic operators, I'd create a PythonOperator, push a value to XCom, pull it in the next task by task id, and set the order separately with >>. With TaskFlow, a function's return value is pushed to XCom automatically, and when I pass that return value into another task function, Airflow pulls it for me and adds the dependency. So the code reads like ordinary Python. It's still XCom underneath, so the same rule applies: return small things like a count or a file path, not a big dataset. And I can mix styles freely, for example a SQL operator followed by a TaskFlow task that checks its result."
import pendulum
from airflow.decorators import dag, task
@dag(schedule="@daily", start_date=pendulum.datetime(2024, 1, 1, tz="UTC"), catchup=False)
def daily_orders():
@task
def extract() -> str:
return "raw/orders.csv"
@task
def load(path: str) -> int:
print(f"loading {path}")
return 42
load(extract())
daily_orders()
Thinking TaskFlow passes data in memory between tasks, when it's still XCom stored between separate processes.
Import test: load every DAG file in CI and fail on any import error or cycle.
Logic tests: keep business logic in plain functions and unit test them without Airflow.
Run tests: run one task or a whole DAG locally, then deploy to a staging environment first.
"I test at three levels. First, a cheap check in CI that loads every DAG file, the same way the scheduler would, and fails if there's any import error, a cycle, or a DAG with no owner or no retries. That alone catches most broken deploys. Second, I keep the real logic in plain Python functions or modules that don't depend on Airflow, so I can unit test them with normal test data. Third, I actually run things. The tasks test command runs one task for a given date without recording state, and dag.test runs a whole DAG in a single process, which is great for debugging in an IDE. Before production, changes go to a staging Airflow pointed at test connections. I also add policy checks, for example that every DAG sets catchup explicitly, so risky defaults never slip through."
from airflow.models import DagBag
def test_no_import_errors():
bag = DagBag(dag_folder="dags/", include_examples=False)
assert bag.import_errors == {}
def test_every_dag_has_retries():
bag = DagBag(dag_folder="dags/", include_examples=False)
for dag_id, dag in bag.dags.items():
assert dag.default_args.get("retries", 0) >= 1, dag_id
Saying the only test is deploying and watching the first run.
Operator: a template for one unit of work; each instance in a DAG is a task.
Sensor: a special operator that waits until a condition is true, like a file arriving.
Hook: a reusable client for an external system, built on a connection, used inside operators and tasks.
"An operator is a template for one kind of work, like running a SQL statement, a bash command or a Python function. When I use one in a DAG with a task id, that instance becomes a task. A sensor is a kind of operator whose only job is to wait: it checks a condition, like a file landing in storage or a partition existing, and succeeds when it's true or fails after its timeout. A hook is lower level. It's the client that knows how to talk to an external system, like a database or a storage bucket, and it gets its host and credentials from an Airflow connection. Operators use hooks internally, and I use hooks directly inside my own Python tasks when no operator does quite what I need. So the hook talks to the system, the operator does the work, and the sensor waits."
Treating hooks and operators as the same thing, or not knowing that a hook gets its credentials from a connection.
Poke: the sensor holds a worker slot the whole time and sleeps between checks.
Reschedule: it checks, frees the slot, and gets rescheduled for the next check.
Deferrable: it hands the wait to the triggerer, which runs many async waits in one process, and resumes when the event fires.
"In poke mode the sensor starts on a worker and stays there, checking, sleeping for the poke interval, and checking again. For six hours that's a worker slot doing nothing. If twenty DAGs do that, my workers are full of sleeping sensors and real tasks queue behind them. Reschedule mode fixes the slot problem: the sensor checks once, and if the file isn't there it ends that attempt, goes into up_for_reschedule and frees the slot until the next check. That's good for long waits with a check every few minutes. A deferrable sensor goes further. It defers itself to the triggerer, which runs lightweight async triggers for many tasks in one process, and the task only comes back to a worker when the event fires. For a six-hour wait I'd use a deferrable sensor if one exists, otherwise reschedule, with a sensible timeout and an alert."
Leaving long sensors in poke mode without seeing that each one blocks a worker slot for the entire wait.
Subclass: inherit from BaseOperator and put the work in execute(self, context).
Hook: get the database client from a hook and a connection id, never hard-coded credentials.
Templates: list fields in template_fields so values like the run date can come from Jinja.
"I subclass BaseOperator and put the logic in execute, which Airflow calls with the run's context when the task runs. The constructor only stores arguments and passes the rest to the parent, so things like retries and task_id still work. For the database, I use the provider's hook with a connection id, so credentials live in the Airflow connection, not in my code. I add table and partition_date to template_fields, which means someone can pass the run date as a Jinja template and Airflow renders it just before execute. Inside, I run a count with a bound parameter. If the count is below the minimum, I raise an exception, which fails the task and triggers retries or alerts. I return the count, which goes to XCom in case a later task wants it. I keep execute small so the logic is easy to unit test."
from airflow.models import BaseOperator
from airflow.providers.postgres.hooks.postgres import PostgresHook
class RowCountCheckOperator(BaseOperator):
template_fields = ("table", "partition_date")
def __init__(self, table, partition_date, conn_id="warehouse", min_rows=1, **kwargs):
super().__init__(**kwargs)
self.table = table
self.partition_date = partition_date
self.conn_id = conn_id
self.min_rows = min_rows
def execute(self, context):
hook = PostgresHook(postgres_conn_id=self.conn_id)
# table comes from DAG code, never from user input
sql = f"SELECT COUNT(*) FROM {self.table} WHERE load_date = %s"
count = hook.get_first(sql, parameters=(self.partition_date,))[0]
if count < self.min_rows:
raise ValueError(f"{self.table} has {count} rows for {self.partition_date}")
return count
# check = RowCountCheckOperator(task_id="check_orders", table="orders", partition_date="{{ ds }}")
Doing the database work in __init__, which runs on every DAG parse, instead of in execute.
Connections: hooks read host and credentials by connection id; store them in a secrets backend, env vars or the metadata database.
Secrets backend: a vault or cloud secret manager keeps credentials out of Airflow's own database.
Variables: read them at run time through templates or inside tasks, never at the top level.
"Credentials never go in DAG files. Every hook takes a connection id, and the connection holds the host, login, password and extras. Where the connection lives is a deployment choice. It can be in the metadata database, which encrypts the password field with a key, or as an environment variable, but in production I prefer a secrets backend, like a vault or the cloud provider's secret manager, so secrets are rotated and audited in one place and Airflow just looks them up by name. Variables are for non-secret config like a bucket name. The trap is reading them at the top of a DAG file, which hits the database on every parse. I read them inside tasks or through templates like var.value.bucket_name, which only resolve at run time. Airflow also masks values that look sensitive in logs, but I don't rely on that alone."
Hard-coding passwords in the DAG or keeping them in plain variables and calling that secure.
Purpose: a key-value store for small messages between tasks, like a file path or a row count.
Storage: by default it lives in the metadata database, tied to the DAG run and task.
Limits: pass references, not datasets; use a custom backend or object storage for anything large.
"XCom, short for cross-communication, is how tasks pass small values to each other. Each task runs in its own process, maybe on a different machine, so they can't share memory. A task pushes a value, for example by returning it, and a later task pulls it by task id and key. By default those values are serialised and stored in the metadata database, attached to that run. That's why I only pass small things: a file path, a list of partition names, a row count, a status. I never pass a DataFrame or a full query result, because it bloats the database that the whole scheduler depends on, and there are size limits that depend on the database. For big data, the task writes it to storage and passes the path. If a team really needs larger XComs, you can set up a custom backend that stores them in object storage."
Saying you pass whole datasets between tasks with XCom.
When: Jinja is rendered on the worker right before the task runs, using that run's context.
Where: only in an operator's template_fields and in template files like .sql it knows about.
Macros: ds, data_interval_start and data_interval_end, params and var give run-specific values.
"Airflow renders Jinja templates just before a task runs, using that run's context. So in a SQL operator I can write WHERE order_date = '{{ ds }}' and each run gets its own date, which is what makes reruns and backfills load the right slice. The key thing is that only fields listed in the operator's template_fields get rendered, plus template files with extensions it recognises, like a .sql file. If I put a Jinja string into a field that isn't templated, it arrives as literal braces. Inside a Python task, I don't use Jinja at all; I read the same values from the context, like data_interval_start. Useful values are ds for the date, data_interval_start and data_interval_end for the exact window, params for user settings, and var for variables. One catch: on Airflow 3 a plain cron schedule gives a zero-length interval by default, so for window queries like this one I make sure the DAG uses the interval timetable."
-- dags/sql/daily_orders.sql, rendered for each run
SELECT store_id, SUM(amount) AS revenue
FROM raw_orders
WHERE created_at >= '{{ data_interval_start }}'
AND created_at < '{{ data_interval_end }}'
GROUP BY store_id;
Using the system clock, like datetime.now(), inside a task to decide which data to load.
Airflow 2 default: a cron schedule uses data intervals, and a run starts only when its window closes.
First run there: just after midnight on January 2, with logical date January 1, the start of the window.
Airflow 3 default: cron schedules fire at their time, so the first run is at midnight on January 1, the logical date is the fire time and the interval has zero length.
"It depends on the timetable, and the default changed between versions. In Airflow 2, a cron schedule like daily uses data intervals: each run covers a window and only starts once that window has closed, because that's when a full day of data exists. So the first interval is January 1 midnight to January 2 midnight, the run starts just after midnight on January 2, and its logical date, the ds macro, is January 1. That's why people say Airflow runs a day late, but the run processes the day it's named after. Airflow 3 switched cron schedules to a trigger-style timetable by default. There the first run fires at midnight on January 1, its logical date is that fire time, and the data interval has zero length. So on Airflow 3, a daily job that loads yesterday either asks for the previous day itself or sets the interval timetable explicitly. I always check which one a project uses before reasoning about dates."
Saying the first run happens on January 1 and processes January 1 with no mention of data intervals, or not knowing the timetable decides it.
Catchup: when on, the scheduler creates a run for every missed interval since the start date or the last run.
Backfill: a deliberate request to run a DAG over a chosen date range, even with catchup off.
Habit: set catchup explicitly on every DAG, because the default is on in Airflow 2 and off in Airflow 3.
"Catchup is a DAG setting. When it's on and the scheduler sees intervals that have no run, say because the DAG was paused for a week or the start date is in the past, it creates a run for every one of those intervals. When it's off, it only schedules the most recent interval and moves on. A backfill is different: it's something I ask for. I pick a date range and Airflow creates runs for it, regardless of the catchup setting. In Airflow 2 that was a CLI command; Airflow 3 also lets you start one from the UI or the API, and the scheduler runs it. I use backfills after fixing a bug or adding a new DAG that needs history. Because catchup is on by default in Airflow 2 and off in Airflow 3, I always set it explicitly, and I keep max_active_runs low so a big catch-up doesn't flood the workers."
Not knowing that catchup can silently create hundreds of runs when you unpause or deploy a DAG.
Data-aware schedule: the producer declares a dataset or asset it updates, and my DAG is scheduled on it.
Sensor: an external task sensor waits for a specific task in the other DAG for a matching logical date.
Push: the producer triggers my DAG directly with a trigger operator.
"There are three common ways. My first choice is data-aware scheduling. The producer's load task declares that it updates a dataset, called an asset in newer versions, identified by a URI like the orders table, and my DAG uses that dataset as its schedule. When the producer task succeeds, my DAG runs. It's loosely coupled: they don't need to know each other's DAG ids or schedules. The second option is an external task sensor in my DAG that waits for a specific task in theirs. It works, but it's tied to their task id and assumes the logical dates line up, so if schedules differ I have to map the dates, and a renamed task breaks my DAG silently. The third is having their DAG trigger mine directly, which puts the dependency in their code. One caution with datasets: they fire when the task succeeds, not when data actually changed, so I still validate the data."
Solving it with a fixed time gap, like scheduling one DAG two hours after the other and hoping.
Problem: a retry after a partial write, or a later clear, inserts the same rows again.
Scope: tie the task to its run's window with templates, never to the current clock.
Rewrite: delete and insert that window in one transaction, overwrite the partition, or MERGE on a key.
"A plain INSERT isn't idempotent. If the task fails after writing, the retry inserts the same rows again. If someone clears the task next week, or runs a backfill, it adds another copy. And if the query picks yesterday using the current date, a rerun next week loads the wrong day entirely. I'd fix two things. First, scope the work to the run's own window using ds or the data interval, so any run always touches exactly its own day. Second, make the write replace that day instead of adding to it: delete the day's rows and insert them again inside one transaction, or overwrite the partition in a lake table, or use a MERGE keyed on the order id. Then I can retry, clear or backfill any date and the table ends up exactly the same."
-- ds is the day this run covers (interval timetable)
BEGIN;
DELETE FROM sales_daily
WHERE order_date = '{{ ds }}';
INSERT INTO sales_daily (order_date, store_id, revenue)
SELECT order_date, store_id, SUM(amount)
FROM raw_orders
WHERE order_date = '{{ ds }}'
GROUP BY order_date, store_id;
COMMIT;
Using CURRENT_DATE or datetime.now() to pick the day, which breaks every rerun and backfill.
Settings: retries, retry_delay, exponential backoff and a max delay, usually in default_args.
Time limit: execution_timeout so a hung call fails instead of blocking a slot for hours.
Judgement: retry transient errors; fail fast on bad input or bad credentials.
"I set retry behaviour in default_args so every task in the DAG gets it, and override it per task when needed. For a flaky API I'd give it a few retries with a retry delay of a few minutes, turn on exponential backoff so each wait is longer than the last, and cap it with max_retry_delay so it doesn't wait forever. I also set execution_timeout, because a request that hangs is worse than one that fails: it holds a worker slot and never triggers the retry. Retries only help with transient problems like timeouts or rate limits. If the API says my credentials are wrong or the payload is invalid, retrying just delays the alert, so for those I raise an exception that fails the task immediately without retrying. And because the task may run more than once, it has to be safe to repeat."
from datetime import timedelta
default_args = {
"owner": "data-platform",
"retries": 3,
"retry_delay": timedelta(minutes=5),
"retry_exponential_backoff": True,
"max_retry_delay": timedelta(minutes=30),
"execution_timeout": timedelta(hours=1),
}
Setting a large number of retries on everything so failures never show, without any timeout.
execution_timeout: caps one attempt of one task; hitting it fails that attempt, and retries still apply.
Sensor timeout: caps the total time a sensor waits for its condition; hitting it fails the sensor without retrying.
dagrun_timeout: caps how long a whole DAG run may stay running before it's marked failed.
"They work at different levels. execution_timeout is per task attempt. If one attempt runs longer, Airflow kills it and marks that attempt failed, and normal retries kick in, so it's my protection against a hung query or API call. A sensor's timeout is about the condition, not one attempt: it's the total time the sensor is allowed to wait for, say, a file to arrive. When that's exceeded, the sensor fails and doesn't use its retries, because waiting again wouldn't make the file appear. That's different from execution_timeout on a sensor, which would limit a single attempt. dagrun_timeout sits on the DAG and limits how long a whole run can stay running before it's marked failed, which also frees a slot when max_active_runs is low. I usually set all three: tight per task, realistic for sensors, and a DAG-level ceiling as a safety net."
Treating the three as interchangeable, or expecting a timed-out sensor to retry automatically.
Failure hooks: on_failure_callback or a notifier for failures and final retries.
Lateness in Airflow: Airflow 2 has a task-level sla setting; Airflow 3 removed it, and deadline alerts take its place in later 3.x releases.
Outside check: a separate freshness check on the output, so a dead scheduler still raises an alarm.
"Failure alerts are the easy part: on_failure_callback, or a notifier, posts to chat or pages whoever is on call. Lateness is harder, because nothing fails. Inside Airflow 2 there's an sla argument on tasks with an SLA miss callback, but it had quirks and Airflow 3 removed it, with deadline alerts replacing it in later releases, so I check the version before relying on either. The bigger gap is that any alert that lives in Airflow depends on Airflow working. If the scheduler is down, no run starts, and no callback ever fires. So for important pipelines I add a check from outside: a small monitor that looks at the output, for example the newest date in the target table, and alerts if it's older than expected by a set time. I also monitor the scheduler's heartbeat. That way I hear about late data whatever the cause."
Relying only on failure emails and never considering that a run might simply not start.
Local: tasks run as processes on the scheduler's machine; simple, limited to one box.
Celery: tasks go through a queue to a pool of long-running workers; fast start, needs a broker and shared dependencies.
Kubernetes: each task gets its own pod; strong isolation and per-task resources, slower start.
"The executor decides how a task instance gets run. The Local executor runs tasks as separate processes on the same machine as the scheduler. It's simple and fine for a small team, but it can't scale past one box. The Celery executor puts tasks on a message queue, usually Redis or RabbitMQ, and a pool of always-on workers picks them up. Tasks start fast, I can add workers, and I can route tasks to named queues, like a queue of big-memory workers. The downside is running the broker and keeping every worker's dependencies in sync. The Kubernetes executor launches a fresh pod for each task. That gives isolation, per-task images and resources, and nothing running when idle, but each task pays pod start-up time. So for many short tasks I lean Celery, and for mixed, heavy or dependency-clashing workloads, Kubernetes. Newer versions also let you run more than one executor together."
Saying Kubernetes is always better, without mentioning pod start-up cost or the work of running a cluster.
Pool: create a pool with a fixed number of slots and assign every task that hits that database to it.
DAG limits: max_active_runs and max_active_tasks cap how much one DAG does at once.
Priority: priority_weight decides which waiting task gets the next free slot.
"The tool for a shared resource is a pool. I'd create a pool for that database with, say, four slots, agreed with the DBA, and put every task that queries it into that pool, across all five DAGs. Then no matter how many runs are active, at most four of those queries run at once, and the rest wait in the scheduled state until a slot frees up. A task that's especially heavy can take more than one slot with pool_slots. I'd also look at the DAG-level limits: max_active_runs so a backfill doesn't start thirty days in parallel, and max_active_tasks to cap tasks per DAG. If some queries matter more, like the one feeding a morning report, I give them a higher priority_weight so they get slots first. Finally I'd check the queries themselves, because sometimes the real fix is one bad full-table scan."
Answering only with bigger database hardware or fewer DAGs, without knowing pools exist.
Which state: stuck in scheduled points at Airflow's own limits; stuck in queued points at the executor or workers.
Airflow limits: global parallelism, pool slots, max_active_tasks and max_active_runs.
Executor side: worker count and concurrency, queue names, broker health, or pods that can't be placed.
"First I check which state they're stuck in, because it tells me where to look. Scheduled means the scheduler knows the task is ready but hasn't handed it to the executor, usually because of a limit: the global parallelism setting, a full pool, or the DAG's max_active_tasks. So I check pools in the UI and count running tasks. Queued means it has been sent to the executor but no worker has started it. With Celery, I check that workers are alive, that their concurrency isn't used up by long sensors in poke mode, and that some worker actually listens on the queue the task was sent to, which is a classic mistake. I also check the broker. With Kubernetes, I look for pods stuck pending because the cluster has no room or can't pull the image. Finally I check scheduler health: its heartbeat, CPU and how long DAG parsing takes."
Restarting the scheduler first and hoping, without checking pools, limits or worker health.
List: one task finds the files at run time and returns their paths.
Expand: the processing task is mapped with expand, so Airflow creates one instance per path.
Reduce: a downstream task receives all the mapped results; a concurrency limit protects the systems involved.
"This is exactly what dynamic task mapping is for. The first task runs at run time, lists the files for that day, and returns a list of paths. Then I call expand on the processing task with that list, and Airflow creates one mapped task instance per path, all in the same run, each with its own logs and retries. So if one file fails, only that instance retries. A final task takes the mapped results and gets them as a list, so it can add up counts or send a summary. I'd put a limit on how many mapped instances run at once so a day with five hundred files doesn't swamp the workers. The nice part compared to older tricks is that the number of tasks isn't fixed when the DAG is parsed; it's decided by the data each run."
import pendulum
from airflow.decorators import dag, task
@dag(schedule="@daily", start_date=pendulum.datetime(2024, 1, 1, tz="UTC"), catchup=False)
def process_new_files():
@task
def list_files() -> list[str]:
# real code: list the day's keys with a storage hook
return ["in/a.csv", "in/b.csv", "in/c.csv"]
@task(max_active_tis_per_dag=4)
def process(path: str) -> int:
print(f"processing {path}")
return 1
@task
def summarize(counts):
print(f"processed {sum(counts)} files")
summarize(process.expand(path=list_files()))
process_new_files()
Looping over files at the top level of the DAG file, which lists storage on every parse.
Build: one Python file loops over a config file shipped with the code and creates a DAG per entry with a stable dag_id.
Risks: slow parsing, one bad entry breaking all fifty, orphaned history when ids change.
Alternative: if only the data varies inside one run, dynamic task mapping may be the better fit.
"I'd write one generator file reading a YAML or JSON config deployed with the code, loops over the entries, and builds a DAG for each with a factory function, with a dag_id like ingest_ plus the source name. Each DAG has to be registered where the parser finds it, so I build it in a with block or put it in globals(). Then I'd guard against the known risks. The config must be a local file, not a database or API call, because it's read on every parse. I'd validate the config in CI so one typo doesn't turn the whole file into an import error and take all fifty DAGs down together. dag_ids must never change, because a renamed DAG loses its run history and starts again from its start date, possibly with catchup. And removing an entry leaves an old DAG in the UI that someone has to clean up. If the fifty sources really share one schedule, I'd ask whether one DAG with mapped tasks is simpler."
Reading the config from a live database at parse time, or generating dag_ids that change when the config is reordered.
Situation: which DAG, how often it failed, what it cost the people using the data.
Evidence: logs across many failed runs, timing, what the failures had in common.
Fix and follow-through: the real fix, what you changed to catch it sooner, and the result.
"At my last company we had a nightly DAG that failed maybe twice a week at the load step, and the usual fix was someone clearing the task in the morning. Retries had been bumped up, which only hid it. I pulled the logs from a month of failed runs and noticed they all failed around the same few minutes, and always with a connection error. That lined up with the warehouse's maintenance window, and on busy nights our run slipped into it because an upstream sensor released late. So the cause wasn't the code, it was timing. I moved the heavy step off that window, added a pool so our loads couldn't open too many connections at once, and made the load a partition overwrite so retries were safe. Failures stopped, and I added a dashboard of task durations so drift like that shows up before it breaks."
A story where the fix was just more retries, with no idea why it failed.
Scope: what was changing and why, how many DAGs and teams were affected.
Safety: inventory of deprecated features, a parallel environment, CI checks, a rollback plan.
Rollout: the order you moved things, how you handled schedules and catchup, and what you learned.
"In my last role I led moving about a hundred and twenty DAGs from an old self-managed Airflow to a newer version on a managed platform. First I built an inventory by running every DAG file through the new version's parser in CI and listing the import errors and deprecation warnings: old import paths, the execution_date name, a couple of custom plugins. Owners fixed their DAGs against that list. We ran both environments side by side, with the new one reading from staging connections, and compared outputs for a week. For the cutover, the dangerous part was scheduling: a DAG that appears in a new environment with catchup on will try to run its whole history. So every DAG went across paused with catchup set explicitly, and we unpaused team by team while the old copy was paused. We kept the old deployment for two weeks as a rollback. The only incident was one missed connection, which the run check caught."
Describing a big-bang switch over a weekend with no parallel run, no inventory and no rollback.
Problem: what kept going wrong across the team's DAGs.
Standard: a short set of rules, a template, and checks in CI so the rules enforce themselves.
Adoption: how you brought people along and what changed afterwards.
"At my last company, about a dozen engineers wrote DAGs and the same mistakes kept coming back: no retries, catchup left to the default, API calls at the top of files, and INSERTs that duplicated data on retry. Instead of catching these one by one in review, I wrote a one-page guide and a starter DAG template with sensible default_args, catchup set explicitly, and an idempotent write pattern. Then I added CI checks that loaded every DAG and failed on import errors, missing owners, missing retries and slow parse times. I walked the team through it in a short session and fixed the three worst existing DAGs myself as examples. People followed it mostly because the template was the easiest place to start. Over the next quarter, duplicate-row incidents stopped and review comments were about logic, not the same basics."
A story about rules that were only written down and never checked, or about fixing everyone's DAGs yourself.
Triage: which task failed, its logs, and whether the cause is transient, code, data or upstream.
Safe rerun: check what was partly written, confirm the tasks are idempotent, then clear the failed task and its downstream.
Communicate: if it won't be fixed by 8, tell the dashboard owners early and mark the data as stale.
"First I look at which task failed and read its log from the last attempt, not the first. Then I sort the cause: a timeout or connection error is probably transient; a schema or key error means the data changed upstream; a code error means a recent deploy. I also check if the source even delivered. Before I rerun anything, I check what the failed task wrote. If the tasks overwrite their partition, I can safely clear the failed task with its downstream tasks and let them run. If not, I clean up the partial data first. If the source is late or broken, I contact whoever owns it and wait rather than load half a day. By about 6, if I'm not confident it'll be fixed, I message the finance owners so they know the numbers are stale. I never mark the task as success to turn the dashboard green. Afterwards, I write up what happened and fix the root cause."
Marking the failed task as success to unblock downstream tasks, or rerunning everything without checking what was partly written.
Stop: pause the DAG right away so no new runs or tasks start.
Assess: see which runs already ran, what they wrote or sent, and whether that data is wrong.
Clean up and prevent: remove or fail the unwanted runs, fix catchup and the start date, add a CI check.
"First I pause the DAG, which stops new task instances starting, and if the warehouse is struggling I'd also check with its admins whether to cancel running queries. Then I assess. Which runs actually completed, and what did they do? If the tasks only overwrite their own day's partition, old runs may have done little harm. If they appended, sent emails or called external systems, I need a list of affected dates to clean up or to warn people about. Next, I deal with the queued runs, by marking them failed or deleting them in bulk from the UI, so they don't all start when I unpause. Then I fix the DAG: catchup off, a realistic start date, and a max_active_runs limit. If some history really is needed, I run a planned backfill later, in small batches. Finally I'd add a CI rule that every DAG sets catchup explicitly."
Unpausing again after deleting a few runs, or leaving it paused without checking what the finished runs already wrote.
Principle: Airflow schedules and coordinates; heavy compute belongs in a warehouse, a Spark job or its own container.
Risk: big pandas jobs eat worker memory, get killed, and slow every other DAG sharing those workers.
Pragmatism: small data in a Python task is fine; agree a size threshold and a path for bigger jobs.
"I'd start by asking how big the data is and how much it'll grow, because for a few hundred megabytes a Python task is perfectly fine and simpler. But for large files I'd push back. Airflow is best as the orchestrator: it decides when work runs, retries it and tracks it, while the heavy lifting happens somewhere built for it. Workers are shared, so a pandas job that needs lots of memory can get killed mid-run, and it slows down every other team's tasks on the same worker. So I'd suggest either doing the transform in the warehouse with SQL, which Airflow triggers with an operator, or running it in a separate container or Spark job with its own resources, which Airflow launches and waits for. I'd frame it as a team guideline with a rough size threshold, not a blanket ban, so the choice is easy next time."
Either saying it's always fine because it works on a laptop, or banning Python tasks outright with no reason.
ClapAssist is an AI interview assistant for Mac and Windows. It listens to the interview on your computer and shows you what to say, in short lines you can read while you talk. Your resume and notes are never stored on our servers. It stays out of screen share on every plan; only you can see it.