Skip to main content
Config entries are the modern way to configure integrations in Home Assistant. They provide a user-friendly UI for setting up and managing integrations, replacing the older YAML-based configuration.

Overview

A config entry consists of:
  • Config Flow: Handles the setup process through the UI
  • Config Entry: Stores the configuration data
  • Options Flow: Allows users to modify settings after setup

Config Entry Basics

The ConfigEntry Object

A ConfigEntry object stores configuration data and has several key properties:
class ConfigEntry:
    """Hold a configuration entry."""
    
    entry_id: str                          # Unique identifier
    domain: str                            # Integration domain
    title: str                             # Display name
    data: MappingProxyType[str, Any]      # Configuration data
    options: MappingProxyType[str, Any]   # Optional settings
    state: ConfigEntryState               # Current state
    source: str                           # How it was created
    unique_id: str | None                 # Unique identifier for the device

Config Entry States

Config entries have different states:
class ConfigEntryState(Enum):
    """Config entry state."""
    
    LOADED = "loaded"                     # Successfully set up
    SETUP_ERROR = "setup_error"           # Setup failed
    SETUP_RETRY = "setup_retry"           # Will retry setup
    NOT_LOADED = "not_loaded"             # Not yet loaded
    FAILED_UNLOAD = "failed_unload"       # Unload failed
    SETUP_IN_PROGRESS = "setup_in_progress"

Implementing a Config Flow

The config flow is the UI wizard that guides users through setting up your integration.

Basic Config Flow

config_flow.py
"""Config flow for Your Integration."""
from __future__ import annotations

from typing import Any

import voluptuous as vol

from homeassistant import config_entries
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
from homeassistant.data_entry_flow import FlowResult
import homeassistant.helpers.config_validation as cv

from .const import DOMAIN


class YourIntegrationConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
    """Handle a config flow for Your Integration."""
    
    VERSION = 1
    
    async def async_step_user(
        self, user_input: dict[str, Any] | None = None
    ) -> FlowResult:
        """Handle the initial step."""
        errors = {}
        
        if user_input is not None:
            try:
                # Validate the user input
                info = await self._async_validate_input(user_input)
                
                # Set a unique ID to prevent duplicate entries
                await self.async_set_unique_id(info["serial_number"])
                self._abort_if_unique_id_configured()
                
                # Create the config entry
                return self.async_create_entry(
                    title=info["title"],
                    data=user_input,
                )
            except CannotConnect:
                errors["base"] = "cannot_connect"
            except InvalidAuth:
                errors["base"] = "invalid_auth"
            except Exception:  # pylint: disable=broad-except
                errors["base"] = "unknown"
        
        # Show the form to the user
        return self.async_show_form(
            step_id="user",
            data_schema=vol.Schema({
                vol.Required(CONF_HOST): str,
                vol.Required(CONF_USERNAME): str,
                vol.Required(CONF_PASSWORD): str,
            }),
            errors=errors,
        )
    
    async def _async_validate_input(self, user_input: dict[str, Any]) -> dict[str, str]:
        """Validate the user input allows us to connect."""
        # Initialize your API client
        # client = YourAPIClient(
        #     user_input[CONF_HOST],
        #     user_input[CONF_USERNAME],
        #     user_input[CONF_PASSWORD],
        # )
        # 
        # # Test the connection
        # await client.authenticate()
        # info = await client.get_info()
        
        return {
            "title": "Your Device Name",
            "serial_number": "unique_device_id",
        }

Config Flow Steps

Each step in the config flow is a method named async_step_<step_name>. Common steps include:
  • async_step_user: Initial user-initiated setup
  • async_step_import: Import from YAML configuration
  • async_step_discovery: Handle discovered devices
  • async_step_zeroconf: Handle Zeroconf/mDNS discovery
  • async_step_bluetooth: Handle Bluetooth discovery

Multi-Step Config Flows

