Sign in to save your place.

MODULE 6 OF 15

Templating & Context

Make your DAGs smart with Jinja templates, macros, and dynamic variables!

Why Templating?

Imagine you have a daily ETL job that loads data for "today." Without templating, you'd have to hardcode the date in your DAG — and every single day, you'd need to change it manually. That's a recipe for disaster!

Explain Like I'm 5

Hardcoded values are like writing your birthdate on a form and handing it to everyone in your class. "Born on March 15, 2010" — that only works for one person! Templating is like having a blank line where each person fills in their own date. In Airflow, the "blank" gets filled automatically with the execution date — and it changes every single run!

Hardcoded

bash_command='echo "2024-01-15"' — Always prints the same date. Useless for a daily job!

Dynamic (Templated)

bash_command='echo "{{ ds }}"' — Prints the execution date. Jan 15, Jan 16, Jan 17... automatically!

Real-World Example

A retail company runs a daily pipeline to load sales data from s3://bucket/sales/2024-01-15.csv. With templating: s3://bucket/sales/{{ ds }}.csv — Airflow substitutes the correct date for each run. No manual edits, no mistakes, no "wrong date" bugs!

What is Jinja2?

Jinja2 is a template engine used by Python (and Airflow). It lets you embed placeholders like {{ variable }} in strings. When the task runs, Airflow replaces those placeholders with actual values.

Explain Like I'm 5

Jinja is like a fill-in-the-blank worksheet. You write: "Hello, _____! Today is _____." The teacher (Airflow) fills in the blanks: "Hello, Alice! Today is Monday." Next time: "Hello, Bob! Today is Tuesday." Same template, different fill-ins!

Like Mail Merge in Word

Remember mail merge? You create one letter: "Dear {{ first_name }}, your order {{ order_id }} will arrive on {{ delivery_date }}." Then you merge with a spreadsheet, and Word generates 100 personalized letters. Airflow does the same thing for your DAGs — one template, infinite personalized executions!

Template Variables in Airflow

Airflow provides a set of built-in template variables you can use inside template_fields. These are available automatically when a task runs.

VariableDescriptionExample Output
{{ ds }}Execution date as string (YYYY-MM-DD)2024-01-15
{{ ds_nodash }}Same date, no dashes (for file paths)20240115
{{ data_interval_start }}Start of the data interval2024-01-15 00:00:00+00:00
{{ data_interval_end }}End of the data interval2024-01-16 00:00:00+00:00
{{ ts }}Timestamp (ISO format)2024-01-15T00:00:00+00:00
{{ params.key }}User-defined DAG paramsWhatever you passed in params

Pro Tip

ds_nodash is perfect for S3 paths, e.g. s3://bucket/data/{{ ds_nodash }}/ → s3://bucket/data/20240115/. Some systems don't like dashes in paths!

Templating Operator Arguments

Not every operator argument supports templating! Only arguments that are in the operator's template_fields can use {{ ... }}.

Which Fields Are Templated?

Each operator defines which of its arguments get rendered with Jinja. Check the operator docs or the source code for template_fields.

  • BashOperator: bash_command is templated. You can use {{ ds }} directly in the command!
  • PythonOperator: templates_dict is templated — a dict whose values are rendered with Jinja before being passed to the callable. The callable itself receives context via **kwargs or ti (TaskInstance).
  • PythonOperator: For dynamic values, use op_kwargs with pre-computed values, or access kwargs['ds'], kwargs['logical_date'], etc. inside your callable.

Rule of Thumb

If an argument is in template_fields, you can put {{ ds }} and friends in it. If not, templating won't work — you'll see the literal {{ ds }} in your output!

Templating the PythonOperator

The PythonOperator is special: its python_callable runs Python code, so you get execution context directly. You don't need Jinja for everything!

Using op_kwargs with Template Strings

You can pass templated values via op_kwargs if the operator supports it. More commonly, you pass the context and pull values inside the function.

Accessing Context via **kwargs or ti

Your Python callable can accept **kwargs or named args like ti (TaskInstance), execution_date, logical_date, etc. Airflow injects these automatically!

Old Style vs New Style

provide_context=True (old): Explicitly passed context. Deprecated in Airflow 2.x.

New style: Just use **kwargs in your callable. Airflow always passes context. Access kwargs['ds'], kwargs['logical_date'], kwargs['params'], etc.

Macros

Airflow provides macros — pre-built functions you can use inside Jinja templates. They live in the macros namespace.

Common Macros

  • macros.ds_add(ds, days) — Add days to a date string. Example: {{ macros.ds_add(ds, -1) }} gives yesterday.
  • macros.ds_format(ds, input_fmt, output_fmt) — Reformats a date. Example: {{ macros.ds_format(ds, '%Y-%m-%d', '%Y/%m/%d') }}

Custom Macros via user_defined_macros

You can add your own macros when defining the DAG: user_defined_macros={'my_func': my_func}. Then use {{ my_func(ds) }} in templates!

Jinja Filters

Jinja filters transform values. Use the pipe | followed by the filter name.

  • {{ value | default('fallback') }} — Use 'fallback' if value is undefined or empty.
  • {{ value | int }} — Convert to integer.
  • {{ value | upper }} — Uppercase string.
  • {{ value | truncate(10) }} — Truncate to 10 chars.

Inspecting Rendered Templates in the UI

After a task runs (or when you hover in the UI), you can see the rendered template — what the command actually looked like after substitution. This is incredibly useful for debugging!

Where to Find It

In the Airflow UI: open a task instance → click "Rendered" tab (or "Task Instance Details" and look for rendered template). You'll see the final bash_command or other templated fields with all {{ ... }} replaced by real values.

