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?