For complex setups, you can chain multiple steps:
async def async_step_user(
    self, user_input: dict[str, Any] | None = None
) -> FlowResult:
    """Handle the initial step."""
    if user_input is None:
        return self.async_show_form(
            step_id="user",
            data_schema=vol.Schema({
                vol.Required(CONF_HOST): str,
            }),
        )
    
    # Store the host for use in the next step
    self.context["host"] = user_input[CONF_HOST]
    
    # Move to authentication step
    return await self.async_step_auth()

async def async_step_auth(
    self, user_input: dict[str, Any] | None = None
) -> FlowResult:
    """Handle the authentication step."""
    if user_input is None:
        return self.async_show_form(
            step_id="auth",
            data_schema=vol.Schema({
                vol.Required(CONF_USERNAME): str,
                vol.Required(CONF_PASSWORD): str,
            }),
        )
    
    # Combine data from both steps
    data = {
        CONF_HOST: self.context["host"],
        **user_input,
    }
    
    return self.async_create_entry(title="Device", data=data)

Discovery Flows

Integrations can be automatically discovered through various methods:

Zeroconf Discovery

async def async_step_zeroconf(
    self, discovery_info: ZeroconfServiceInfo
) -> FlowResult:
    """Handle zeroconf discovery."""
    # Extract information from discovery
    host = discovery_info.host
    properties = discovery_info.properties
    
    # Set unique ID to prevent duplicate entries
    await self.async_set_unique_id(properties["serial"])
    self._abort_if_unique_id_configured(updates={CONF_HOST: host})
    
    # Store discovery info and ask user to confirm
    self.context["title_placeholders"] = {
        "name": properties.get("name", host)
    }
    
    return await self.async_step_discovery_confirm()

async def async_step_discovery_confirm(
    self, user_input: dict[str, Any] | None = None
) -> FlowResult:
    """Confirm discovery."""
    if user_input is not None:
        return self.async_create_entry(
            title=self.context["title_placeholders"]["name"],
            data={CONF_HOST: self.context["host"]},
        )
    
    return self.async_show_form(step_id="discovery_confirm")
Declare Zeroconf discovery in your manifest:
manifest.json
{
  "zeroconf": [
    {
      "type": "_your-service._tcp.local.",
      "name": "your-device*"
    }
  ]
}

Bluetooth Discovery

async def async_step_bluetooth(
    self, discovery_info: BluetoothServiceInfoBleak
) -> FlowResult:
    """Handle bluetooth discovery."""
    await self.async_set_unique_id(discovery_info.address)
    self._abort_if_unique_id_configured()
    
    # Store the address for later use
    self.context["address"] = discovery_info.address
    self.context["title_placeholders"] = {
        "name": discovery_info.name or discovery_info.address
    }
    
    return await self.async_step_bluetooth_confirm()

Options Flow

Options flows allow users to modify integration settings after initial setup:
class YourIntegrationConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
    """Handle a config flow."""
    
    @staticmethod
    @callback
    def async_get_options_flow(
        config_entry: ConfigEntry,
    ) -> YourIntegrationOptionsFlow:
        """Get the options flow for this handler."""
        return YourIntegrationOptionsFlow(config_entry)


class YourIntegrationOptionsFlow(config_entries.OptionsFlow):
    """Handle options flow."""
    
    def __init__(self, config_entry: ConfigEntry) -> None:
        """Initialize options flow."""
        self.config_entry = config_entry
    
    async def async_step_init(
        self, user_input: dict[str, Any] | None = None
    ) -> FlowResult:
        """Manage the options."""
        if user_input is not None:
            return self.async_create_entry(title="", data=user_input)
        
        return self.async_show_form(
            step_id="init",
            data_schema=vol.Schema({
                vol.Optional(
                    "scan_interval",
                    default=self.config_entry.options.get("scan_interval", 60),
                ): int,
            }),
        )

Handling Config Entry Updates

You can listen for config entry changes:
__init__.py
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
    """Set up from a config entry."""
    # Set up your integration...
    
    # Register update listener
    entry.async_on_unload(entry.add_update_listener(async_update_options))
    
    return True


async def async_update_options(hass: HomeAssistant, entry: ConfigEntry) -> None:
    """Handle options update."""
    # Reload the config entry when options change
    await hass.config_entries.async_reload(entry.entry_id)