Common Pitfalls

  • Quoting: In BashOperator, use single quotes for the outer string so {{ ds }} isn't interpreted by the shell. Use double quotes for inner strings if needed.
  • Nested templates: Don't over-complicate. {{ "{{ ds }}" }} won't work the way you expect. Keep templates simple.
  • Debugging: When templating fails silently, check the Rendered tab. Often the issue is a typo in the variable name (e.g. {{ ds }} vs {{ date }}).

Template Rendering Pipeline

How a template flows from your DAG code to the actual executed command:

DAG Code'echo {{ ds }}' Airflow RendersJinja2 Engine Substituted'echo 2024-01-15' Bash ExecutesOutput: 2024-01-15

Template rendering flow: DAG → Jinja → Substitution → Execution

Variable Substitution Flow

{{ ds }} → 2024-01-15 {{ ds_nodash }} → 20240115

Variable substitution: placeholders become actual values at runtime

Rendered Template Inspection UI

Where to See Rendered Output

Task Instance → Rendered tab shows the final rendered template.

Graph View → Hover over a task → tooltip may show key info.

Grid View → Click task → Task Instance Details → Rendered tab.

BashOperator with Templates

from airflow import DAG
from airflow.operators.bash import BashOperator
from datetime import datetime

with DAG(
    dag_id="templating_bash_dag",
    start_date=datetime(2024, 1, 1),
    schedule="@daily",
    catchup=False,
) as dag:

    # bash_command is a template_field - use {{ ds }} directly!
    echo_date = BashOperator(
        task_id="echo_date",
        bash_command='echo "Execution date: {{ ds }}"',
    )

    # Build a dynamic path (e.g. for S3 or local files)
    list_files = BashOperator(
        task_id="list_files",
        bash_command='echo "Path: s3://bucket/data/{{ ds_nodash }}/"',
    )

PythonOperator with Context

from airflow import DAG
from airflow.operators.python import PythonOperator
from datetime import datetime

def print_context(**kwargs):
    ds = kwargs.get("ds")
    logical_date = kwargs.get("logical_date")
    ti = kwargs.get("ti")
    params = kwargs.get("params", {})
    print(f"Execution date: {ds}")
    print(f"Logical date: {logical_date}")
    print(f"Params: {params}")

with DAG(
    dag_id="templating_python_dag",
    start_date=datetime(2024, 1, 1),
    schedule="@daily",
    catchup=False,
    params={"env": "production"},
) as dag:

    python_task = PythonOperator(
        task_id="print_context",
        python_callable=print_context,
    )

Macros Example

from airflow import DAG
from airflow.operators.bash import BashOperator
from datetime import datetime

with DAG(
    dag_id="macros_dag",
    start_date=datetime(2024, 1, 1),
    schedule="@daily",
    catchup=False,
) as dag:

    # macros.ds_add: yesterday = ds + (-1) days
    echo_yesterday = BashOperator(
        task_id="echo_yesterday",
        bash_command='echo "Yesterday: {{ macros.ds_add(ds, -1) }}"',
    )

Custom Macros & Params

from airflow import DAG
from airflow.operators.bash import BashOperator
from datetime import datetime

def custom_format(ds):
    return ds.replace("-", "/")

with DAG(
    dag_id="custom_macros_dag",
    start_date=datetime(2024, 1, 1),
    schedule="@daily",
    catchup=False,
    user_defined_macros={"my_format": custom_format},
    params={"bucket": "my-bucket"},
) as dag:

    task = BashOperator(
        task_id="task",
        bash_command='echo "Path: s3://{{ params.bucket }}/{{ my_format(ds) }}"',
    )

Practice Exercises

Exercise 1: BashOperator with {{ ds }}

Challenge

Write a BashOperator that echoes the execution date in the format: "Processing data for YYYY-MM-DD". Use the {{ ds }} variable. Trigger the DAG and verify the Rendered tab shows the correct date.

Hint

Use single quotes for the outer bash_command string so the shell doesn't interpret {{ ds }}. Example: bash_command='echo "Processing data for {{ ds }}"'

Exercise 2: PythonOperator with **kwargs

Challenge

Create a PythonOperator callable that accepts **kwargs, extracts ds and data_interval_end, and prints them. Verify they match the execution date and interval.

Exercise 3: Use macros.ds_add

Challenge

Write a BashOperator that prints "Yesterday: YYYY-MM-DD" using {{ macros.ds_add(ds, -1) }}. Then try macros.ds_add(ds, 7) to get "One week from execution date".

Exercise 4: Params in a Template

Challenge

Define a DAG with params={'bucket': 'my-data-bucket', 'prefix': 'raw'}. In a BashOperator, build and echo the full path: s3://{{ params.bucket }}/{{ params.prefix }}/{{ ds_nodash }}/.

Module 6 Quiz

Test your understanding! Click on the answer you think is correct.

1. What does {{ ds }} render to?

2. Which BashOperator argument is templated?

3. How do you access execution date in a PythonOperator callable?

4. What does macros.ds_add(ds, -1) return?

5. Where can you inspect the rendered template in Airflow?

6. How do you pass custom parameters for use in templates?

7. What is ds_nodash useful for?

8. Which PythonOperator argument supports Jinja templating?

October 2026 practice

Checked against Apache Airflow 3.3.2, the stable release dated 17 September 2026. New DAG files import from airflow.sdk. Dataset was renamed to Asset. @task is the default for Python. PythonOperator still runs, and it is the migration path from older DAGs.

Jinja still renders fields listed in template_fields before the task runs. In Airflow 3, prefer logical_date, ds, data_interval_start, and data_interval_end. execution_date is the old context name. Do not pass a huge payload through a template. User-controlled template strings are code: do not render raw request text into a Bash command.

# rendered before the task starts
bash_command='echo {{ ds }} {{ data_interval_start }}'

Knowledge check

Which template names match Airflow 3?