In this article
When one ESPHome configuration supports several hardware variants or optional features, it helps to think about composition in two stages:
- Selection: substitutions determine which file or package value is used.
- Merging: content supplied under
packages:is combined with the main configuration.
This selection-then-merge model describes ESPHome’s documented examples without claiming a complete internal evaluation order.
Substitutions help compose the configuration
Substitutions are reusable YAML values referenced with $name or ${name}. ESPHome replaces substitution references before validating the configuration, and substitution names are case-sensitive (ESPHome substitutions).

substitutions:
platform: esp32
The value can then be referenced as ${platform} elsewhere in the configuration. ESPHome also supports simple Jinja expressions inside ${...}, using those delimiters instead of Jinja’s usual {{...}} syntax (ESPHome substitutions).
These mechanisms select configuration content before a device runs. They do not describe runtime hardware detection: in the documented filename example, ESPHome expands the substitution before loading the selected file (ESPHome packages).
Selecting a package through its filename
ESPHome supports substitution expressions in !include filenames. Its packages documentation demonstrates selecting a hardware package with device-${platform}.yaml:
substitutions:
platform: esp32
packages:
hardware: !include device-${platform}.yaml
light:
- platform: status_led
name: "Board Status"
pin: $LED_GPIO
With platform set to esp32, ESPHome expands the filename before loading it:
device-${platform}.yaml
↓
device-esp32.yaml
The selected file can provide the corresponding hardware configuration:
substitutions:
LED_GPIO: GPIO2
esp32:
board: esp32dev
For this documented pattern, the useful conceptual flow is:
${platform}suppliesesp32.device-${platform}.yamlresolves todevice-esp32.yaml.- ESPHome loads that file under the
hardwarepackage. - Its contents participate in package merging.
ESPHome permits valid substitution expressions, including supported Jinja expressions, in these filenames (ESPHome packages; ESPHome substitutions).
This mechanism selects a configuration file. The cited documentation does not establish that a selected file exists, matches the physical device, or produces valid firmware.
Making a package optional
A dynamic filename chooses which file to load. A conditional package expression can instead decide whether package content is supplied.
ESPHome documents this garage-door example:
substitutions:
left_garage_door: !include
file: garage-door.yaml
vars:
door_name: Left
enable_extra_door: true
packages:
extra_door: ${left_garage_door if enable_extra_door else {}}
Here, garage-door.yaml is loaded into the left_garage_door substitution variable. The expression under packages: then chooses:
left_garage_doorwhenenable_extra_dooris true- an empty mapping,
{}, when it is false
When selected, the package value participates in normal package merging. When the expression returns {}, it contributes no garage-door definitions.
The documentation presents this package-or-empty-mapping pattern and says enable_extra_door can also be set from the command line (ESPHome packages). It does not document every possible combination of conditional expressions and package forms.
Package precedence applies after selection
Selecting a package does not make all its values final. Package data is merged with the main configuration, whose values take precedence over package values (ESPHome YAML guide).
For example, the selected hardware package above supplies:
substitutions:
LED_GPIO: GPIO2
The main configuration could override that package substitution:
substitutions:
platform: esp32
LED_GPIO: GPIO4
ESPHome specifically documents that a substitution in the main configuration overrides a substitution with the same name in a package (ESPHome packages).
The distinction is therefore:
- Selection answers: Which package content is supplied?
- Merging answers: How does that content combine with the main configuration?
Detailed rules for merging dictionaries and lists are separate from this selection step.
Comparing the composition forms
| Configuration form | Selection behavior | What happens next |
|---|---|---|
!include device-esp32.yaml under packages: |
The filename is fixed | The file is merged as package data |
!include device-${platform}.yaml under packages: |
${platform} is expanded before loading |
The selected file is merged as package data |
${package_value if enabled else {}} under packages: |
The expression chooses package content or {} |
The chosen value participates in package merging |
!include fragment.yaml elsewhere |
The named file is loaded at that YAML position | Its content is inserted there rather than merged as a top-level package |
<<: !include file-${variant}.yaml |
Filename substitution is unsupported | This form cannot provide dynamic filename selection |
Ordinary !include is different from a package
Outside packages:, !include inserts a file’s contents at the location where the tag appears:
binary_sensor:
- platform: gpio
id: button1
on_multi_click: !include on-multi-click.yaml
Here, the included content becomes the value of on_multi_click; it is not treated as a top-level package.
The YAML guide calls this literal insertion, but “literal” does not mean substitution processing is disabled. The included content is still substituted at that location (ESPHome YAML guide).
Use an ordinary !include when a fragment belongs at one exact configuration location. Use packages: when a partial configuration should be merged with the device’s main configuration.
Important syntax boundaries
Dynamic filenames are not supported with the YAML insertion or merge-key form (ESPHome packages; ESPHome substitutions):
<<: !include common-${platform}.yaml
This restriction applies to substitution in that form’s filename. It does not mean substitutions are unavailable within included content.
The !literal tag has a different purpose: it prevents substitution replacement and Jinja processing for the tagged value (ESPHome substitutions).
text: !literal "This is a ${value}"
The resulting text retains ${value} unchanged. !literal opts a value out of processing; it is not a package-selection mechanism.
Choosing the right form
Use a fixed package include when every device needs the same reusable configuration. Use a substitution-expanded filename when one value selects among parallel files. Use the documented conditional expression when a package is optional. Use ordinary !include when content belongs at a particular YAML location.
For the documented package patterns, the boundary is straightforward: substitutions select the package input, and package merging determines how that input combines with the main configuration.
No comments yet