
This article may contain affiliate links. If you buy through these links, the price is the same for you and the store pays me a small commission that helps keep Tecnoyfoto running.
A template may work perfectly for weeks and then fail immediately after a restart, when a sensor needs a few seconds to publish its first value. It may also continue running without errors while making a bad decision: turn unavailable into zero degrees and switch on the heat as though that were a real measurement.
In this chapter, we will use variables to store values during a run and learn to treat unknown and unavailable as expected situations. The goal is not to hide every failure behind a default value. It is to decide what an automation should do when reliable data is unavailable.
Our example will control the living room fan:
- If the temperature is valid and reaches 25 °C, turn on the fan.
- If the temperature is valid and below 25 °C, turn it off.
- If there is no usable temperature, do not change the fan and create a warning.
YAML does not provide runtime variables on its own
Older articles often refer to “YAML variables.” That phrase blends several different concepts.
YAML can store a pair like:
threshold: 25
That merely represents data with a key and a value. YAML itself does not maintain a variable that changes while an automation is running.
Home Assistant provides variables in automations and scripts:
- variables:
temperature_text: "{{ states('sensor.course_living_room_temperature') }}"
And Jinja also allows local variables within a template:
{% set temperature = states('sensor.course_living_room_temperature') | float(0) %}
{{ temperature }}
All three examples may appear in YAML files or text fields, but they do not belong to the same layer:
- YAML organizes keys and values.
- Home Assistant defines the
variablesaction and its scope within a sequence. - Jinja defines
{% set %}within the rendering of a template.
This chapter focuses primarily on Home Assistant variables, which can be reused by multiple actions during the same run.
Store a value within the sequence
The variables action creates values that subsequent steps can use:
actions:
- variables:
temperature_text: >-
{{ states('sensor.course_living_room_temperature') }}
- variables:
temperature_is_valid: >-
{{ temperature_text not in ['unknown', 'unavailable'] }}
- action: persistent_notification.create
data:
title: "YAML Course - Temperature"
message: "Received value: {{ temperature_text }}"
The first two actions do not control a device. The first reads the entity and stores the result. The second validates the stored text without querying the sensor again:
temperature_textholds the state as text.temperature_is_validindicates whether that stored value is something other thanunknownorunavailable.
The message that follows uses the same reading. The entire run therefore works from one consistent snapshot, even if the sensor updates again a moment later.
A name should describe the type and purpose
Naming the variable temperature_text reminds us that the value has not been converted yet. If we simply called it temperature, we might accidentally use it in a numeric comparison.
A good name does not replace conversion, but it does reduce ambiguity:
temperature_text → original state as text
temperature_is_valid → boolean result
temperature → number after converting
has_value() and the difference between querying and storing
This function:
{{ has_value('sensor.course_living_room_temperature') }}
returns false when the state is unknown or unavailable. It also handles a nonexistent entity because the query does not return a usable value.
It does not prove that every other string can be converted to the number we expect. A faulty integration could publish some other text. For well-defined numeric sensors, checking availability and converting with an appropriate default usually covers the common cases. Unvalidated external input may require additional checks.
When we only need to know whether an entity has a usable state right now, has_value() expresses that intent clearly. However, it takes an entity_id and queries the entity. If we have already called states() to store a snapshot, calling has_value() afterward performs a second query that could see a newer value.
To validate the exact text we already stored, we use:
{{ temperature_text not in ['unknown', 'unavailable'] }}
This is not a universal replacement for has_value(). It is the appropriate choice when the reading, validation, conversion, and message must all use the same value. The right option depends on the intent: query the entity’s current state or work from a stored reading.
Why float(0) can be technically safe and logically wrong
This conversion does not raise an error if the sensor is unavailable:
{{ states('sensor.course_living_room_temperature') | float(0) }}
If the state is unavailable, the result is 0. That prevents an error, but zero now looks like a valid measurement.
Consider a frost-protection automation:
{{ states('sensor.outdoor_temperature') | float(0) < 3 }}
When the sensor becomes unavailable, the condition is true because 0 < 3. You might want to activate a safety measure, or you might prefer to send a warning and take no action. What matters is making that policy explicit instead of letting a default value decide by accident.
Choose the fallback value according to the domain:
- When displaying a counter, zero may make sense.
- For an unknown physical temperature, zero can be misleading.
- For a safety-related decision, you need a policy, not just a conversion.
An automation with a valid-data path and a no-data path
The complete solution uses an outer choose:
alias: "YAML Course - Control fan by temperature"
description: >-
Controls the fan when the temperature is valid and sends an alert
when the sensor does not have usable data.
triggers:
- trigger: state
entity_id: sensor.course_living_room_temperature
conditions: []
actions:
- variables:
temperature_text: >-
{{ states('sensor.course_living_room_temperature') }}
- variables:
temperature_is_valid: >-
{{ temperature_text not in ['unknown', 'unavailable'] }}
- choose:
- alias: "The temperature is valid"
conditions:
- condition: template
value_template: "{{ temperature_is_valid }}"
sequence:
- variables:
temperature: "{{ temperature_text | float }}"
- choose:
- alias: "High temperature"
conditions:
- condition: template
value_template: "{{ temperature >= 25 }}"
sequence:
- action: switch.turn_on
target:
entity_id: switch.course_fan
default:
- action: switch.turn_off
target:
entity_id: switch.course_fan
default:
- action: persistent_notification.create
data:
title: "YAML Course - Temperature not available"
message: >-
The fan was left unchanged because the temperature sensor's
state is {{ temperature_text }}.
mode: restart
The trigger does not specify from or to because we want to reevaluate the fan control whenever the temperature state changes, regardless of its previous value.
The first two actions separate reading from validation: the entity is queried once, and validity is calculated from that snapshot. The outer choose separates two policies:
- Valid data: convert and decide on the fan.
- Invalid data: leave the fan as it is and send a warning.
Within the valid branch, temperature_text | float will not receive unknown or unavailable because the preceding condition filtered out those states. Configurations with less predictable data may still need a fallback and stricter validation.
The inner choose compares the numeric value. At 25 or above, it turns the fan on; at any other valid value, it turns the fan off.
Scope: where a variable exists
temperature_text and temperature_is_valid are created before choose, so both branches can use them.
temperature is created inside the “The temperature is valid” sequence. We use it only within that branch. We must not assume that it exists after the choose, because the no-data path never assigns it.
This design would be fragile:
actions:
- choose:
- conditions:
- condition: state
entity_id: binary_sensor.course_someone_home
state: "on"
sequence:
- variables:
greeting: "Hello from home."
- action: persistent_notification.create
data:
message: "{{ greeting }}"
If no one is home, greeting is never defined, and the next action tries to use a nonexistent variable.
When a value will be needed after multiple branches, initialize it beforehand or make sure every path assigns it.
Variables provided by the trigger
Automations receive a special variable named trigger with information about what started the run.
With a state trigger, we can access the previous and new state objects:
message: >-
The temperature has changed from
{{ trigger.from_state.state }} to {{ trigger.to_state.state }}.
This value exists only when a trigger that provides the relevant data starts the automation. If you select Run actions, Home Assistant skips the trigger, so the trigger variable may not be available.
A template can therefore work during a real trigger and fail when you run the actions manually. The reverse can also happen if you simulate values in the editor that are absent from the real execution context.
The identifiers we saw in chapter 5 are also available as:
{{ trigger.id }}
We do not need Jinja to compare trigger IDs when the trigger condition is available, but the variable is useful for building messages or recording information.
Script fields and variables received
Let’s return to the message script from the previous chapter. A field describes the data that the interface expects:
fields:
message_text:
name: "Message"
required: true
selector:
text:
The call provides the value:
action: script.course_create_message
data:
message_text: "The door has opened."
And the sequence uses it as a variable:
message: "{{ message_text }}"
Declaring a field improves the interface and documents the input, but required: true primarily guides the user interface. A malformed YAML call can still omit the data. If many different callers use the script, define a default value or verify that the variable exists, depending on the behavior you want.
Jinja variables with {% set %}
When a variable is needed only within one piece of text, it may be more natural to create it inside the template:
{% set temperature = states('sensor.course_living_room_temperature') | float(0) %}
The current temperature is {{ temperature }} °C.
{% set %} belongs to Jinja and lasts only for that rendering. The variables action belongs to Home Assistant and lets subsequent steps in the sequence share data.
Choose according to the required scope:
- Only within a template:
{% set %}can be enough. - Across multiple actions: use Home Assistant’s
variables. - Data supplied when calling a script: use fields and call data.
Calling all of these things “YAML variables” hides the differences and makes the right documentation harder to find.
How to simulate unavailable in the practice environment
To test the no-data path, temporarily open Settings → Tools → States and set the state of:
sensor.course_living_room_temperature
to:
unavailable
Remember what we learned in chapter 3: this changes the temporary internal state, not the input_number helper that feeds the sensor. The template entity may overwrite your change the next time it updates.
After simulating it:
- Confirm that the unavailable-data notification is created.
- Make sure the fan does not change.
- Change the temperature helper to restore a normal reading.
- Test values below and above 25.
A robust template must be tested with both normal data and the special states it claims to handle.
Deliberate error: a variable that exists only sometimes
Analyze this flow:
actions:
- choose:
- conditions:
- condition: state
entity_id: binary_sensor.course_someone_home
state: "on"
sequence:
- variables:
status_message: "There's someone at home."
- action: persistent_notification.create
data:
message: "{{ status_message }}"
When no branch matches, the assignment never runs, so status_message is never created. One solution is to initialize it first:
actions:
- variables:
status_message: "There's no one home."
- choose:
- conditions:
- condition: state
entity_id: binary_sensor.course_someone_home
state: "on"
sequence:
- variables:
status_message: "There's someone at home."
- action: persistent_notification.create
data:
message: "{{ status_message }}"
Now every path has a value, and the matching branch replaces it.
Exercise: control the fan without inventing a temperature
Build an automation with these requirements:
- Reevaluate whenever
sensor.course_living_room_temperaturechanges. - Store its state as
temperature_text. - Check if the saved reading is
unknownorunavailable, without consulting the entity again. - Convert it to number only in the valid branch.
- Turn on the fan at 25 °C or above.
- Turn it off below 25 °C.
- If there is no valid data, do not touch the fan and create a notification.
- Try
unavailable, 24 and 26.
See the explained solution
The complete solution appears above and is also available as a practice file. The important decision is not how many choose blocks it contains, but how it separates the policies:
Is the data valid?
No → send a warning and leave the fan unchanged
Yes → convert
temperature >= 25 → turn on
temperature < 25 → turn off
Converting only after checking availability prevents unavailable from masquerading as a real temperature. Using temperature only inside the branch where it is created prevents a scope error.
What should you take away from this chapter?
The variables used by automations and scripts belong to Home Assistant, not YAML. Jinja can also create local variables with {% set %}, while script fields supply data as input variables. Each mechanism has a different scope.
unknown and unavailable are expected states. A numeric fallback can prevent an error while causing the wrong behavior at home. First decide what should happen when data is missing; then write a conversion that reflects that policy.
In the next chapter, we will move from logic to organization. We will open configuration.yaml, automations.yaml, scripts.yaml, the !include directives, packages, and secrets.yaml, and see why every included file must have the structure expected at its insertion point.

