# How the Home Assistant Integration Loader Discovers and Loads Custom Components

> Learn how Home Assistant's integration loader discovers and loads custom components by scanning manifest files and caching objects for efficient on-demand Python code execution.

- Repository: [Home Assistant/core](https://github.com/home-assistant/core)
- Tags: internals
- Published: 2026-02-28

---

**The Home Assistant integration loader discovers custom components by importing the `custom_components` package, scanning its subdirectories for valid [`manifest.json`](https://github.com/home-assistant/core/blob/main/manifest.json) files, and caching `Integration` objects that load Python code on demand via thread-pool executors.**

The **integration loader** in the home-assistant/core repository manages both built-in and user-provided extensions. Understanding how it discovers and loads **custom components** is essential for developers debugging integration issues or building custom automation platforms.

## The Three-Phase Discovery Process

The loader implements a three-phase pipeline to isolate custom code while maintaining fast lookup times. In [`homeassistant/loader.py`](https://github.com/home-assistant/core/blob/main/homeassistant/loader.py), the process splits filesystem scanning from metadata resolution to prevent blocking the event loop during startup.

1. **Filesystem scan** – `async_get_custom_components()` delegates to `_get_custom_components()` to enumerate candidate domains.
2. **Metadata resolution** – Each candidate passes through `Integration.resolve_from_root()` to validate manifests.
3. **Caching and exposure** – Valid integrations populate `hass.data[DATA_CUSTOM_COMPONENTS]` for subsequent lookups.

## Phase 1: Scanning the Filesystem with async_get_custom_components

The entry point `async_get_custom_components()` (lines 27-45 in [`homeassistant/loader.py`](https://github.com/home-assistant/core/blob/main/homeassistant/loader.py)) ensures the scan runs exactly once per Home Assistant instance. It creates a future in `hass.data[DATA_CUSTOM_COMPONENTS]` and executes the synchronous helper `_get_custom_components()` within the executor thread pool via `hass.async_add_executor_job()`.

The synchronous scanner first checks for **recovery mode** or **safe mode**, returning an empty dictionary immediately if either is active. It then attempts `import custom_components`. If the import fails (indicating no custom components folder exists), the function returns an empty mapping. When the package exists, the code iterates over every subdirectory using `pathlib.Path(entry).iterdir()` to build a list of candidate domain names.

## Phase 2: Resolving Integration Metadata

For each candidate domain, the loader invokes `Integration.resolve_from_root()` (lines 63-99) to transform a directory name into a validated `Integration` object. This method performs three critical validations:

- **Manifest presence**: The loader expects `custom_components/<domain>/manifest.json` and parses it using `json_loads()` into a typed `Manifest` dictionary.
- **Version validation**: Lines 102-124 enforce strict version checking. If the manifest lacks a `version` key or contains a malformed version string, the integration is rejected immediately.
- **Blocked integration check**: The domain and version are validated against `BLOCKED_CUSTOM_INTEGRATIONS` (lines 94-134). If the integration is known to break Home Assistant, the loader aborts loading before any Python code executes.

Upon validation, the `Integration` object stores the package path as `custom_components.<domain>`, the manifest data, and a set of top-level files used later to identify available platforms.

## Phase 3: Caching and Lazy Loading

Valid `Integration` objects are stored in `hass.data[DATA_CUSTOM_COMPONENTS]`, creating a persistent cache that eliminates repeated filesystem scans. When Home Assistant needs an integration, it calls `async_get_integration()` (lines 63-73), which first checks this custom component cache. If the domain is absent, the loader falls back to the built-in `homeassistant.components` package via `_resolve_integrations_from_root()`.

Actual Python code loading occurs lazily through `Integration.async_get_component()`. If the integration’s `import_executor` flag is true (the default), the import executes in a thread-pool executor via `hass.async_add_import_executor_job()`. Otherwise, it loads directly in the event loop. Platform modules (e.g., [`sensor_platform.py`](https://github.com/home-assistant/core/blob/main/sensor_platform.py)) load on demand via `async_get_platform()` or `async_get_platforms()`, respecting the same executor flag to prevent blocking.

## Loading Custom Integration Code

Once cached, integrating a custom component into the running system requires explicit module loading. The `Integration` object provides methods to load both the core module and specific platforms without manual import statements.

```python
import homeassistant.loader

# 1. Populate the custom component cache

custom_integrations = await homeassistant.loader.async_get_custom_components(hass)

# 2. Retrieve the Integration metadata object

integration = await homeassistant.loader.async_get_integration(hass, "my_custom_sensor")

# 3. Load the component's __init__.py module

component = await integration.async_get_component()

# 4. Load a specific platform (e.g., sensor)

sensor_platform = await integration.async_get_platform("sensor")

```

This workflow mirrors the internal path Home Assistant uses during config flow discovery and entity platform setup.

## Summary

- The **integration loader** scans `custom_components` via `async_get_custom_components()` in [`homeassistant/loader.py`](https://github.com/home-assistant/core/blob/main/homeassistant/loader.py), skipping the scan in safe or recovery mode.
- Each domain resolves through `Integration.resolve_from_root()`, which validates [`manifest.json`](https://github.com/home-assistant/core/blob/main/manifest.json), enforces version requirements, and checks against `BLOCKED_CUSTOM_INTEGRATIONS`.
- Valid integrations cache in `hass.data[DATA_CUSTOM_COMPONENTS]`, with `async_get_integration()` falling back to core components if no custom match exists.
- Python code loads lazily via `async_get_component()` and `async_get_platform()`, respecting the `import_executor` flag to manage thread safety.

## Frequently Asked Questions

### Where should custom components be placed for the loader to find them?

The loader expects a `custom_components` directory at the same level as the core Home Assistant installation. Each integration must reside in its own subdirectory (e.g., `custom_components/my_integration/`) containing a [`manifest.json`](https://github.com/home-assistant/core/blob/main/manifest.json) file and an [`__init__.py`](https://github.com/home-assistant/core/blob/main/__init__.py) module. If the `custom_components` package cannot be imported, the loader returns an empty dictionary and continues with core components only.

### What happens if a custom component's manifest.json is invalid or missing?

The `Integration.resolve_from_root()` method rejects any domain lacking a valid [`manifest.json`](https://github.com/home-assistant/core/blob/main/manifest.json). Specifically, lines 102-124 in [`homeassistant/loader.py`](https://github.com/home-assistant/core/blob/main/homeassistant/loader.py) enforce that the manifest must contain a valid `version` key. Missing or malformed manifests cause the loader to skip that directory entirely without caching an `Integration` object, effectively excluding it from the available integration list.

### How does Home Assistant handle naming conflicts between custom and core components?

The loader checks the custom component cache first via `async_get_integration()`. If a domain exists in both `custom_components` and the built-in `homeassistant.components` package, the custom version takes precedence. Only if the domain is absent from the custom cache does the loader fall back to resolving from the core package root.

### Can custom components block the Home Assistant event loop during loading?

By default, no. The `import_executor` flag in the `Integration` object defaults to true, causing `async_get_component()` and `async_get_platform()` to execute imports within a dedicated thread-pool executor using `hass.async_add_import_executor_job()`. Developers can override this behavior, but the default design prevents long-running or poorly written custom integrations from freezing the event loop during startup.