Upstream Failed and Skipped

Concepts covered: Task instance states

Every task so far waited for the one above it. The rule behind that waiting is the default rule for every task: a cell moves to scheduled only when every cell it depends on has ended success. The rule has a second half. When an upstream cell ends failed, the condition can never be met, so the scheduler does not leave the dependent cell waiting for the rest of the night: it writes upstream_failed straight into it, without ever sending it to a worker. That cell is now final, so the cells that depend on it get the same treatment, and the state walks down the chain until it reaches a task with nothing below it.

The daily_report DAG below has 2 tasks feeding a third. pull_sales and pull_returns both run when the run appears, because neither depends on the other. build_report depends on both, and send_report depends on build_report. When pull_returns raises with no retries, the scheduler writes upstream_failed on build_report the moment it sees the red cell, then on send_report. pull_sales keeps running and ends success, because its own upstream condition has nothing to do with the failure.

Spreading follows the arrows and nothing else. Add an archive_sales task that depends on pull_sales alone, and on the night pull_returns raises it still runs and ends success, because none of the cells above it failed. The run still ends failed, since 1 of its cells is failed, but the work that never needed the returns file is done. The arrows you draw decide how much of a bad night survives: a task wired below every other task loses whenever any of them fails.

Skipped spreads the same way

A task can also end without failing and without doing anything. pull_returns reads a returns file that the finance system produces only on days with returns. On a day with none, the task raises AirflowSkipException, and the worker reports skipped. Under the same default rule, skipped is a final state other than success, so build_report becomes skipped as well, and then send_report. The shape of the spread is identical to a failure. The meaning is the opposite: nothing went wrong, and there was nothing to do.

from airflow.exceptions import AirflowSkipException
from airflow.sdk import task


@task
def pull_returns():
    if not returns_file_exists():
        raise AirflowSkipException("no returns file today")
    copy_returns_file()
from datetime import datetime
from airflow.sdk import dag, task

@dag(
    schedule="@daily",
    start_date=datetime(2026, 3, 1),
    catchup=False,
)
def daily_report():
    @task
    def pull_sales(): ...
    @task
    def pull_returns(): ...
    @task
    def build_report(): ...
    @task
    def send_report(): ...
    pulls = [pull_sales(), pull_returns()]
    pulls >> build_report() >> send_report()

daily_report()
  • pull_returns raises
  • pull_returns skips
pull_returns ends first, by a raise or by a skip, and the 2 cells below it take their state from it while pull_sales runs on.

What the run says afterwards

The 2 grids look alike and the runs end differently. On the left, 1 cell is failed, so the run ends failed, the DAG's run list shows a red run, and anyone watching for failed runs hears about it. On the right, no cell is failed: the run ends success with 3 grey cells inside it. A report that was never built and never sent sits under a green run. That is correct by the rule, and it is why a skipped chain deserves a check of its own, because the run state will never raise it.

failed
  • The code raised and no tries were left
  • Spreads down as upstream_failed
  • The run ends failed
  • Fix the cause, then clear the cell
skipped
  • The code chose to stop by raising AirflowSkipException
  • Spreads down as skipped
  • The run ends success
  • Check it was meant, then leave it

Reading a run with a wall of orange or grey cells, the cells that spread tell you nothing about the cause, because no worker touched them. Walk up the dependencies from any upstream_failed cell until you reach a failed one; that cell had a try, and its log holds the error. Retries belong to that cell as well: a cell in upstream_failed never had a try, so retries on it has nothing to count.

The default rule can be changed per task, so a task can run after a failure above it or after a skip. A later lesson covers those rules; everything in this lesson assumes the default.

About this lesson

This section is part of the Task Lifecycle: Beginner lesson on DataDriven, the home of data engineering. Lessons cover SQL, Python, Spark, data modeling and pipeline architecture, and each section pairs its explanation with worked examples and practice you run as you go.