Using the Python Elasticsearch Client to Apply ILM Policies

You have an ILM policy defined and you need to attach it to indices from Python — idempotently, so that re-running the script never double-applies, orphans a policy, or leaves an index silently unmanaged.

Where this fits in the lifecycle

Defining a lifecycle policy and applying it are two separate operations, and only the second one changes cluster behaviour. This page covers the client-side application step: writing the policy definition into the deployment, binding it to the indices that should obey it, and confirming Elasticsearch actually accepted the binding. It is the setup that the state-aware loop in automating phase transitions with Python depends on — that orchestrator can only force rollover, shrink, or a reindex pipeline fallback against indices that are already managed by a known policy. Apply the policy wrong here and every downstream transition inherits the fault: an index with no index.lifecycle.name setting is invisible to ILM, and one attached to a policy without a bootstrapped write alias wedges at the first rollover step.

Applying a policy has three distinct targets, and a correct script hits all three: the policy document itself (created or updated with ilm.put_lifecycle), the existing indices that must adopt it now (patched with indices.put_settings), and the future indices a rollover will create (bound through an index template so the setting is inherited, not re-applied by hand). Skip the template and every new rollover index is born unmanaged.

The three targets one apply call must hitA top-down flow. A single entry box, apply_ilm_policy, fans into three parallel method boxes: ilm.put_lifecycle writing the policy document, indices.put_settings attaching the lifecycle name and rollover alias to existing indices, and indices.put_index_template binding the same settings to future rollover indices so they inherit management. All three arrows converge on a final verification box, ilm.explain_lifecycle, which must report managed true with the expected policy.One apply routine, three binding targets, one verificationapply_ilm_policy()one idempotent routine1ilm.put_lifecycleupsert the policy documentcreated or overwritten in place2indices.put_settingsindices that exist nowlifecycle.name + rollover_alias3indices.put_index_templatefuture rollover indicesinherited, not re-appliedilm.explain_lifecyclemanaged: truewith the expected policy — never trust the 200

Prerequisites

  • Elasticsearch 8.x reachable from the automation host, with elasticsearch-py v8 installed (elasticsearch>=8.0,<9.0) — the ilm.put_lifecycle, ilm.explain_lifecycle, and indices.put_index_template methods used below exist only on the v8 client surface.
  • An API key scoped by RBAC to manage_ilm plus manage on the target index pattern, so the token can write the policy and patch index settings but not touch policies it does not own.
  • A policy body ready to apply — if you do not have one yet, start from the ILM policy JSON template.
  • A write alias and matching data-tier node attributes (data_hot, data_warm, data_cold); a policy whose allocate action targets a tier with no matching node will attach cleanly but stall on the first transition.

Implementation

Initialise a client hardened for unattended runs, then apply the policy across all three targets in one idempotent function. Every call here is an upsert: ilm.put_lifecycle overwrites an existing policy of the same name rather than raising, and indices.put_settings re-applies the same setting harmlessly, so the whole routine is safe to re-run.

from elasticsearch import Elasticsearch
from elasticsearch import ApiError, BadRequestError, NotFoundError
import logging

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(message)s",
    handlers=[logging.FileHandler("ilm_apply_audit.log"), logging.StreamHandler()],
)
logger = logging.getLogger("ilm-apply")


def get_client(hosts: list[str], api_key: str, ca_path: str) -> Elasticsearch:
    """A v8 client hardened for unattended policy application."""
    return Elasticsearch(
        hosts,
        api_key=api_key,               # rotatable key scoped to manage_ilm; never basic auth
        ca_certs=ca_path,
        verify_certs=True,             # refuse an unverified endpoint
        request_timeout=30,            # per-request ceiling
        max_retries=3,                 # v8 transport retries natively; this is an int
        retry_on_timeout=True,
        retry_on_status=(429, 502, 503, 504),  # retry transient cluster pressure
        http_compress=True,
    )


def apply_ilm_policy(
    es: Elasticsearch,
    policy_name: str,
    policy_body: dict,
    index_pattern: str,
    template_name: str,
    rollover_alias: str,
) -> None:
    """Upsert a policy, attach it to existing + future indices, then verify."""

    # 1. Upsert the policy document. PUT is idempotent — an existing policy of the
    #    same name is overwritten, so this never raises ConflictError. A malformed
    #    body raises BadRequestError (400); let that surface, do not swallow it.
    try:
        es.ilm.put_lifecycle(name=policy_name, policy=policy_body)
        logger.info("Policy '%s' upserted.", policy_name)
    except BadRequestError as exc:
        logger.error("Policy '%s' rejected as invalid: %s", policy_name, exc.info)
        raise

    # 2. Attach the policy to indices that already exist. expand_wildcards='all'
    #    so closed/hidden backing indices are not silently skipped.
    es.indices.put_settings(
        index=index_pattern,
        settings={
            "index.lifecycle.name": policy_name,
            "index.lifecycle.rollover_alias": rollover_alias,
        },
        expand_wildcards="all",
    )
    logger.info("Attached '%s' to existing indices matching '%s'.", policy_name, index_pattern)

    # 3. Bind the policy to FUTURE indices via an index template, so every rollover
    #    target inherits the setting instead of needing put_settings re-run by hand.
    es.indices.put_index_template(
        name=template_name,
        index_patterns=[index_pattern],
        template={
            "settings": {
                "index.lifecycle.name": policy_name,
                "index.lifecycle.rollover_alias": rollover_alias,
            }
        },
    )
    logger.info("Template '%s' will apply '%s' to new indices.", template_name, policy_name)

    # 4. Verify the binding took — do not trust a 200 as proof of management.
    verify_managed(es, index_pattern, policy_name)


