ha:coordinator-patterns
Coordinator patterns — DataUpdateCoordinator anatomy, typed runtime_data, CoordinatorEntity data flow, push vs poll, first refresh, unload symmetry, config-entry migrations. Use when editing coordinat
SKILL.mdskills/coordinator-patterns/SKILL.md
GitHubCoordinator Patterns Reference
Reference for working with DataUpdateCoordinator, typed runtime_data, and
CoordinatorEntity data flow. Detect whether the integration polls or is pushed before
applying patterns: look for update_interval=timedelta(...) (polling) versus
update_interval=None + library callbacks/dispatcher (push). Some integrations use both
for different data streams — match the pattern to the coordinator you're editing.
Iron Laws — Never Violate These
- THE LIBRARY OWNS THE PROTOCOL (P11) — Transport, parsing, and device semantics live in the published PyPI library; the coordinator only orchestrates fetches. "Transport code and protocol details should always go in a 3rd party library published on PyPI." — MartinHjelmare, https://github.com/home-assistant/core/pull/158279#discussion_r2600415810
_async_update_dataRAISES — IT NEVER LOGS-AND-RETURNS (PS3, P3) —UpdateFailedfor fetch errors,ConfigEntryAuthFailedfor auth; the base class handles availability, logging, and retry. "We just need to raise UpdateFailed... the coordinator will take part of everything" — joostlek, https://github.com/home-assistant/core/pull/162188#discussion_r2765956114- TYPE THE COORDINATOR AND THE ENTRY (P6) —
type XConfigEntry = ConfigEntry[XCoordinator]onruntime_data; neverhass.data[DOMAIN], neverdict[str, Any]. "don't use hass data instead use runtime_data." — edenhaus, https://github.com/home-assistant/core/pull/167872#discussion_r3064090359 - FIRST REFRESH HAPPENS IN SETUP (P16) —
await coordinator.async_config_entry_first_refresh()insideasync_setup_entry; failures becomeConfigEntryNotReady/ConfigEntryAuthFailedautomatically. "The first refresh method needs to be called within theasync_setup_entry" — MartinHjelmare, https://github.com/home-assistant/core/pull/166175#discussion_r3032611222 - ENTITIES READ
coordinator.data— THEY NEVER FETCH — entity properties are pure views over the coordinator snapshot; a fetch per entity is the event-loop analog of an N+1 query (P4, PS3). - UPDATE INTERVALS ARE MODULE CONSTANTS (PS3, PS6) — never parameters, never user-configurable. "We don't allow options for changing scan interval." — MartinHjelmare, https://github.com/home-assistant/core/pull/154428#discussion_r2493741931
- UNLOAD SYMMETRICALLY (PS3, PS5) — every listener, session, and background task registered during setup is torn down via
entry.async_on_unload; no leaked clients across reloads. "Why would we leak one on every reload?" — joostlek, https://github.com/home-assistant/core/pull/169689#discussion_r3547362246 - NO PRE-SEEDED
data, NO MANUAL AVAILABILITY (PS3) — never assigncoordinator.data = {}or hand-roll_attr_availablebookkeeping the base class already does. "No need to initialize data as a dict,_async_update_datawill be run at start" — joostlek, https://github.com/home-assistant/core/pull/148181#discussion_r2249340085 - NEVER
async_refresh()OR COORDINATOR ACCESS IN TESTS (P13, P14) — move time withfreezer.tick+async_fire_time_changed. "Don't access the coordinator directly in tests. Move time forward instead" — MartinHjelmare, https://github.com/home-assistant/core/pull/154677#discussion_r2522917818
Quick Coordinator Templates
Polling coordinator
type MyConfigEntry = ConfigEntry[MyCoordinator] # P6
UPDATE_INTERVAL = timedelta(minutes=5) # constant, never user-config (PS6)
class MyCoordinator(DataUpdateCoordinator[MyDeviceData]):
"""Coordinate fetching from the device."""
config_entry: MyConfigEntry
def __init__(
self, hass: HomeAssistant, config_entry: MyConfigEntry, client: MyClient
) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
_LOGGER,
config_entry=config_entry,
name=DOMAIN,
update_interval=UPDATE_INTERVAL,
)
self.client = client
async def _async_setup(self) -> None:
"""One-time init before the first refresh (PS3)."""
self.device_info = await self.client.get_device_info()
async def _async_update_data(self) -> MyDeviceData:
"""Fetch a full snapshot; raise, never log-and-return (PS3, P2)."""
try:
return await self.client.get_data()
except MyAuthError as err:
raise ConfigEntryAuthFailed from err # triggers reauth flow
except MyConnectionError as err:
raise UpdateFailed(
translation_domain=DOMAIN, translation_key="update_failed"
) from err
CoordinatorEntity
class MySensor(CoordinatorEntity[MyCoordinator], SensorEntity):
"""Pure view over coordinator.data — never fetches."""
_attr_has_entity_name = True # P17
def __init__(
self, coordinator: MyCoordinator, description: MySensorEntityDescription
) -> None:
"""Initialize the sensor. No side effects in constructors (PS4)."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{coordinator.device_info.serial}_{description.key}" # P7
@property
def native_value(self) -> float | None:
"""Return None for unknown — never a sentinel (P8)."""
return self.entity_description.value_fn(self.coordinator.data)
Quick Decisions
Poll vs Push vs No Coordinator
| Pattern | Use When |
|---|---|
Polling coordinator (update_interval constant) |
API must be polled; one fetch feeds all entities |
Push coordinator (update_interval=None + async_set_updated_data) |
Library delivers callbacks/websocket events |
| No coordinator | Single entity, trivial fetch — "I am starting to doubt if a coordinator is the right pattern" — joostlek, https://github.com/home-assistant/core/pull/159140#discussion_r2657808028 (rejected-5 guidance) |
Coordinator Data Shape
| Data | Shape |
|---|---|
| Single device | One library model or frozen dataclass — don't wrap a single object in a dataclass (P6 corollary) |
| Multiple devices | dict[str, DeviceData] keyed by stable device id (P7) — entities index by their id |
Common Anti-patterns
| Wrong | Right |
|---|---|
except Exception: _LOGGER.error(...) in _async_update_data |
raise UpdateFailed(...) from err (PS3, P1, P2) |
self.data = {} before first refresh |
Let async_config_entry_first_refresh populate it (PS3) |
update_interval=timedelta(seconds=entry.options["scan_interval"]) |
Module constant UPDATE_INTERVAL (PS6) |
await self.client.fetch() inside an entity property |
Read self.coordinator.data |
Overriding available with last_update_success |
Delete it — "Is default for CoordinatorEntity" — joostlek, https://github.com/home-assistant/core/pull/149456#discussion_r2538844415 |
struct.unpack/checksum math in _async_update_data |
Parse in the PyPI library (P11) |
References
For detailed patterns, see:
${CLAUDE_SKILL_DIR}/references/typed-data.md- Typed coordinator data, dataclasses vs library models,runtime_datatyping (P6), None-for-unknown (P8)${CLAUDE_SKILL_DIR}/references/data-flow.md- CoordinatorEntity data flow, derived values, dynamic device discovery, sequential vs parallel fetches${CLAUDE_SKILL_DIR}/references/entry-migrations.md- Config-entryversion/minor_versionmigrations,async_migrate_entry, unique-id migrations (PR3, P7)${CLAUDE_SKILL_DIR}/references/lifecycle.md- Setup/unload symmetry,_async_setup, first refresh (P16), session discipline (PS5), poll-after-write${CLAUDE_SKILL_DIR}/references/push-patterns.md- Push coordinators, dispatcher wiring (PS4), parent/child fan-out at scale,always_update=False