Time Zones and Daylight Saving

Concepts covered: Schedules and timetables

A start_date of pendulum.datetime(2026, 3, 1, tz="Europe/Berlin") gives the DAG a time zone. A bare cron string reads its hours in that zone; a timetable class reads them in the timezone you pass it. Inside Airflow every instant is stored in UTC, so the run named 06:00 Berlin on 28 March has the id scheduled__2026-03-28T05:00:00+00:00, and the UI shows its times in whichever zone the page is set to.

On 29 March 2026, Berlin moves from CET, UTC+1, to CEST, UTC+2, at 02:00. A cron at 06:00 local keeps firing at 06:00 local, which is 05:00 UTC up to the 28th and 04:00 UTC from the 29th. The window that closes on the 29th opened at 05:00 UTC on the 28th and closed at 04:00 UTC on the 29th: 23 hours. On 25 October the clocks go back at 03:00, and the window closing that morning runs 25 hours. Every other window of the year is 24 hours. A daily sum over the 23-hour window is short by an hour's worth of rows, and a per-hour rate that divides the day's total by 24 is wrong twice a year.

06:00 local oninstant in UTCrun id of the run appearing thenwindow closing here
28 Mar 2026, CET05:00scheduled__2026-03-27T05:00:00+00:0024 hours
29 Mar 2026, CEST04:00scheduled__2026-03-28T05:00:00+00:0023 hours
30 Mar 2026, CEST04:00scheduled__2026-03-29T04:00:00+00:0024 hours
25 Oct 2026, CET05:00scheduled__2026-10-24T04:00:00+00:0025 hours
from airflow.sdk import task


@task
def window_hours(data_interval_start=None, data_interval_end=None):
    seconds = (data_interval_end - data_interval_start).total_seconds()
    return seconds / 3600

The window fields carry the length, so a task that divides a total by window_hours instead of by 24 gives the true hours of that day: 23 or 25 on the 2 change days, 24 on all the others. The run ids in the table show the other effect: the UTC instant inside the id moves from 05:00 to 04:00 on the 29th, so anything that parses run ids and expects a fixed hour breaks on that morning, while anything that reads the window keeps working.

What the grid shows across the change

With the UI set to Berlin time, the column headers read 06:00 on every day and the only trace of the change is the shorter bar for the 28th's window. With the UI set to UTC, the headers read 05:00 up to 27 March and 04:00 from 29 March, and the 28th reads 05:00 because that run is named for the instant its window opened, before the clocks moved. Both views describe the same 4 runs; reading the one your teammates use saves an argument about whether the schedule slipped. A timedelta(days=1) schedule in the same DAG does something else entirely: it keeps a fixed 24 hours, so after the 29th it fires at 07:00 Berlin time, which section 4 takes up.

The hour that exists twice

Between 02:00 and 03:00 on 29 March there is no Berlin time at all; between 02:00 and 03:00 on 25 October every Berlin time occurs twice. A cron inside that hour names an instant the zone either lacks or names twice, and what happens to that run depends on the cron library's rules rather than on anything in your file. Keep schedules in a DST zone outside 02:00 to 03:00. A timezone of UTC removes the question: UTC has no clock change and every window is 24 hours. The price is that a 06:00 UTC schedule reads 07:00 on a Berlin clock in winter and 08:00 in summer, which is fine for a warehouse load and wrong for a report promised at 06:00 local.

Cron in a local zone
  • Fires at the same local time all year
  • One 23-hour and one 25-hour window a year
  • UTC instant in run ids shifts twice a year
  • Right when the business defines the day locally
Cron in UTC
  • Fires at the same UTC instant all year
  • Every window is 24 hours
  • Run ids keep one hour all year
  • Right when downstream wants equal windows
import pendulum
from airflow.sdk import DAG, task
from airflow.timetables.interval import (
    CronDataIntervalTimetable)

ZONE = "Europe/Berlin"
with DAG(
    dag_id="berlin_daily",
    start_date=pendulum.datetime(
        2026, 3, 25, tz=ZONE),
    catchup=True,
    schedule=CronDataIntervalTimetable(
        "0 6 * * *", timezone=ZONE),
):
    @task
    def total_sales():
        pass

    total_sales()
  • Berlin zone
  • UTC zone
The 06:00 Berlin cron keeps its local hour across 29 March, so one window is 23 hours; the UTC variant stays at 24.

About this lesson

This section is part of the Scheduling: Intermediate 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.