Every task run knows which date it belongs to. Airflow exposes that date as template variables such as ds, and as arguments you can ask for in a Python task. Building file names and queries from them, instead of from datetime.now(), makes a task repeatable: a rerun next week processes the same date as the original run.

Copy and paste

In a templated field (bash_command, sql, html_content and the other fields an operator lists in template_fields):

from airflow.providers.standard.operators.bash import BashOperator

export = BashOperator(
    task_id="export",
    bash_command="python export.py --date {{ ds }} --file sales_{{ ds_nodash }}.csv",
)

In a TaskFlow task, name the arguments after the variables:

from airflow.sdk import task

@task
def export(ds=None, logical_date=None):
    print(f"Exporting {ds}")      # 2026-05-15
    print(logical_date.year)      # 2026: logical_date is a pendulum DateTime

Anywhere in task code:

from airflow.sdk import get_current_context

context = get_current_context()
run_date = context["ds"]

Other formats:

"{{ logical_date.strftime('%Y/%m/%d') }}"               # 2026/05/15
"{{ macros.ds_format(ds, '%Y-%m-%d', '%d/%m/%Y') }}"    # 15/05/2026
"{{ macros.ds_add(ds, 7) }}"                            # 2026-05-22
"{{ data_interval_end | ds_nodash }}"                   # 20260515

What each variable contains

The values for the run scheduled at 00:00 UTC on 15 May 2026, in a DAG with schedule="0 0 * * *":

Variable Value
ds 2026-05-15
ds_nodash 20260515
ts 2026-05-15T00:00:00+00:00
ts_nodash 20260515T000000
logical_date 2026-05-15 00:00:00+00:00
data_interval_start 2026-05-15 00:00:00+00:00
data_interval_end 2026-05-15 00:00:00+00:00
run_id scheduled__2026-05-15T00:00:00+00:00

ds, ds_nodash, ts and ts_nodash are formats of logical_date. In Airflow 3 a cron schedule creates runs without a data interval, so its start and end are both the moment the run was scheduled, and every variable in the table points at the same instant.

In Airflow 2 the same run had ds = 2026-05-14 and an interval from 14 to 15 May. Why your DAG processes yesterday’s data explains the difference and how to keep the old behaviour.

Templates work only in templated fields

Airflow renders Jinja only in the operator arguments listed in template_fields. For the BashOperator those are bash_command, env and cwd; for the PythonOperator, op_args, op_kwargs and templates_dict. The arguments passed to a TaskFlow task are rendered too, so export(run_date="{{ ds }}") receives the date.

A string inside your Python code is not rendered. This prints the braces as they are:

@task
def report():
    print("Report for {{ ds }}")   # prints: Report for {{ ds }}

In Python, take the value from an argument or from get_current_context() instead.

Runs without a logical date

Runs triggered by an asset, and manual runs created without a logical date (for example airflow dags trigger my_dag with no --logical-date), have no ds, ts or logical_date. A template that uses them fails:

jinja2.exceptions.UndefinedError: 'ds' is undefined

In DAGs that can start that way, use {{ run_id }} or {{ dag_run.run_after }}, which every run has.

The date is in UTC

ds is the date in UTC. A DAG that runs at 00:30 in Rome starts at 22:30 UTC on the previous day, so its ds is the previous day. For the local date, write {{ logical_date.in_timezone('Europe/Rome') | ds }}. See how to schedule a DAG in your timezone.

Tested with Airflow 3.3.2.

References