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-ilm policy attached to the target index (managed: true).
  • elasticsearch-py v8.0+ for the programmatic path (ilm.move_to_step, ilm.explain_lifecycle).
  • Cluster privilege manage_ilm on 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, and name of the step the index currently occupies, read fresh from _ilm/explain immediately 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_step must match exactly, or the request errors. All three keys — phase, action, name — have to equal what _ilm/explain reports 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 shrink and forcemerge so 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/move relocates a healthy index on a non-ERROR step to a different step. POST /<index>/_ilm/retry re-runs the failed step of an index sitting in ERROR. Using move to escape an ERROR step skips the failed work rather than completing it, and often re-enters ERROR immediately 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.

← Back to Monitoring ILM Health · ILM Architecture & Fundamentals