Templating
Templates let WoowTech bend its output to whatever you need, reading live data from your system and turning it into dynamic text and values.
A taste of what templates do
A template is a small piece of logic embedded in your configuration. Rather than printing fixed text, it inspects your system and produces a result that fits the moment. A notification, for example, can read where a person actually is and phrase itself accordingly — "Sandra is home" versus "Sandra is at the office."
action: notify.send_message
target:
entity_id: notify.my_device
data:
message: >
{% if is_state('device_tracker.sandra', 'home') %}
Sandra is home.
{% else %}
Sandra is at {{ states('device_tracker.sandra') }}.
{% endif %}
Output: Sandra is at the office.
The bits wrapped in {%…%} and {{…}} are template code that WoowTech evaluates when the configuration runs.
How the syntax works
When WoowTech encounters a template, ordinary text passes through untouched. Special marker pairs — called delimiters — flag the parts that need computing. There are three:
{{ ... }}evaluates an expression and prints its result{% ... %}runs a statement (logic) without printing anything itself{# ... #}is a comment, ignored entirely
{# Greet whoever is at home #}
Hi there, {{ states('person.sandra') }}.
{% if is_state('sun.sun', 'below_horizon') %}
It is dark out.
{% endif %}
Output:
Hi there, home.
It is dark out.
The comment vanishes, the expression is swapped for the person's state, and the conditional decides whether the darkness line appears.
Expressions
Anything inside {{ ... }} that resolves to a value is an expression. Valid ones include:
- Text:
'morning'or"morning" - Numbers:
7,2.71 - Booleans:
true,false - Nothing at all:
none - Lists:
[10, 20, 30]or['garage', 'attic'] - Arithmetic:
8 + 4 - Function calls:
states('sensor.humidity')ornow() - Variables set earlier
Reach into nested data with dots or square brackets:
Reading: {{ states.sensor.patio.state }}
Label: {{ states.sensor.patio.attributes.friendly_name }}
Same one: {{ states.sensor.patio.attributes['friendly_name'] }}
Output:
Reading: 18.3
Label: Patio temperature
Same one: Patio temperature
Dots read cleaner, but brackets are mandatory when a name holds spaces or odd characters. Entity IDs beginning with a digit, such as device_tracker.2008_gmc, also force bracket form: states.device_tracker['2008_gmc'].
Operators
Operators combine or compare values with the usual symbols.
Arithmetic:
Add: {{ 8 + 4 }}
Subtract: {{ 8 - 4 }}
Multiply: {{ 8 * 4 }}
Divide: {{ 8 / 3 }}
Floor div: {{ 8 // 3 }}
Modulo: {{ 8 % 3 }}
Exponent: {{ 8 ** 2 }}
Output:
Add: 12
Subtract: 4
Multiply: 32
Divide: 2.6666666666666665
Floor div: 2
Modulo: 2
Exponent: 64
The less obvious ones: // divides and throws away the remainder, % keeps only the remainder, and ** raises to a power.
Comparison — every one answers True or False:
Equal: {{ 8 == 8 }}
Not equal: {{ 8 != 3 }}
Greater: {{ 8 > 3 }}
At most: {{ 3 <= 3 }}
Output:
Equal: True
Not equal: True
Greater: True
At most: True
Note that == tests whether two things match, while a single = assigns a name.
Logic — and needs both true, or needs one, not flips it, in checks membership:
And: {{ true and false }}
Or: {{ true or false }}
Not: {{ not false }}
In: {{ 'attic' in 'attic fan' }}
Output:
And: False
Or: True
Not: True
In: True
Filters
A filter reshapes a value via the pipe |, read left to right:
Uppercase: {{ 'morning' | upper }}
Rounded: {{ 2.71828 | round(2) }}
Chained: {{ [9, 4, 6] | sort | join(', ') }}
Output:
Uppercase: MORNING
Rounded: 2.72
Chained: 4, 6, 9
WoowTech bundles dozens of filters for conversions, formatting, math, and list work. The template functions reference lists them all.
Most functions double as both a filter and a plain call:
As a call: {{ float(states('sensor.humidity')) }}
As a filter: {{ states('sensor.humidity') | float }}
Output:
As a call: 54.0
As a filter: 54.0
Use the filter form for readable chains; use the call form when you want an explicit fallback like float(value, 0).
Watch precedence: the pipe binds tighter than math.
{{ 8 / 3 | round(2) }}reads as "8 divided by (3 rounded)," not "(8 divided by 3) rounded." Add parentheses:{{ (8 / 3) | round(2) }}gives2.67, while the unparenthesized form gives2.6666666666666665.
Tests
A test is a yes/no question written with is:
Is a number: {{ 7 is number }}
Is text: {{ 'morning' is string }}
Is odd: {{ 7 is odd }}
In a list: {{ 4 is in [2, 4, 6] }}
Output:
Is a number: True
Is text: True
Is odd: True
In a list: True
Use is not for the negative: {{ 7 is not number }} returns False. Tests shine inside if statements.
Trimming whitespace
Templates keep every space and line break, including the ones around {% ... %} tags. That's fine for messages but messy for clean sensor values. Add a - inside a tag to strip whitespace on that side:
{% ... %}strips nothing{%- ... %}strips the left{% ... -%}strips the right{%- ... -%}strips both
The same applies to {{ ... }}. A common cleanup — trimming the trailing line break after each tag:
{% set temp = 24 -%}
{% if temp > 20 -%}
Warm
{% else -%}
Cool
{% endif -%}
outside.
Output:
Warm
outside.
Trim aggressively for sensor values; leave it alone for human-readable messages where blank lines aid readability.
Loops and conditions
if / elif / else
if runs its block only when the condition is true:
{% if is_state('sun.sun', 'above_horizon') %}
The sun is up.
{% else %}
The sun is down.
{% endif %}
Output: The sun is up.
Always close with {% endif %}; {% else %} is optional. Chain alternatives with {% elif %}, evaluated in order until one matches:
{% set temp = states('sensor.patio_temperature') | float(0) %}
{% if temp < 0 %}
Freezing.
{% elif temp < 15 %}
Chilly.
{% elif temp < 25 %}
Pleasant.
{% else %}
Hot.
{% endif %}
Output: Pleasant.
For a quick one-liner, use the inline form:
{% set temp = 24 %}
It is {{ 'warm' if temp > 20 else 'cool' }}.
Output: It is warm.
for loops
A for loop runs its body once per list item:
{% for fan in states.fan %}
{{ fan.name }}: {{ fan.state }}
{% endfor %}
Output:
Attic: on
Bedroom: off
Office: off
Close every loop with {% endfor %}. Filter items inline by tacking if onto the for:
Fans that are on:
{% for fan in states.fan if fan.state == 'on' %}
- {{ fan.name }}
{% endfor %}
Output:
Fans that are on:
- Attic
- Studio
Inside a loop, the loop variable reports position: loop.first, loop.last, loop.index (from 1), loop.index0 (from 0), and loop.length.
{% for person in states.person %}
{{ loop.index }}. {{ person.name }}{% if not loop.last %},{% endif %}
{% endfor %}
Output:
1. Sandra,
2. Diego,
3. Mira
Variables with set
set names a value for reuse:
{% set temp = states('sensor.patio_temperature') | float(0) %}
{% set unit = state_attr('sensor.patio_temperature', 'unit_of_measurement') %}
It is {{ temp | round(1) }} {{ unit }} outside.
Output: It is 18.3 °C outside.
Loop gotcha: a set inside a loop doesn't survive past the loop — it creates a fresh scoped variable each pass.
{% set count = 0 %}
{% for fan in states.fan if fan.state == 'on' %}
{% set count = count + 1 %}
{% endfor %}
{{ count }}
Output: 0
Fix it with a namespace, which persists across iterations:
{% set ns = namespace(count=0) %}
{% for fan in states.fan if fan.state == 'on' %}
{% set ns.count = ns.count + 1 %}
{% endfor %}
{{ ns.count }}
Output: 2
Breaking out early
{% break %} stops the loop; {% continue %} skips to the next item:
{# First three fans that are on #}
{% set ns = namespace(shown=0) %}
{% for fan in states.fan %}
{% if fan.state != 'on' %}
{% continue %}
{% endif %}
{% if ns.shown >= 3 %}
{% break %}
{% endif %}
{{ fan.name }}
{% set ns.shown = ns.shown + 1 %}
{% endfor %}
Output:
Attic
Studio
Workshop
The {% do %} statement runs an expression without printing. Because the template sandbox blocks mutating methods such as .append(), .pop(), and .update(), build up lists and counters with namespaces instead.
Working with states
Look at your states first
Before writing anything, open Settings > Developer tools > States to browse every entity, its current state, and its attributes. A typical entry looks like:
Entity: sensor.patio_temperature
State: 18.3
Attributes:
unit_of_measurement: °C
device_class: temperature
friendly_name: Patio temperature
Each entity has an ID (its lookup name), a state (its main value), and attributes (extra detail). Keep this page open in another tab while you work.
Reading a state
states fetches an entity's current state by ID:
The garage light is {{ states('light.garage') }}.
Output: The garage light is on.
Every state is text. Even numbers come back as strings, so comparisons run alphabetically unless you convert. Always convert before doing math:
{{ states('sensor.patio_temperature') | float(0) + 5 }}
Output: 23.3
The | float(0) converts to a number, with 0 as the fallback if conversion fails. Use | float(0) or | int(0) for any numeric work.
States are capped at 255 characters — store anything longer in attributes, which have no limit. Two special states flag trouble: unknown (the entity exists but its value isn't known) and unavailable (the entity can't be reached). Asking for a nonexistent entity also yields unknown. Handle both gracefully with fallbacks or has_value().
Reading an attribute
state_attr() pulls extra detail, with an optional fallback:
The garage light sits at {{ state_attr('light.garage', 'brightness') | int(0) }}.
Output: The garage light sits at 200.
Checking a state
is_state() compares cleanly even when an entity is missing:
{{ is_state('light.garage', 'on') }}
Output: True
is_state_attr() does the same for attributes:
{{ is_state_attr('media_player.office', 'source', 'Spotify') }}
Output: True
has_value() confirms an entity has a usable state — ideal for friendly fallback messages:
{% if has_value('sensor.patio_temperature') %}
It is {{ states('sensor.patio_temperature') }}°C outside.
{% else %}
The patio sensor is offline.
{% endif %}
Output: It is 18.3°C outside.
Listing entities
states.<domain> returns every entity in a domain:
There are {{ states.fan | count }} fans in total.
Output: There are 9 fans in total.
Narrow it with selectattr():
Fans that are on:
{% for fan in states.fan | selectattr('state', 'eq', 'on') %}
- {{ fan.name }}
{% endfor %}
Output:
Fans that are on:
- Attic
- Studio
- Workshop
Finding entities by area, device, label, or floor
WoowTech offers grouping lookups: area_entities(), device_entities(), label_entities(), floor_entities(), and integration_entities(). Reverse functions also exist (area_devices(), etc.). Example — bedroom lights that are on:
Bedroom lights on:
{% for entity in area_entities('bedroom') %}
{% if entity.startswith('light.') and is_state(entity, 'on') %}
- {{ state_attr(entity, 'friendly_name') }}
{% endif %}
{% endfor %}
Output:
Bedroom lights on:
- Bedroom ceiling
- Reading lamp
The this variable
In template entities, this refers to the entity itself, so it can read its own state without hardcoding its ID:
template:
- sensor:
- name: "Patio helper"
state: "{{ this.attributes.get('tally', 0) + 1 }}"
attributes:
tally: "{{ this.state | int(0) + 1 }}"
this exists only in template entities and certain automation contexts — not in the Developer Tools template editor.
The trigger variable
When an automation fires, a trigger variable carries details about what caused it (field names vary by trigger type):
- trigger: state
entity_id: binary_sensor.front_gate
to: "on"
action:
- action: notify.send_message
target:
entity_id: notify.my_device
data:
message: >
{{ trigger.to_state.name }} opened at
{{ trigger.to_state.last_changed.strftime('%H:%M') }}.
Output: Front gate opened at 14:32.
Types and conversion
The types you'll meet: text (str), integer (int), float, boolean (bool), None, list, dictionary (dict), iterable (a one-pass sequence from filters like map/select), and datetime (from now() or last_changed).
Types matter because operators are picky: math wants numbers, | count wants a list (not an iterable), and </> on text compares alphabetically — so '6' > '10' is True because '6' sorts after '1'.
Every state is text. states('sensor.humidity') returns the string '54', not the number 54:
{{ states('sensor.humidity') | typeof }}
Output: str
That's why | float(0) and | int(0) are everywhere. Compare:
{# Text concatenation, not math #}
{{ states('sensor.a') + states('sensor.b') }}
Output: 54.018.5
{# Real math #}
{{ states('sensor.a') | float(0) + states('sensor.b') | float(0) }}
Output: 72.5
Each type has a converting filter: | float(default), | int(default), | string, | bool(default), | list. The defaults kick in when conversion fails (e.g. an unavailable sensor).
{{ "2.71" | float(0) }}
{{ "nine" | float(0) }}
{{ "42" | int(0) }}
{{ 3.7 | int }}
{{ 1 | string }}
{{ "yes" | bool }}
Output:
2.71
0.0
42
3
1
True
Iterables aren't lists. The filters map, select, reject, selectattr, and rejectattr return a lazy iterable you can't count or index. | count on one errors:
{# Fails #}
{{ states.fan | selectattr('state', 'eq', 'on') | count }}
Output: Error: object of type 'generator' has no len()
Add | list first:
{{ states.fan | selectattr('state', 'eq', 'on') | list | count }}
Output: 3
Pull items from a list with first, last, or bracket indexing (0-based, negatives count from the end):
{% set rooms = ['attic', 'cellar', 'garage'] %}
First: {{ rooms | first }}
Last: {{ rooms | last }}
Index 0: {{ rooms[0] }}
Index 1: {{ rooms[1] }}
Index -1: {{ rooms[-1] }}
Output:
First: attic
Last: garage
Index 0: attic
Index 1: cellar
Index -1: garage
From a dictionary, use bracket or dot notation — but prefer brackets when a key could clash with a dict method like values, keys, or get:
{% set response = {'status': 'ok', 'values': [1, 2, 3]} %}
{{ response['values'] }}
Output: [1, 2, 3]
Inspect a type with typeof, or test it inside an if:
{{ 42 is number }}
{{ "hello" is string }}
{{ [1, 2, 3] is iterable }}
{{ {"a": 1} is mapping }}
{{ None is none }}
Output:
True
True
True
True
True
Dates and times
now() gives the current local time; utcnow() gives UTC. Templates using either re-run once a minute — base faster updates on state changes instead.
Local: {{ now() }}
UTC: {{ utcnow() }}
Output:
Local: 2026-04-04 14:30:00.123456+02:00
UTC: 2026-04-04 12:30:00.123456+00:00
today_at('22:00') returns a specific time today — handy for "past bedtime?" checks. Pull out parts with dot notation:
Year: {{ now().year }}
Month: {{ now().month }}
Day: {{ now().day }}
Hour: {{ now().hour }}
Weekday: {{ now().weekday() }}
weekday() runs 0 (Monday) to 6 (Sunday); isoweekday() runs 1 to 7.
Format with strftime:
24-hour: {{ now().strftime('%H:%M') }}
12-hour: {{ now().strftime('%I:%M %p') }}
Weekday: {{ now().strftime('%A') }}
Long date: {{ now().strftime('%A, %B %-d, %Y') }}
Output:
24-hour: 14:30
12-hour: 02:30 PM
Weekday: Saturday
Long date: Saturday, April 4, 2026
Common codes: %Y four-digit year, %m padded month, %d padded day, %-d day without leading zero, %H 24-hour, %M minute, %S second, %I 12-hour, %p AM/PM, %A/%a full/short weekday, %B/%b full/short month.
Parse text back into a datetime with strptime:
{% set event = strptime('2026-12-25 10:30', '%Y-%m-%d %H:%M') %}
{{ event }}
Output: 2026-12-25 10:30:00
Convert UNIX timestamps with timestamp_local, timestamp_utc, or timestamp_custom; go the other way with as_timestamp. as_datetime is a forgiving parser:
{% set ts = 1710510600 %}
Local: {{ ts | timestamp_local }}
Custom: {{ ts | timestamp_custom('%H:%M on %B %d') }}
Back: {{ as_timestamp(now()) | int }}
Subtracting two datetimes yields a timedelta. To find how long ago something happened:
{% set since = now() - states.binary_sensor.front_gate.last_changed %}
Total minutes: {{ (since.total_seconds() / 60) | int }}
Output: Total minutes: 15
For human-readable spans use time_since and time_until:
{{ time_since(states.binary_sensor.front_gate.last_changed) }} ago
Output: 15 minutes ago
Trigger an alert when something has lingered too long:
{{ (now() - states.binary_sensor.front_gate.last_changed).total_seconds() > 600 }}
Output: True (once the gate has held its state over 10 minutes).
Time gotchas: comparing date-like text with
</>sorts alphabetically, not chronologically — convert to datetimes first.timestamp_customis for UNIX timestamps; for datetimes call.strftime(...)directly. Subtraction returns a timedelta, so call.total_seconds()for a number. Around daylight-saving changes prefertoday_at('HH:MM')over.replace().
Templates inside YAML
Most templates live in YAML files, which have their own quoting and multi-line rules that interact with templates in ways that trip everyone up at least once.
A single-line template must be quoted (single or double, no behavioral difference):
value_template: "{{ states('sensor.temp') | float > 20 }}"
Without quotes, YAML mistakes {{ for a flow-style mapping and fails. If the template contains quotes, wrap it in the other kind:
value_template: "{{ is_state('light.kitchen', 'on') }}"
value_template: '{{ states["sensor.temp"] }}'
For longer templates, use a multi-line block. The folded > style joins lines with a space (good for one long expression split for readability); the literal | style keeps line breaks (good for multi-line messages):
value_template: >
{{
states('sensor.temp') | float(0) > 20
and states('sensor.humidity') | float(0) < 60
}}
message: |
Today's summary:
Temperature: {{ states('sensor.temp') }}°C
Humidity: {{ states('sensor.humidity') }}%
A trailing - (>- or |-) strips the final newline — common for value_template:
value_template: >-
{% if states('sensor.outdoor_temp') | float(0) < 0 %}
freezing
{% else %}
not freezing
{% endif %}
Inside a block, YAML takes the indent from the first non-empty line; every later line must indent at least as far, or YAML cuts the block off and treats the rest as a new key.
Frequent slip-ups: forgetting quotes on a single-line template, opening a quote and never closing it, using > when you need line breaks preserved, and mixing indents inside |/> blocks.
Custom templates and macros
Reusable template snippets and macros can be shared across your configuration so you don't repeat yourself. See the custom templates documentation for the full mechanism.
Where to go next
- Reference: WoowTech offers hundreds of template functions, filters, and tests — browse them by category in the template functions reference.
- Debugging: experiment and troubleshoot live in the template editor under Developer tools > Template — hands-on trial is the fastest way to learn.
- Patterns: ready-made recipes for common jobs live on the common template patterns page.
- Tutorials: two walkthroughs put it together — building a daily low-battery notification, and a template sensor that averages every temperature sensor in your home.
Related topics
Templating
Templates let WoowTech bend its output to whatever you need, reading live data from your system and turning it into dynamic text and values.
A taste of what templates do
A template is a small piece of logic embedded in your configuration. Rather than printing fixed text, it inspects your system and produces a result that fits the moment. A notification, for example, can read where a person actually is and phrase itself accordingly — "Sandra is home" versus "Sandra is at the office."
action: notify.send_message
target:
entity_id: notify.my_device
data:
message: >
{% if is_state('device_tracker.sandra', 'home') %}
Sandra is home.
{% else %}
Sandra is at {{ states('device_tracker.sandra') }}.
{% endif %}
Output: Sandra is at the office.
The bits wrapped in {%…%} and {{…}} are template code that WoowTech evaluates when the configuration runs.
How the syntax works
When WoowTech encounters a template, ordinary text passes through untouched. Special marker pairs — called delimiters — flag the parts that need computing. There are three:
{{ ... }}evaluates an expression and prints its result{% ... %}runs a statement (logic) without printing anything itself{# ... #}is a comment, ignored entirely
{# Greet whoever is at home #}
Hi there, {{ states('person.sandra') }}.
{% if is_state('sun.sun', 'below_horizon') %}
It is dark out.
{% endif %}
Output:
Hi there, home.
It is dark out.
The comment vanishes, the expression is swapped for the person's state, and the conditional decides whether the darkness line appears.
Expressions
Anything inside {{ ... }} that resolves to a value is an expression. Valid ones include:
- Text:
'morning'or"morning" - Numbers:
7,2.71 - Booleans:
true,false - Nothing at all:
none - Lists:
[10, 20, 30]or['garage', 'attic'] - Arithmetic:
8 + 4 - Function calls:
states('sensor.humidity')ornow() - Variables set earlier
Reach into nested data with dots or square brackets:
Reading: {{ states.sensor.patio.state }}
Label: {{ states.sensor.patio.attributes.friendly_name }}
Same one: {{ states.sensor.patio.attributes['friendly_name'] }}
Output:
Reading: 18.3
Label: Patio temperature
Same one: Patio temperature
Dots read cleaner, but brackets are mandatory when a name holds spaces or odd characters. Entity IDs beginning with a digit, such as device_tracker.2008_gmc, also force bracket form: states.device_tracker['2008_gmc'].
Operators
Operators combine or compare values with the usual symbols.
Arithmetic:
Add: {{ 8 + 4 }}
Subtract: {{ 8 - 4 }}
Multiply: {{ 8 * 4 }}
Divide: {{ 8 / 3 }}
Floor div: {{ 8 // 3 }}
Modulo: {{ 8 % 3 }}
Exponent: {{ 8 ** 2 }}
Output:
Add: 12
Subtract: 4
Multiply: 32
Divide: 2.6666666666666665
Floor div: 2
Modulo: 2
Exponent: 64
The less obvious ones: // divides and throws away the remainder, % keeps only the remainder, and ** raises to a power.
Comparison — every one answers True or False:
Equal: {{ 8 == 8 }}
Not equal: {{ 8 != 3 }}
Greater: {{ 8 > 3 }}
At most: {{ 3 <= 3 }}
Output:
Equal: True
Not equal: True
Greater: True
At most: True
Note that == tests whether two things match, while a single = assigns a name.
Logic — and needs both true, or needs one, not flips it, in checks membership:
And: {{ true and false }}
Or: {{ true or false }}
Not: {{ not false }}
In: {{ 'attic' in 'attic fan' }}
Output:
And: False
Or: True
Not: True
In: True
Filters
A filter reshapes a value via the pipe |, read left to right:
Uppercase: {{ 'morning' | upper }}
Rounded: {{ 2.71828 | round(2) }}
Chained: {{ [9, 4, 6] | sort | join(', ') }}
Output:
Uppercase: MORNING
Rounded: 2.72
Chained: 4, 6, 9
WoowTech bundles dozens of filters for conversions, formatting, math, and list work. The template functions reference lists them all.
Most functions double as both a filter and a plain call:
As a call: {{ float(states('sensor.humidity')) }}
As a filter: {{ states('sensor.humidity') | float }}
Output:
As a call: 54.0
As a filter: 54.0
Use the filter form for readable chains; use the call form when you want an explicit fallback like float(value, 0).
Watch precedence: the pipe binds tighter than math.
{{ 8 / 3 | round(2) }}reads as "8 divided by (3 rounded)," not "(8 divided by 3) rounded." Add parentheses:{{ (8 / 3) | round(2) }}gives2.67, while the unparenthesized form gives2.6666666666666665.
Tests
A test is a yes/no question written with is:
Is a number: {{ 7 is number }}
Is text: {{ 'morning' is string }}
Is odd: {{ 7 is odd }}
In a list: {{ 4 is in [2, 4, 6] }}
Output:
Is a number: True
Is text: True
Is odd: True
In a list: True
Use is not for the negative: {{ 7 is not number }} returns False. Tests shine inside if statements.
Trimming whitespace
Templates keep every space and line break, including the ones around {% ... %} tags. That's fine for messages but messy for clean sensor values. Add a - inside a tag to strip whitespace on that side:
{% ... %}strips nothing{%- ... %}strips the left{% ... -%}strips the right{%- ... -%}strips both
The same applies to {{ ... }}. A common cleanup — trimming the trailing line break after each tag:
{% set temp = 24 -%}
{% if temp > 20 -%}
Warm
{% else -%}
Cool
{% endif -%}
outside.
Output:
Warm
outside.
Trim aggressively for sensor values; leave it alone for human-readable messages where blank lines aid readability.
Loops and conditions
if / elif / else
if runs its block only when the condition is true:
{% if is_state('sun.sun', 'above_horizon') %}
The sun is up.
{% else %}
The sun is down.
{% endif %}
Output: The sun is up.
Always close with {% endif %}; {% else %} is optional. Chain alternatives with {% elif %}, evaluated in order until one matches:
{% set temp = states('sensor.patio_temperature') | float(0) %}
{% if temp < 0 %}
Freezing.
{% elif temp < 15 %}
Chilly.
{% elif temp < 25 %}
Pleasant.
{% else %}
Hot.
{% endif %}
Output: Pleasant.
For a quick one-liner, use the inline form:
{% set temp = 24 %}
It is {{ 'warm' if temp > 20 else 'cool' }}.
Output: It is warm.
for loops
A for loop runs its body once per list item:
{% for fan in states.fan %}
{{ fan.name }}: {{ fan.state }}
{% endfor %}
Output:
Attic: on
Bedroom: off
Office: off
Close every loop with {% endfor %}. Filter items inline by tacking if onto the for:
Fans that are on:
{% for fan in states.fan if fan.state == 'on' %}
- {{ fan.name }}
{% endfor %}
Output:
Fans that are on:
- Attic
- Studio
Inside a loop, the loop variable reports position: loop.first, loop.last, loop.index (from 1), loop.index0 (from 0), and loop.length.
{% for person in states.person %}
{{ loop.index }}. {{ person.name }}{% if not loop.last %},{% endif %}
{% endfor %}
Output:
1. Sandra,
2. Diego,
3. Mira
Variables with set
set names a value for reuse:
{% set temp = states('sensor.patio_temperature') | float(0) %}
{% set unit = state_attr('sensor.patio_temperature', 'unit_of_measurement') %}
It is {{ temp | round(1) }} {{ unit }} outside.
Output: It is 18.3 °C outside.
Loop gotcha: a set inside a loop doesn't survive past the loop — it creates a fresh scoped variable each pass.
{% set count = 0 %}
{% for fan in states.fan if fan.state == 'on' %}
{% set count = count + 1 %}
{% endfor %}
{{ count }}
Output: 0
Fix it with a namespace, which persists across iterations:
{% set ns = namespace(count=0) %}
{% for fan in states.fan if fan.state == 'on' %}
{% set ns.count = ns.count + 1 %}
{% endfor %}
{{ ns.count }}
Output: 2
Breaking out early
{% break %} stops the loop; {% continue %} skips to the next item:
{# First three fans that are on #}
{% set ns = namespace(shown=0) %}
{% for fan in states.fan %}
{% if fan.state != 'on' %}
{% continue %}
{% endif %}
{% if ns.shown >= 3 %}
{% break %}
{% endif %}
{{ fan.name }}
{% set ns.shown = ns.shown + 1 %}
{% endfor %}
Output:
Attic
Studio
Workshop
The {% do %} statement runs an expression without printing. Because the template sandbox blocks mutating methods such as .append(), .pop(), and .update(), build up lists and counters with namespaces instead.
Working with states
Look at your states first
Before writing anything, open Settings > Developer tools > States to browse every entity, its current state, and its attributes. A typical entry looks like:
Entity: sensor.patio_temperature
State: 18.3
Attributes:
unit_of_measurement: °C
device_class: temperature
friendly_name: Patio temperature
Each entity has an ID (its lookup name), a state (its main value), and attributes (extra detail). Keep this page open in another tab while you work.
Reading a state
states fetches an entity's current state by ID:
The garage light is {{ states('light.garage') }}.
Output: The garage light is on.
Every state is text. Even numbers come back as strings, so comparisons run alphabetically unless you convert. Always convert before doing math:
{{ states('sensor.patio_temperature') | float(0) + 5 }}
Output: 23.3
The | float(0) converts to a number, with 0 as the fallback if conversion fails. Use | float(0) or | int(0) for any numeric work.
States are capped at 255 characters — store anything longer in attributes, which have no limit. Two special states flag trouble: unknown (the entity exists but its value isn't known) and unavailable (the entity can't be reached). Asking for a nonexistent entity also yields unknown. Handle both gracefully with fallbacks or has_value().
Reading an attribute
state_attr() pulls extra detail, with an optional fallback:
The garage light sits at {{ state_attr('light.garage', 'brightness') | int(0) }}.
Output: The garage light sits at 200.
Checking a state
is_state() compares cleanly even when an entity is missing:
{{ is_state('light.garage', 'on') }}
Output: True
is_state_attr() does the same for attributes:
{{ is_state_attr('media_player.office', 'source', 'Spotify') }}
Output: True
has_value() confirms an entity has a usable state — ideal for friendly fallback messages:
{% if has_value('sensor.patio_temperature') %}
It is {{ states('sensor.patio_temperature') }}°C outside.
{% else %}
The patio sensor is offline.
{% endif %}
Output: It is 18.3°C outside.
Listing entities
states.<domain> returns every entity in a domain:
There are {{ states.fan | count }} fans in total.
Output: There are 9 fans in total.
Narrow it with selectattr():
Fans that are on:
{% for fan in states.fan | selectattr('state', 'eq', 'on') %}
- {{ fan.name }}
{% endfor %}
Output:
Fans that are on:
- Attic
- Studio
- Workshop
Finding entities by area, device, label, or floor
WoowTech offers grouping lookups: area_entities(), device_entities(), label_entities(), floor_entities(), and integration_entities(). Reverse functions also exist (area_devices(), etc.). Example — bedroom lights that are on:
Bedroom lights on:
{% for entity in area_entities('bedroom') %}
{% if entity.startswith('light.') and is_state(entity, 'on') %}
- {{ state_attr(entity, 'friendly_name') }}
{% endif %}
{% endfor %}
Output:
Bedroom lights on:
- Bedroom ceiling
- Reading lamp
The this variable
In template entities, this refers to the entity itself, so it can read its own state without hardcoding its ID:
template:
- sensor:
- name: "Patio helper"
state: "{{ this.attributes.get('tally', 0) + 1 }}"
attributes:
tally: "{{ this.state | int(0) + 1 }}"
this exists only in template entities and certain automation contexts — not in the Developer Tools template editor.
The trigger variable
When an automation fires, a trigger variable carries details about what caused it (field names vary by trigger type):
- trigger: state
entity_id: binary_sensor.front_gate
to: "on"
action:
- action: notify.send_message
target:
entity_id: notify.my_device
data:
message: >
{{ trigger.to_state.name }} opened at
{{ trigger.to_state.last_changed.strftime('%H:%M') }}.
Output: Front gate opened at 14:32.
Types and conversion
The types you'll meet: text (str), integer (int), float, boolean (bool), None, list, dictionary (dict), iterable (a one-pass sequence from filters like map/select), and datetime (from now() or last_changed).
Types matter because operators are picky: math wants numbers, | count wants a list (not an iterable), and </> on text compares alphabetically — so '6' > '10' is True because '6' sorts after '1'.
Every state is text. states('sensor.humidity') returns the string '54', not the number 54:
{{ states('sensor.humidity') | typeof }}
Output: str
That's why | float(0) and | int(0) are everywhere. Compare:
{# Text concatenation, not math #}
{{ states('sensor.a') + states('sensor.b') }}
Output: 54.018.5
{# Real math #}
{{ states('sensor.a') | float(0) + states('sensor.b') | float(0) }}
Output: 72.5
Each type has a converting filter: | float(default), | int(default), | string, | bool(default), | list. The defaults kick in when conversion fails (e.g. an unavailable sensor).
{{ "2.71" | float(0) }}
{{ "nine" | float(0) }}
{{ "42" | int(0) }}
{{ 3.7 | int }}
{{ 1 | string }}
{{ "yes" | bool }}
Output:
2.71
0.0
42
3
1
True
Iterables aren't lists. The filters map, select, reject, selectattr, and rejectattr return a lazy iterable you can't count or index. | count on one errors:
{# Fails #}
{{ states.fan | selectattr('state', 'eq', 'on') | count }}
Output: Error: object of type 'generator' has no len()
Add | list first:
{{ states.fan | selectattr('state', 'eq', 'on') | list | count }}
Output: 3
Pull items from a list with first, last, or bracket indexing (0-based, negatives count from the end):
{% set rooms = ['attic', 'cellar', 'garage'] %}
First: {{ rooms | first }}
Last: {{ rooms | last }}
Index 0: {{ rooms[0] }}
Index 1: {{ rooms[1] }}
Index -1: {{ rooms[-1] }}
Output:
First: attic
Last: garage
Index 0: attic
Index 1: cellar
Index -1: garage
From a dictionary, use bracket or dot notation — but prefer brackets when a key could clash with a dict method like values, keys, or get:
{% set response = {'status': 'ok', 'values': [1, 2, 3]} %}
{{ response['values'] }}
Output: [1, 2, 3]
Inspect a type with typeof, or test it inside an if:
{{ 42 is number }}
{{ "hello" is string }}
{{ [1, 2, 3] is iterable }}
{{ {"a": 1} is mapping }}
{{ None is none }}
Output:
True
True
True
True
True
Dates and times
now() gives the current local time; utcnow() gives UTC. Templates using either re-run once a minute — base faster updates on state changes instead.
Local: {{ now() }}
UTC: {{ utcnow() }}
Output:
Local: 2026-04-04 14:30:00.123456+02:00
UTC: 2026-04-04 12:30:00.123456+00:00
today_at('22:00') returns a specific time today — handy for "past bedtime?" checks. Pull out parts with dot notation:
Year: {{ now().year }}
Month: {{ now().month }}
Day: {{ now().day }}
Hour: {{ now().hour }}
Weekday: {{ now().weekday() }}
weekday() runs 0 (Monday) to 6 (Sunday); isoweekday() runs 1 to 7.
Format with strftime:
24-hour: {{ now().strftime('%H:%M') }}
12-hour: {{ now().strftime('%I:%M %p') }}
Weekday: {{ now().strftime('%A') }}
Long date: {{ now().strftime('%A, %B %-d, %Y') }}
Output:
24-hour: 14:30
12-hour: 02:30 PM
Weekday: Saturday
Long date: Saturday, April 4, 2026
Common codes: %Y four-digit year, %m padded month, %d padded day, %-d day without leading zero, %H 24-hour, %M minute, %S second, %I 12-hour, %p AM/PM, %A/%a full/short weekday, %B/%b full/short month.
Parse text back into a datetime with strptime:
{% set event = strptime('2026-12-25 10:30', '%Y-%m-%d %H:%M') %}
{{ event }}
Output: 2026-12-25 10:30:00
Convert UNIX timestamps with timestamp_local, timestamp_utc, or timestamp_custom; go the other way with as_timestamp. as_datetime is a forgiving parser:
{% set ts = 1710510600 %}
Local: {{ ts | timestamp_local }}
Custom: {{ ts | timestamp_custom('%H:%M on %B %d') }}
Back: {{ as_timestamp(now()) | int }}
Subtracting two datetimes yields a timedelta. To find how long ago something happened:
{% set since = now() - states.binary_sensor.front_gate.last_changed %}
Total minutes: {{ (since.total_seconds() / 60) | int }}
Output: Total minutes: 15
For human-readable spans use time_since and time_until:
{{ time_since(states.binary_sensor.front_gate.last_changed) }} ago
Output: 15 minutes ago
Trigger an alert when something has lingered too long:
{{ (now() - states.binary_sensor.front_gate.last_changed).total_seconds() > 600 }}
Output: True (once the gate has held its state over 10 minutes).
Time gotchas: comparing date-like text with
</>sorts alphabetically, not chronologically — convert to datetimes first.timestamp_customis for UNIX timestamps; for datetimes call.strftime(...)directly. Subtraction returns a timedelta, so call.total_seconds()for a number. Around daylight-saving changes prefertoday_at('HH:MM')over.replace().
Templates inside YAML
Most templates live in YAML files, which have their own quoting and multi-line rules that interact with templates in ways that trip everyone up at least once.
A single-line template must be quoted (single or double, no behavioral difference):
value_template: "{{ states('sensor.temp') | float > 20 }}"
Without quotes, YAML mistakes {{ for a flow-style mapping and fails. If the template contains quotes, wrap it in the other kind:
value_template: "{{ is_state('light.kitchen', 'on') }}"
value_template: '{{ states["sensor.temp"] }}'
For longer templates, use a multi-line block. The folded > style joins lines with a space (good for one long expression split for readability); the literal | style keeps line breaks (good for multi-line messages):
value_template: >
{{
states('sensor.temp') | float(0) > 20
and states('sensor.humidity') | float(0) < 60
}}
message: |
Today's summary:
Temperature: {{ states('sensor.temp') }}°C
Humidity: {{ states('sensor.humidity') }}%
A trailing - (>- or |-) strips the final newline — common for value_template:
value_template: >-
{% if states('sensor.outdoor_temp') | float(0) < 0 %}
freezing
{% else %}
not freezing
{% endif %}
Inside a block, YAML takes the indent from the first non-empty line; every later line must indent at least as far, or YAML cuts the block off and treats the rest as a new key.
Frequent slip-ups: forgetting quotes on a single-line template, opening a quote and never closing it, using > when you need line breaks preserved, and mixing indents inside |/> blocks.
Custom templates and macros
Reusable template snippets and macros can be shared across your configuration so you don't repeat yourself. See the custom templates documentation for the full mechanism.
Where to go next
- Reference: WoowTech offers hundreds of template functions, filters, and tests — browse them by category in the template functions reference.
- Debugging: experiment and troubleshoot live in the template editor under Developer tools > Template — hands-on trial is the fastest way to learn.
- Patterns: ready-made recipes for common jobs live on the common template patterns page.
- Tutorials: two walkthroughs put it together — building a daily low-battery notification, and a template sensor that averages every temperature sensor in your home.
Related topics
Start writing here...