Manually Triggering an ILM Phase Transition
Force a managed index from its current lifecycle step directly to the next one with POST _ilm/move/<index>, supplying the exact current_step the index sits in and the next_step you want it to jump to.
You reach for this when an index is parked on a step you want to skip — a warm-phase min_age wait you need to bypass to drain a hot node ahead of maintenance, or a step that has advanced correctly on every other index but that you want to hand-advance on one straggler. It is a deliberate override of the state machine, so it belongs in a runbook rather than in routine automation. Manual moves live downstream of the observability described in monitoring ILM health with the _cat and explain APIs: you monitor to find the parked index, then move it. The control plane both rely on is Index Lifecycle Management (ILM), and the example throughout uses the events-* pattern managed by the events-ilm policy.
The single rule that governs whether a move succeeds is that current_step must describe exactly where the index actually is right now. ILM rejects the request if the index has already moved past the step you named, which is a safety feature — it prevents a stale runbook from firing a transition against an index that has since advanced. That is why every move begins by reading the live step from _ilm/explain rather than assuming it.
Prerequisites
- Elasticsearch 8.x with the
events-ilmpolicy attached to the target index (managed: true). elasticsearch-pyv8.0+ for the programmatic path (ilm.move_to_step,ilm.explain_lifecycle).- Cluster privilege
manage_ilmon the account performing the move — this is a mutation, not an observation, so it must not run under a read-only monitoring key. - The exact
phase,action, andnameof the step the index currently occupies, read fresh from_ilm/explainimmediately before the move.
Implementation
1. Read the exact current step
A move is only valid against the step the index is on now, so read it live. Both current_step and next_step are three-key objects — { "phase", "action", "name" } — and every key must match what ILM reports:
GET events-000004/_ilm/explain{
"indices": {
"events-000004": {
"index": "events-000004",
"managed": true,
"policy": "events-ilm",
"phase": "hot",
"action": "complete",
"step": "complete",
"phase_execution": { "policy": "events-ilm", "version": 4 }
}
}
}Here the index has finished the hot phase and is sitting on the terminal complete step, waiting on the warm phase min_age timer. The current_step is therefore { "phase": "hot", "action": "complete", "name": "complete" }.
2. Issue the move
Advance the index to the start of the warm phase. The canonical entry step of any phase is { "action": "complete", "name": "complete" } under that phase name, which tells ILM to begin evaluating the phase from its first action:
POST _ilm/move/events-000004
{
"current_step": {
"phase": "hot",
"action": "complete",
"name": "complete"
},
"next_step": {
"phase": "warm",
"action": "complete",
"name": "complete"
}
}ILM validates that events-000004 is genuinely on the hot complete step, then re-enters it at the head of the warm phase, running the warm actions (allocate, shrink, forcemerge) in their fixed internal order. If the index had already advanced to warm on its own, the call would return an error rather than silently re-running anything.
3. Drive it from Python (v8)
For a runbook you can re-run safely, wrap the read-then-move sequence so the current_step is always taken from live state, never hard-coded:
import logging
from elasticsearch import Elasticsearch, ApiError
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger("ilm_move")
def move_phase(es: Elasticsearch, index: str, to_phase: str) -> bool:
"""Advance `index` to the head of `to_phase`, reading current_step live."""
explain = es.ilm.explain_lifecycle(index=index)
meta = explain["indices"][index]
if not meta.get("managed"):
logger.error("%s is not managed by ILM; nothing to move.", index)
return False
if meta.get("step") == "ERROR":
# A move is not the tool for an ERROR step — retry re-runs the failed step.
logger.error("%s is in ERROR; fix the cause and retry, do not move.", index)
return False
current_step = {
"phase": meta["phase"],
"action": meta["action"],
"name": meta["step"],
}
next_step = {"phase": to_phase, "action": "complete", "name": "complete"}
try:
es.ilm.move_to_step(index=index, current_step=current_step, next_step=next_step)
logger.info("Moved %s from %s to phase %s.", index, current_step, to_phase)
return True
except ApiError as exc:
# Most commonly: current_step no longer matches (index advanced since we read it).
logger.error("Move rejected (%s): %s", exc.status_code, exc.info)
return False
if __name__ == "__main__":
es = Elasticsearch("https://es-cluster-01:9200",
api_key="YOUR_MANAGE_ILM_API_KEY", verify_certs=True)
move_phase(es, "events-000004", "warm")Because the function reads current_step from explain_lifecycle on every call, a race — where the index advanced between your read and the move — surfaces as a clean ApiError rather than an accidental transition.
Verification
Confirm the move took effect by re-reading the explain output. The index should now report the target phase and be working through its actions (or already complete within it):
GET events-000004/_ilm/explain?human{
"indices": {
"events-000004": {
"index": "events-000004",
"managed": true,
"policy": "events-ilm",
"phase": "warm",
"action": "allocate",
"step": "check-allocation",
"age": "2.1d"
}
}
}The phase has flipped to warm and the index is executing the first warm action. If instead you see step: "ERROR" after the move, the transition ran but the target phase’s first action failed — read step_info for the cause; a move does not bypass the allocation or capacity checks the destination phase still enforces.
Gotchas and edge cases
current_stepmust match exactly, or the request errors. All three keys —phase,action,name— have to equal what_ilm/explainreports at the moment of the call. If the index advanced on its own poll tick between your read and your move, ILM rejects the request. Always read the step immediately before moving, and never hard-code it from a previous run.- Do not skip destructive-prep steps. Actions within a phase run in a fixed internal order for a reason — allocation and priority are set before
shrinkandforcemergeso those destructive actions operate on correctly-placed shards. Jumping straight to a later step can run a shrink against shards that were never co-located, corrupting the transition. Move to the head of a phase (action: complete,name: complete), not into its middle, unless you have a specific reason and understand the order. - Move is not retry.
POST _ilm/moverelocates a healthy index on a non-ERROR step to a different step.POST /<index>/_ilm/retryre-runs the failed step of an index sitting inERROR. Using move to escape anERRORstep skips the failed work rather than completing it, and often re-entersERRORimmediately because the underlying cause is untouched. Recovering a genuinely stuck step is covered in recovering a stuck ILM step. - Prefer fixing the root cause over forcing the transition. A manual move treats a symptom. If an index is parked because a tier is over its high watermark or a template is misconfigured, the move relocates the problem to the next index rather than solving it. Reserve moves for legitimate operational skips (draining a node, an emergency migration), not as a standing workaround for a broken policy.
FAQ
What is the difference between _ilm/move and _ilm/retry?
_ilm/move sends a healthy index that is not in ERROR to a different step you specify — it is a deliberate override of where the state machine currently sits. _ilm/retry re-runs the step that just failed on an index sitting in the ERROR step, after you have fixed the underlying cause. Move is for skipping ahead; retry is for recovering a stall. Using move to escape an ERROR step skips the failed work and usually re-enters ERROR.
Why does my move request return an error about the current step?
Because current_step no longer matches where the index actually is. ILM validates all three keys — phase, action, name — against live state and rejects the move if the index has advanced since you read it, which happens when a poll tick fires between your _ilm/explain call and your move. Re-read the step with GET <index>/_ilm/explain and issue the move again with the fresh values.
Can I move an index backwards to an earlier phase?
Technically _ilm/move will accept a next_step in an earlier phase, but it is almost never safe — later phases apply irreversible changes such as readonly, reduced replicas, or a searchable-snapshot mount, and moving backward does not undo them. Treat moves as forward-only in practice. If you need to re-process data, reindex it into a fresh index under the policy rather than trying to rewind the lifecycle.
Related
- Alerting on ILM Errors with Kibana Stack Monitoring — detect the parked or halted index before you move it.
- Monitoring ILM Health with the _cat and Explain APIs — the explain output this runbook reads its
current_stepfrom. - Recovering a Stuck ILM Step — the retry path for an
ERRORstep, which a move must not replace.
← Back to Monitoring ILM Health · ILM Architecture & Fundamentals