Skip to content

Variables and Templates That Survive Unknown States

30/10/2026
Variables and templates that survive unknown states

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.

← Chapter 8 · Course index

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 variables action 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.

DEALS · OFFICIAL STORE

Discounts on SONOFF smart home

Wi-Fi switches, relays, sensors, LED strips and more. Deals change frequently in the official store.

Coupon: TECNOYFOTO (10% off at checkout)

View official deals Affiliate link Affiliate link · SONOFF store

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_text holds the state as text.
  • temperature_is_valid indicates whether that stored value is something other than unknown or unavailable.

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:

  1. Confirm that the unavailable-data notification is created.
  2. Make sure the fan does not change.
  3. Change the temperature helper to restore a normal reading.
  4. 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:

  1. Reevaluate whenever sensor.course_living_room_temperature changes.
  2. Store its state as temperature_text.
  3. Check if the saved reading is unknown or unavailable, without consulting the entity again.
  4. Convert it to number only in the valid branch.
  5. Turn on the fan at 25 °C or above.
  6. Turn it off below 25 °C.
  7. If there is no valid data, do not touch the fan and create a notification.
  8. 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.