Reauthentication Flow

If credentials expire, trigger a reauth flow:
# In your integration code when auth fails:
await hass.config_entries.flow.async_init(
    DOMAIN,
    context={
        "source": config_entries.SOURCE_REAUTH,
        "entry_id": entry.entry_id,
    },
    data=entry.data,
)

# In your config flow:
async def async_step_reauth(
    self, user_input: dict[str, Any]
) -> FlowResult:
    """Handle reauth upon an API authentication error."""
    self.entry = self.hass.config_entries.async_get_entry(self.context["entry_id"])
    return await self.async_step_reauth_confirm()

async def async_step_reauth_confirm(
    self, user_input: dict[str, Any] | None = None
) -> FlowResult:
    """Confirm reauth dialog."""
    if user_input is None:
        return self.async_show_form(
            step_id="reauth_confirm",
            data_schema=vol.Schema({
                vol.Required(CONF_PASSWORD): str,
            }),
        )
    
    # Update the entry with new credentials
    self.hass.config_entries.async_update_entry(
        self.entry,
        data={**self.entry.data, **user_input},
    )
    await self.hass.config_entries.async_reload(self.entry.entry_id)
    
    return self.async_abort(reason="reauth_successful")

Reconfigure Flow

Allow users to reconfigure an existing entry:
async def async_step_reconfigure(
    self, user_input: dict[str, Any] | None = None
) -> FlowResult:
    """Handle reconfiguration."""
    entry = self.hass.config_entries.async_get_entry(self.context["entry_id"])
    
    if user_input is not None:
        self.hass.config_entries.async_update_entry(
            entry,
            data={**entry.data, **user_input},
        )
        await self.hass.config_entries.async_reload(entry.entry_id)
        return self.async_abort(reason="reconfigure_successful")
    
    return self.async_show_form(
        step_id="reconfigure",
        data_schema=vol.Schema({
            vol.Required(CONF_HOST, default=entry.data[CONF_HOST]): str,
        }),
    )

Translations

Provide translations for your config flow in strings.json:
strings.json
{
  "config": {
    "step": {
      "user": {
        "title": "Connect to Your Device",
        "description": "Enter the connection details",
        "data": {
          "host": "Host",
          "username": "Username",
          "password": "Password"
        }
      }
    },
    "error": {
      "cannot_connect": "Failed to connect",
      "invalid_auth": "Invalid authentication",
      "unknown": "Unexpected error"
    },
    "abort": {
      "already_configured": "Device is already configured",
      "reauth_successful": "Re-authentication was successful"
    }
  },
  "options": {
    "step": {
      "init": {
        "title": "Options",
        "data": {
          "scan_interval": "Update interval (seconds)"
        }
      }
    }
  }
}

Best Practices

Use Unique IDs

Always set a unique ID to prevent duplicate entries:
await self.async_set_unique_id(device_serial)
self._abort_if_unique_id_configured()

Validate Early

Validate user input as soon as possible to provide quick feedback.

Handle Errors Gracefully

Provide clear error messages for common failure scenarios.

Update Existing Entries

When rediscovering a device, update the existing entry:
await self.async_set_unique_id(device_id)
self._abort_if_unique_id_configured(
    updates={CONF_HOST: discovery_info.host}
)

Store Minimal Data

Only store essential configuration in data. Use runtime_data for temporary data.

Common Patterns

Single Config Entry

Prevent multiple config entries:
async def async_step_user(self, user_input=None):
    """Handle user step."""
    # Check if already configured
    if self._async_current_entries():
        return self.async_abort(reason="single_instance_allowed")
    # Continue with setup...
Or use in manifest:
{
  "single_config_entry": true
}

Selectors

Use modern selectors for better UX:
from homeassistant.helpers.selector import (
    SelectSelector,
    SelectSelectorConfig,
    TextSelector,
    TextSelectorConfig,
    TextSelectorType,
)

data_schema=vol.Schema({
    vol.Required(CONF_PASSWORD): TextSelector(
        TextSelectorConfig(type=TextSelectorType.PASSWORD)
    ),
})

Next Steps

Build docs developers (and LLMs) love