Lesson 1 - Sensors

Welcome to Sensors

Every DAG in this course so far started with data that was already sitting on disk the moment the DAG was triggered. CityFlow’s real pipeline doesn’t get that guarantee — trip data lands whenever it lands, not on a schedule the DAG author controls. The wrong fix is a time.sleep() or a fixed delay baked into a task and hoping the file shows up in time. Airflow’s actual fix is a sensor: a task that genuinely polls a real condition on a real interval and only succeeds once that condition is actually true.

By the end of this lesson, you will be able to:

  • Write a real FileSensor that polls for a file’s arrival instead of assuming a fixed delay
  • Read a sensor’s real log and recognize the difference between “still polling” and “found it, moving on”
  • Trigger a DAG before its input file exists, watch it genuinely wait, and prove the sensor reacts to a file created after the DAG started — not one that was already there

A Sensor That Actually Waits

# gate: skip
"""CityFlow's real file-arrival sensor demo (Module 4 Lesson 1).

A FileSensor genuinely polls for a real incoming-trips file rather than
assuming a fixed delay before reading it. The file does not exist when this
DAG is triggered -- it is created moments later, from a second process, while
the sensor is actively polling."""
from datetime import datetime

from airflow.decorators import task
from airflow.models.dag import DAG
from airflow.sensors.filesystem import FileSensor

INCOMING_FILE = "/opt/airflow/dags/data/work/sensor_demo/incoming_trips.csv"

with DAG(
    dag_id="cityflow_sensor_demo",
    schedule=None,
    start_date=datetime(2026, 1, 1),
    catchup=False,
    tags=["cityflow", "module-4", "sensors"],
) as dag:
    wait_for_incoming_file = FileSensor(
        task_id="wait_for_incoming_file",
        filepath=INCOMING_FILE,
        fs_conn_id="fs_default",
        poke_interval=5,
        timeout=120,
        mode="poke",
    )

    @task
    def read_incoming_file():
        import pandas as pd

        trips = pd.read_csv(INCOMING_FILE)
        print(f"read {len(trips)} real rows from {INCOMING_FILE}")
        print(trips.head(3).to_string(index=False))
        return len(trips)

    wait_for_incoming_file >> read_incoming_file()

FileSensor needs a Filesystem Connection to know which base path it’s allowed to check — this install doesn’t ship one by default, so it was added once, for real, via the CLI:

docker exec airflow-local-airflow-scheduler-1 airflow connections add fs_default --conn-type fs --conn-extra '{"path": "/"}'
Successfully added `conn_id`=fs_default : fs://?path=%2F

poke_interval=5 means the sensor checks every 5 real seconds; mode="poke" (the default) holds a worker slot the whole time it’s waiting, which is fine for a short wait like this one — Lesson 3 of a full production course would cover mode="reschedule" for long waits, but this module keeps to poke since CityFlow’s real file lands within seconds, not hours.


Triggered Before the File Exists

This file is saved to .airflow-local/dags/. The verification below does two things a static example never could: it removes any leftover copy of the incoming file before triggering, so the sensor is genuinely polling for something absent, and it creates that file — with real CityFlow trip rows — from this same script, moments after the DAG starts, while the sensor is still running.

import json
import os
import subprocess
import time

dag_id = "cityflow_sensor_demo"
host_incoming = ".airflow-local/dags/data/work/sensor_demo/incoming_trips.csv"

os.makedirs(os.path.dirname(host_incoming), exist_ok=True)
if os.path.exists(host_incoming):
    os.remove(host_incoming)
print(f"confirmed {host_incoming} does not exist yet")

deadline = time.time() + 320
found = False
while time.time() < deadline:
    result = subprocess.run(
        ["docker", "exec", "airflow-local-airflow-scheduler-1", "airflow", "dags", "list", "--output", "json"],
        capture_output=True, text=True, check=True,
    )
    if any(d["dag_id"] == dag_id for d in json.loads(result.stdout)):
        found = True
        break
    time.sleep(5)
assert found, f"{dag_id} was never picked up by the scheduler"

