Home Assistant Jinja2 Template Debugging: Five Error Families and a Production Checklist
At 11:40 PM my phone lit up with a push I did not expect: UndefinedError: 'sensor.living_room_temp' is undefined. I had written a small template sensor that afternoon for the living room temperature, and the entity ID was missing one character — sensor.living_room_tem instead of sensor.living_room_temp. The template engine did not say "entity not found"; it said "variable undefined," the whole render failed, and that sensor sat at unknown all evening while I stared at a preview that looked perfectly fine.
That night I went through every template failure mode Home Assistant can throw. What I learned is that template errors are more insidious than an automation that never fires. A dead automation you can find; a template render failure is often silent — a sensor quietly turns unknown, a condition never evaluates true, a dashboard cell shows n/a, and you have no idea which link in the chain broke. Worse, it is not always your fault: an entity going offline at 3 AM can turn a working template into a broken one overnight. If you are new to automations, start with automation basics and the YAML cheatsheet; templates are the layer where automations most often go wrong.
Where Templates Actually Run
Jinja2 in Home Assistant is not confined to automations, and knowing where a template runs tells you how its failure will surface. I split template execution into three places, each with a very different error signature:
| Where it runs | How failure surfaces | Where to look |
|---|---|---|
| template sensor / binary_sensor | entity turns unknown, log shows TemplateError |
Developer Tools, States + log |
| automation condition / action | condition never true, or action skipped, log has an error | Settings, System, Logs |
| template helper | helper unusable / shows n/a |
Developer Tools, Template |
All three share one engine, but how visible the failure is varies sharply. A template sensor failing is the worst because it silently flips to unknown. A template inside an automation only surfaces when the automation fires. A helper failure disables the whole helper. So step one is always: figure out which layer failed, then look at the right place.
There is a second trap: preview is not production. The Developer Tools, Template preview renders against live state. A template that previews fine only proves the syntax is valid — it does not prove the render succeeds after save. A mistyped ID, an unavailable entity, or a missing attribute can still blow up at runtime even though the preview returned a value.
The Three Spots Templates Hide in an Automation
Templates are not only in the condition block. I use them in three places, and each fails differently:
# (1) trigger value_template: filters the trigger value
trigger:
- platform: state
entity_id: sensor.outdoor_temp
value_template: "{{ (trigger.to_state.state | float(0) - trigger.from_state.state | float(0)) | abs > 2 }}"
# (2) condition template: if it fails, the whole automation silently skips
condition:
- condition: template
value_template: "{{ states('sensor.living_temp') | float(0) > 26 }}"
# (3) action data template: renders a value into a service call
action:
- service: notify.mobile_app_phone
data:
message: "Living room is now {{ states('sensor.living_temp') | float(0) | round(1) }} C"
A failure in (1) kills the trigger, in (2) makes the condition permanently false, in (3) aborts the action. (2) is the nasty one: a template condition that raises does not throw a visible error — it is treated as false, so the automation silently does nothing and you assume your logic is wrong.
Error Family 1: Undefined Variables
Signature: UndefinedError: 'sensor.xxx' is undefined
Cause: the entity does not exist, the ID is mistyped, or the entity has never been instantiated (a device that never paired).
This is my most common failure, and the fix hinges on one distinction most people miss — dotted access vs. function access behave completely differently:
{# Dotted access: a missing entity throws undefined and kills the whole template #}
{{ states.sensor.living_room_temp.state }}
{# Function access: a missing entity returns the string 'unknown', render continues #}
{{ states('sensor.living_room_temp') }}
{# With a default: the second true arg treats undefined as empty too #}
{{ states('sensor.living_room_temp') | default('unavailable', true) }}
The rule: if you can use states('sensor.xxx'), never write states.sensor.xxx.state. The function is a Home Assistant wrapper that yields 'unknown' when the entity is absent; the dotted form reaches into the Jinja context and throws undefined. For any entity that might not exist, go function-style plus default().
The most frustrating case I hit: a missing underscore in the entity ID produced 'sensor.kitchen_temp' is undefined, and I stared at it for ten minutes before searching Developer Tools, States and finding the real ID was sensor.kitchen_temperature. When you see undefined, the first action is to verify the entity ID exists in States — not to rewrite the template.
Error Family 2: Type Coercion
Signature: TypeError: float() argument must be a string or a number
Cause: a non-convertible value was handed to a numeric filter.
This is a twin of family 1. When an entity is unavailable, states() returns 'unavailable' — a string — and | float cannot coerce it, raising TypeError. The classic broken line:
{# When the entity is unavailable, 'unavailable' | float throws #}
{{ states('sensor.outdoor_temp') | float | round(1) }}
The fix is to guard before converting, or to give float() a fallback:
{# Guard with is_number first: only convert when it really is numeric #}
{{ states('sensor.outdoor_temp') | float(0) | round(1) if is_number(states('sensor.outdoor_temp')) else 'n/a' }}
{# Or just give float a default: unavailable becomes 0.0 #}
{{ states('sensor.outdoor_temp') | float(0) | round(1) }}
String concatenation is the other half of this family. + and ~ are not interchangeable in Jinja:
{# + between a string and a float raises a type mismatch #}
{{ 'Temp is ' + states('sensor.temp') }}
{# ~ coerces to string first, then joins #}
{{ 'Temp is ' ~ (states('sensor.temp') | float(0) | round(1)) ~ ' C' }}
Remember: + is arithmetic and demands matching types; ~ is Jinja's string-join operator and coerces first. Join text with ~, never +.
There is a subtler | int trap: '12.7' | int truncates to 12, not 13. If you want rounding, use round:
{# 12.7 becomes 12, not 13 #}
{{ '12.7' | float | int }}
{# round to zero places gives 13.0, then int gives 13 #}
{{ ('12.7' | float | round(0)) | int }}
Error Family 3: Misplaced or Misspelled Filters
Signature: TemplateError: No filter named 'xxx' or a wrong result
Cause: a typo in the filter name, or a filter applied to a type it does not support.
Home Assistant inherits the full Jinja filter set and adds its own (round, as_timestamp, relative_time, float, int, now). The errors I keep seeing:
{# round on a string: '3.14' | round does not exist, convert first #}
{{ '3.14' | round(1) }}
{# convert, then round #}
{{ states('sensor.temp') | float(0) | round(1) }}
{# default placed mid-pipe gets consumed by the next filter #}
{{ states('sensor.x') | default('n/a') | float }}
{# default with true last: treats undefined/None as needing a default too #}
{{ states('sensor.x') | float(0) | round(1) | default('n/a', true) }}
A quick reference for the filters I reach for most:
| Filter / test | Purpose | Common trap |
|---|---|---|
float(default) |
coerce to float | throws on 'unavailable' without a default |
int(default) |
coerce to int | truncates, does not round |
round(n) |
keep n decimals | numeric input only, convert first |
default(value, true) |
fallback value | must be last in the pipe |
as_timestamp() |
to Unix seconds | pair with now() for differences |
relative_time() |
human relative time | input must be a datetime |
is_number() (test) |
is it numeric | check before converting |
has_value() (test) |
does the entity have a value | false when unavailable |
Do not mix tests and filters. is_number() is a test and reads x is number; float() is a filter and reads x | float. {{ states('sensor.x') is_number }} is wrong; {{ states('sensor.x') is number }} is right.
Error Family 4: Time and Timezone
Signature: no error, but results are off by the timezone offset, or a condition never satisfies Cause: comparing local-time values with UTC timestamps.
This family does not error — it silently misbehaves, which makes it the most exhausting to chase. now() returns your configured timezone; utcnow() returns UTC; entity timestamps like last_changed are UTC. Comparing them directly skews by the offset:
{# now() is local, last_changed is UTC, the difference is off by the offset #}
{% if now() - states.sensor.temp.last_changed > timedelta(minutes=5) %}
{# Normalize both to Unix seconds: always safe #}
{% if (now().timestamp() - as_timestamp(states.sensor.temp.last_changed)) > 300 %}
The rule: for any time comparison, normalize through as_timestamp() to Unix seconds first, and never mix now() with UTC timestamps. Formatting with now().strftime('%H:%M') is fine; arithmetic is not.
My real case: an "alert if the sensor has not updated in ten minutes" automation written as now() - states.sensor.x.last_changed > timedelta(minutes=10). It never fired during the day and flooded me at night. The cause: last_changed is UTC and now() is UTC+8, so the computed difference was "negative eight hours plus the real interval." During the day the interval stayed under eight hours and the condition was always false; past eight hours it went true. Switching to as_timestamp() fixed it. No error, worst kind of bug.
Error Family 5: State Attribute Access and Unavailable
Signature: attribute access returns empty / 'unavailable', a condition silently fails
Cause: the entity exists but is unavailable, or the attribute does not exist.
This one is silent by design. When an entity is online but unavailable, states('sensor.x') returns the string 'unavailable', and every comparison fails without a trace:
{# When unavailable, this is false and the automation silently skips #}
{% if states('sensor.motion') == 'on' %}
{# Guard with has_value before reading the state #}
{% if has_value('sensor.motion') and states('sensor.motion') == 'on' %}
{# Or reject the unavailable state explicitly #}
{% if states('sensor.motion') != 'unavailable' and states('sensor.motion') == 'on' %}
Attribute access is the same. A chain like states.sensor.x.attributes.temperature turns None or undefined if any level is missing. The safe route is to confirm a value first, or use states.sensor.x.attributes.get('temperature', default).
There is a related trap: a state string that carries a unit. sensor.energy may report '12.5' with the unit in attributes.unit_of_measurement, or it may report '12.5 kWh' inline. states('sensor.energy') | float works only on the bare number; an inline unit makes the coercion fail. Check whether the unit lives in the state or in attributes before converting.
A Five-Step Debugging Workflow
The five families above rarely arrive alone, so I use a fixed workflow instead of guessing:
- Read the log: Settings, System, Logs — find
TemplateErrororUndefinedErrorwith its full traceback. The traceback carries a file and line number that points straight at the failing template line. - Verify the entity: Developer Tools, States — confirm the ID actually exists and is not
unavailable. - Fix the template: match the error to a family and apply the fix — function-style access, a guard, a default, or a timezone normalization.
- Reload: reload the relevant piece — Developer Tools, YAML, Reload for sensors; the automations page for automations.
- Re-verify with a live entity: re-render in Developer Tools, Template and confirm the output value, not just that the syntax is valid.
Reading the Traceback Line Number
The log gives you a precise target. Learn to read it and you halve the search time:
2026-11-05 00:12:33 ERROR (MainThread) [homeassistant.components.template.template]
Error rendering template: UndefinedError: 'sensor.bedroom_temp' is undefined
File "/config/templates.yaml", line 12, in top-level template code
{{ states.sensor.bedroom_temp.state }}
The final File "...", line 12 is the line in your template file. Open templates.yaml at line 12 and you are basically done. Do not just read the first line of the error and start rewriting the whole template.
A Production Template Checklist
From all those failures I built a checklist that prevents most of them. Follow it and templates stop surprising you:
- Access entities function-style:
states('sensor.x'), neverstates.sensor.x.state(absent entity does not throw). - Guard before converting numbers:
states('sensor.x') | float(0)for a fallback, oris_number()first. - Put default last:
| default('n/a', true), always at the end of the pipe. - Normalize time comparisons:
as_timestamp()to Unix seconds. - Join with
~, not+: string-join is type-safe. - Confirm a value before reading an attribute:
has_value()orattributes.get(). - Keep templates simple: more than a few lines, split into steps with
mode: restartrather than stacking logic in one template. - Reload and re-verify after every change: render with a real entity and check the output value.
{# A production-grade template sensor that follows the checklist #}
template:
- sensor:
- name: "Living room temp with trend"
state: >-
{% set t = states('sensor.living_room_temp') %}
{% if is_number(t) %}
{{ (t | float) | round(1) }}
{% else %}
n/a
{% endif %}
unit_of_measurement: "C"
This is what I run in production. is_number() guards first so an unavailable sensor cannot blow up | float; round(1) keeps one decimal; otherwise it prints n/a instead of flipping the sensor to unknown. Better to show n/a than to take down the whole chain with a template error.
The Refresh Timing Trap
One more failure that does not error: knowing when a template recomputes. A template sensor re-renders only when a referenced entity changes, unless you set update_interval. A template that reads now() with no changing dependency computes once and then goes stale:
template:
- sensor:
- name: "Daily mode"
# A template that depends on now() needs an update_interval or it will not refresh
state: "{{ 'day' if now().hour >= 9 else 'night' }}"
update_interval:
minutes: 30
Any template that depends on now() should set update_interval, or it computes once and never flips. I once saw someone "fix" this by rebooting Home Assistant daily — one update_interval would have done the job.
FAQ Extras
Two more edge cases worth knowing:
forloops overstates: iteratingstates.sensor | selectattr(...)is expensive when the entity set is large. The globalstatesobject is costly to traverse, so prefer a point lookupstates('sensor.x')and avoid loops. If you must aggregate many sensors, do it in an automation instead of a template loop.- Whitespace control and indentation:
{%-strips leading whitespace,-%}strips trailing. Indentation errors inside a template surface as YAML errors, not template errors, which is confusing. Watch the block scalar indentation understate:— template content must be indented deeper than the key.
The heart of template debugging is one sentence: make templates immune to a missing or unavailable entity, then think about logic. Get comfortable with default(), function-style states(), and is_number(), and nine out of ten template failures resolve on the spot.
Related: Automation Basics · Automation YAML Cheatsheet · HA Lovelace Dashboard · Trigger vs Condition · HA Notifications
Official docs: Home Assistant Templating · Template Integration · Jinja2 Documentation