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.
Prerequisites
- Elasticsearch 8.x reachable from the automation host, with
elasticsearch-pyv8 installed (elasticsearch>=8.0,<9.0) — theilm.put_lifecycle,ilm.explain_lifecycle, andindices.put_index_templatemethods used below exist only on the v8 client surface. - An API key scoped by RBAC to
manage_ilmplusmanageon 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 whoseallocateaction 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-templateIts 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_lifecycleis an overwrite, not a create. PUT-ing a policy that already exists silently replaces its definition — it never raisesConflictError. If you must avoid clobbering an in-flight policy, read it first withilm.get_lifecycle(name=...)and diff, or gate the write behind a version check; do not rely on aConflictErrorthat will never fire.- A template does not retro-attach.
put_index_templatebinds the policy only to indices created after it lands. Indices that already exist keep whatever settings they had, so step 2’sput_settingsis not optional — dropping it leaves today’s indices unmanaged even though tomorrow’s are fine. rollover_aliasmust point at the current write index. If the alias is missing, points at a read-only index, or has nois_write_index: truemember, therolloverstep fails withillegal_argument_exceptionno 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 urllib3Retryobject. Configure resilience throughmax_retries,retry_on_status, andretry_on_timeoutas shown — passing a legacyRetryraises 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=...).
Related
- Automating phase transitions with Python — the state-aware loop that drives the indices this script makes managed.
- Building custom ILM policies via API — authoring the policy body you pass to
put_lifecycle. - Handling ILM step execution failures programmatically — recovering an index that lands in
ERRORafter attachment. - Monitoring ILM execution and error states — turning the
explain_lifecycleverification into standing alerts.
← Back to Automating Phase Transitions with Python · Up to ILM Policy Design & Lifecycle Synchronization