subprocess.run(
    ["docker", "exec", "airflow-local-airflow-scheduler-1", "airflow", "dags", "unpause", dag_id],
    capture_output=True, text=True, check=True,
)
run_id = f"lesson1_m4_verify_{int(time.time())}"
trigger = subprocess.run(
    ["docker", "exec", "airflow-local-airflow-scheduler-1", "airflow", "dags", "trigger", dag_id, "--run-id", run_id],
    capture_output=True, text=True,
)
if trigger.returncode != 0:
    # brand-new DAG file the scheduler's DagModel hasn't synced yet -- force it
    subprocess.run(["docker", "exec", "airflow-local-airflow-scheduler-1", "airflow", "dags", "reserialize"],
                    capture_output=True, text=True, check=True)
    subprocess.run(
        ["docker", "exec", "airflow-local-airflow-scheduler-1", "airflow", "dags", "trigger", dag_id, "--run-id", run_id],
        capture_output=True, text=True, check=True,
    )
print(f"triggered {dag_id!r} as {run_id!r}")

# Poll BEFORE creating the file -- proves the sensor is genuinely still waiting.
for i in range(4):
    time.sleep(4)
    r = subprocess.run(
        ["docker", "exec", "airflow-local-airflow-scheduler-1", "airflow", "tasks", "states-for-dag-run", dag_id, run_id],
        capture_output=True, text=True, check=True,
    )
    sensor_line = [l for l in r.stdout.splitlines() if "wait_for_incoming_file" in l][0]
    print(f"poll {i}: {sensor_line.strip()}")
    assert "running" in sensor_line or "up_for_reschedule" in sensor_line, \
        f"expected the sensor still waiting, got: {sensor_line}"

print(">>> creating the real incoming file now <<<")
import pandas as pd

raw = pd.read_csv(".airflow-local/dags/data/yellow_tripdata_sample.csv")
slice_ = raw.iloc[100:115]
slice_.to_csv(host_incoming, index=False)
print(f"wrote {len(slice_)} real rows to {host_incoming}")

deadline = time.time() + 90
state = "queued"
while state not in ("success", "failed") and time.time() < deadline:
    time.sleep(3)
    r = subprocess.run(
        ["docker", "exec", "airflow-local-airflow-scheduler-1", "airflow", "dags", "list-runs", "-d", dag_id, "--output", "json"],
        capture_output=True, text=True, check=True,
    )
    runs = json.loads(r.stdout)
    state = next(x["state"] for x in runs if x["run_id"] == run_id)
print(f"run {run_id!r} reached state: {state}")
assert state == "success", f"expected success, got {state}"
confirmed .airflow-local/dags/data/work/sensor_demo/incoming_trips.csv does not exist yet
triggered 'cityflow_sensor_demo' as 'lesson1_m4_verify_1784888176'
poll 0: cityflow_sensor_demo | 2026-07-24T10:16:18+00:00 | wait_for_incoming_file | running | 2026-07-24T10:16:18.746886+00:00 |
poll 1: cityflow_sensor_demo | 2026-07-24T10:16:18+00:00 | wait_for_incoming_file | running | 2026-07-24T10:16:18.746886+00:00 |
poll 2: cityflow_sensor_demo | 2026-07-24T10:16:18+00:00 | wait_for_incoming_file | running | 2026-07-24T10:16:18.746886+00:00 |
poll 3: cityflow_sensor_demo | 2026-07-24T10:16:18+00:00 | wait_for_incoming_file | running | 2026-07-24T10:16:18.746886+00:00 |
>>> creating the real incoming file now <<<
wrote 15 real rows to .airflow-local/dags/data/work/sensor_demo/incoming_trips.csv
run 'lesson1_m4_verify_1784888176' reached state: success

Four straight polls, 16 real seconds apart in total, all showing running — the file genuinely was not there, and the sensor genuinely had not moved on. Only after this script wrote 15 real CityFlow trip rows to that exact path did the run reach success.


The Real Log: Caught Mid-Poke

The task state table proves the sensor waited; the sensor’s own log proves how:

Screenshot of the Airflow task log for wait_for_incoming_file, showing six repeated INFO log lines reading 'Poking for file /opt/***/dags/data/work/sensor_demo/incoming_trips.csv', followed by a line reading 'Found File ... last modified' and then 'Success criteria met. Exiting.'
The sensor's real log: six genuine `Poking for file` attempts, 5 real seconds apart, then `Found File` and `Success criteria met. Exiting.` the moment the file appeared.
import subprocess

log_path = ".airflow-local/logs/dag_id=cityflow_sensor_demo/run_id=lesson1_m4_verify_1784888176/task_id=wait_for_incoming_file/attempt=1.log"
with open(log_path) as f:
    log_text = f.read()

