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.