def verify_managed(es: Elasticsearch, index_pattern: str, expected_policy: str) -> None:
    """Assert every matching index is managed by the expected policy."""
    explain = es.ilm.explain_lifecycle(index=index_pattern, expand_wildcards="all")
    for idx_name, idx_data in explain.get("indices", {}).items():
        if not idx_data.get("managed"):
            raise RuntimeError(f"{idx_name} is NOT ILM-managed after apply")
        if idx_data.get("policy") != expected_policy:
            logger.warning(
                "%s is managed by '%s', expected '%s'",
                idx_name, idx_data.get("policy"), expected_policy,
            )

The two index.lifecycle.* settings are a pair: index.lifecycle.name binds the policy, and index.lifecycle.rollover_alias tells the rollover action which alias to advance. Attaching the name without the alias is the single most common way to produce an index that looks managed in explain_lifecycle but fails the moment it reaches its rollover conditions. Where the resulting shards land after each transition is governed by the hot-warm-cold architecture and the policy’s own allocate action.

Verification

A successful put_settings returns {"acknowledged": true}, but that only means the setting was written — it is not proof that ILM adopted the index. The authoritative check is ilm.explain_lifecycle, which reports managed and the attached policy per index:

GET /logs-app-000001/_ilm/explain
{
  "indices": {
    "logs-app-000001": {
      "index": "logs-app-000001",
      "managed": true,
      "policy": "logs-retention-policy",
      "phase": "hot",
      "action": "rollover",
      "step": "check-rollover-ready",
      "lifecycle_date_millis": 1719763200000
    }
  }
}

managed: true with the expected policy name confirms the attachment. A step of check-rollover-ready is the healthy waiting state — the index is under management and has simply not yet met a rollover condition, not an error. A genuine failure instead shows "step": "ERROR" with a step_info.type (the exception class, such as illegal_argument_exception) and a step_info.reason; the usual cause is a missing rollover_alias or a tier with no eligible node. To confirm future indices inherit the policy, check the template resolved correctly:

GET /_index_template/logs-app-template

Its template.settings should contain both index.lifecycle.name and index.lifecycle.rollover_alias, so the next rollover index is born managed.

Gotchas and edge cases

  • put_lifecycle is an overwrite, not a create. PUT-ing a policy that already exists silently replaces its definition — it never raises ConflictError. If you must avoid clobbering an in-flight policy, read it first with ilm.get_lifecycle(name=...) and diff, or gate the write behind a version check; do not rely on a ConflictError that will never fire.
  • A template does not retro-attach. put_index_template binds the policy only to indices created after it lands. Indices that already exist keep whatever settings they had, so step 2’s put_settings is not optional — dropping it leaves today’s indices unmanaged even though tomorrow’s are fine.
  • rollover_alias must point at the current write index. If the alias is missing, points at a read-only index, or has no is_write_index: true member, the rollover step fails with illegal_argument_exception no matter how correct the policy is. Bootstrap the alias before applying the policy, not after.
  • Use the v8 client’s native retry controls, not a urllib3 Retry. The v8 transport does not accept a urllib3 Retry object. Configure resilience through max_retries, retry_on_status, and retry_on_timeout as shown — passing a legacy Retry raises at client construction.

Frequently asked questions

Does re-running the apply script re-create or duplicate the policy?

No. ilm.put_lifecycle is an idempotent upsert keyed on the policy name — re-running it overwrites the existing document with an identical body, producing no duplicate and no error. Likewise indices.put_settings re-writes the same index.lifecycle.name harmlessly. The whole routine is safe to run on every deploy.

Why attach the policy through both put_settings and an index template?

They cover different indices. put_settings attaches the policy to indices that exist right now; the index template binds it to indices a future rollover creates, which inherit the setting automatically. Use only the template and today's indices stay unmanaged; use only put_settings and every new rollover index is born without a policy.

The index shows managed:true but never rolls over — what did I miss?

Almost always the write alias. managed:true confirms the policy is attached, but the rollover action needs index.lifecycle.rollover_alias to name a real alias whose write index is this index. Read _ilm/explain: if step is ERROR, step_info.type names the cause; a missing or misdirected alias reports illegal_argument_exception. Fix the alias, then es.ilm.retry(index=...).

← Back to Automating Phase Transitions with Python · Up to ILM Policy Design & Lifecycle Synchronization