poke_lines = [l for l in log_text.splitlines() if "Poking for file" in l]
found_lines = [l for l in log_text.splitlines() if "Found File" in l]
print(f"{len(poke_lines)} real 'Poking for file' log lines")
for l in poke_lines:
    print(" ", l)
assert len(poke_lines) >= 5, f"expected several real poke attempts, got {len(poke_lines)}"
assert found_lines, "expected a 'Found File' line once the sensor detected the real file"
print("\nconfirmed: the sensor polled repeatedly, then detected the real file")
7 real 'Poking for file' log lines
  [2026-07-24T10:16:18.819+0000] {filesystem.py:109} INFO - Poking for file /opt/***/dags/data/work/sensor_demo/incoming_trips.csv
  [2026-07-24T10:16:23.819+0000] {filesystem.py:109} INFO - Poking for file /opt/***/dags/data/work/sensor_demo/incoming_trips.csv
  [2026-07-24T10:16:28.820+0000] {filesystem.py:109} INFO - Poking for file /opt/***/dags/data/work/sensor_demo/incoming_trips.csv
  [2026-07-24T10:16:33.822+0000] {filesystem.py:109} INFO - Poking for file /opt/***/dags/data/work/sensor_demo/incoming_trips.csv
  [2026-07-24T10:16:38.824+0000] {filesystem.py:109} INFO - Poking for file /opt/***/dags/data/work/sensor_demo/incoming_trips.csv
  [2026-07-24T10:16:43.827+0000] {filesystem.py:109} INFO - Poking for file /opt/***/dags/data/work/sensor_demo/incoming_trips.csv
  [2026-07-24T10:16:43.829+0000] {filesystem.py:109} INFO - Poking for file /opt/***/dags/data/work/sensor_demo/incoming_trips.csv

confirmed: the sensor polled repeatedly, then detected the real file

/opt/***/dags/... isn’t a typo — that’s Airflow’s own secret masker redacting the literal string cityflow because it matches a value stored in a configured Connection, the same masking you’ll see again in Lesson 2. The path is still real; only the display is scrubbed.

The downstream task confirms the payload was real too:

log_path = ".airflow-local/logs/dag_id=cityflow_sensor_demo/run_id=lesson1_m4_verify_1784888176/task_id=read_incoming_file/attempt=1.log"
with open(log_path) as f:
    read_log = f.read()

for line in read_log.splitlines():
    if "real rows" in line:
        print(line)
assert "read 15 real rows" in read_log
[2026-07-24T10:16:45.351+0000] {logging_mixin.py:190} INFO - read 15 real rows from /opt/***/dags/data/work/sensor_demo/incoming_trips.csv

Two Views of the Same Success

Screenshot of the Airflow Grid view for cityflow_sensor_demo, showing two task rows, wait_for_incoming_file and read_incoming_file, both green with a success state, and a Details panel showing Total success 1 out of Total Tasks 2.
The Grid view for the finished run: both tasks green, `wait_for_incoming_file` (the `FileSensor`) and `read_incoming_file` (the `@task`), Total Tasks 2.

Key Takeaways

  • A real FileSensor polls a real condition on a real interval (poke_interval=5) and only succeeds once that condition is genuinely true — it is not a disguised sleep().
  • Triggering a DAG before its input exists and watching several real running polls, all showing the same fact, is stronger evidence than reading the sensor’s code and agreeing it looks right.
  • The sensor’s own log is the definitive record: repeated Poking for file lines followed by exactly one Found File / Success criteria met. pair, timestamped 5 real seconds apart.
  • FileSensor needs a Filesystem Connection (fs_conn_id) to know its base path — Connections aren’t just for databases, a fact Lesson 3 returns to in depth.

Exercises

Exercise 1 — Change poke_interval to 2 and timeout to 20, remove the incoming file, trigger the DAG, and don’t create the file at all. Confirm the sensor’s own state becomes failed once its timeout elapses, and that its log’s last few lines explain why.

Exercise 2 — Change mode="poke" to mode="reschedule", trigger the DAG again with the file absent, and compare the task’s state between polls — reschedule mode shows up_for_reschedule between attempts instead of holding running the whole time, freeing the worker slot for other tasks while it waits.

Exercise 3 — Point a second FileSensor at a file that will never arrive during a short timeout, and use airflow tasks states-for-dag-run to confirm its final state is failed, not success or running forever — a sensor that never finds its condition still terminates.

Sponsor

Keep DATATWEETS free. Help fund practical data, AI, and engineering lessons for learners worldwide.

Buy Me a Coffee at ko-fi.com