ISM vs Elasticsearch ILM

OpenSearch Index State Management (ISM) and Elasticsearch Index Lifecycle Management (ILM) solve the same problem — age out indices through hot, warm, and cold tiers and eventually delete them — but they use fundamentally different models: ISM builds a graph of arbitrarily-named states joined by explicit transitions, while ILM runs an index through a fixed, ordered set of phases (hotwarmcoldfrozendelete) each gated by a single min_age. If your team is migrating a logging or observability platform from Elasticsearch to OpenSearch, the day-one question is not “which is better” but “how does each ILM concept translate, and which translations silently change behaviour.” This page maps the two models field by field, shows where they diverge (rollover-relative ages, searchable snapshots, auto-attach), and gives an opensearch-py helper plus a troubleshooting table for the mistakes that break the first migration. It sits within OpenSearch ISM Architecture & Fundamentals; the full conversion procedure lives in the child guide, Migrating Elasticsearch ILM policies to OpenSearch ISM.

Throughout this page, Elasticsearch _ilm/* endpoints and phase JSON appear only for contrast, always paired with the OpenSearch _plugins/_ism/* equivalent. In production on OpenSearch you never call _ilm — it does not exist. The ILM samples show you what you are translating from.

Concept-model comparison

The single most important difference is structural. ILM gives you five named boxes in a fixed order and lets you turn each one on or off; ISM gives you an empty state machine and asks you to name every state and draw every edge yourself. That freedom is why ISM has no built-in notion of “the warm phase” — warm is just a state name you chose, and its meaning comes entirely from the allocation action you put inside it. The tier semantics you are reproducing are covered in Index Lifecycle Basics, and the physical hot/warm/cold layout each state targets is covered in Hot-Warm-Cold Tier Design.

Dimension Elasticsearch ILM OpenSearch ISM Migration impact
Model Fixed phases: hot, warm, cold, frozen, delete Arbitrary named states + explicit transitions You invent the states; one ILM phase usually becomes one ISM state
Progression trigger Each phase has one min_age Each transition carries conditions (min_index_age, min_size, min_doc_count, cron) Richer conditions, but no automatic phase ordering — you draw every edge
Age datum min_age is relative to rollover for post-hot phases (creation if never rolled) min_index_age is always relative to index creation The number-one silent behaviour change (see below)
Policy endpoint PUT _ilm/policy/<name> PUT _plugins/_ism/policies/<id> Different path, different JSON envelope (phases vs states)
Explain GET <index>/_ilm/explain GET _plugins/_ism/explain/<index> Field names differ (phase/action/step vs state/action)
Attach Template index.lifecycle.name (auto on create) Template plugins.index_state_management.policy_id or explicit POST _plugins/_ism/add/<index> Existing indices need an explicit add; ILM auto-applied
Entry point Implicit — starts at hot Explicit default_state field Must be declared; a missing default_state is a validation error
Global pause POST _ilm/stop / _ilm/start Per-index + plugins.index_state_management.job_interval sweep No cluster-wide “pause ILM” switch; you throttle the sweep or remove policies
Searchable snapshot searchable_snapshot action mounts a snapshot as a searchable index snapshot action only takes a snapshot; searchable snapshots are a separate mount step Not a 1:1 action — the biggest action-level gap

The age-datum trap

ILM measures min_age for every phase after hot from the moment the index rolled over (stopped being the write index), not from when it was created. ISM’s min_index_age is measured from index creation, full stop. So an ILM policy that rolls the write index at 2 days and then moves to warm at min_age: 7d actually relocates the index roughly 9 days after creation (2 days as write index + 7 days after rollover). A naive translation to an ISM transition with min_index_age: "7d" moves it at day 7 — two days early. The correct ISM value is the rollover age plus the ILM min_age:

min_index_ageISM=trollover+min_ageILM\text{min\_index\_age}_{\text{ISM}} = t_{\text{rollover}} + \text{min\_age}_{\text{ILM}}

If your rollover cadence is not fixed, prefer size- or doc-count-based transitions (min_size, min_doc_count) so the state machine does not depend on a wall-clock assumption at all. The child migration guide walks this arithmetic per phase.

Phase-to-state mapping

The diagram below is the mental model to internalise before touching JSON: each ILM phase collapses into exactly one ISM state, the phase’s min_age becomes the incoming transition’s condition on the ISM side, and the phase’s action list moves inside the state. ILM’s implicit “next phase” ordering becomes an explicit transitions edge you must write.

Mapping ILM fixed phases to ISM named states and transitions Two columns. The left column is the Elasticsearch ILM model: five stacked phase boxes — hot (rollover), warm (min_age 7 days), cold (min_age 30 days), frozen (min_age 90 days), and delete (min_age 365 days) — connected top to bottom by the implicit phase order. The right column is the OpenSearch ISM model: five stacked state boxes with the same names, each joined to the next by an explicit transition arrow labelled with a min_index_age condition. Horizontal arrows map each ILM phase to the ISM state of the same name. A footnote records that ILM min_age is relative to rollover whereas ISM min_index_age is relative to index creation, so equivalent numbers differ by the rollover age. Elasticsearch ILM phases · min_age OpenSearch ISM states · transitions min_index_age 9d min_index_age 32d min_index_age 92d min_index_age 367d hot rollover warm min_age 7d cold min_age 30d frozen min_age 90d delete min_age 365d hot warm cold frozen delete ILM min_age is measured from rollover; ISM min_index_age is measured from index creation. Equivalent values differ by the rollover age (here ~2 days). One phase → one state · implicit order → explicit transitions

Endpoint equivalence

Every ILM operational call has a direct ISM counterpart on a different path. The table pairs them so you can rewrite runbooks mechanically. Remember: on OpenSearch, the left column is dead — it returns 404. It is here only to show the mapping.

Operation Elasticsearch ILM (contrast only) OpenSearch ISM (use this)
Create/update policy PUT _ilm/policy/logs PUT _plugins/_ism/policies/logs
Read policy GET _ilm/policy/logs GET _plugins/_ism/policies/logs
Attach to an existing index (implicit via template only) POST _plugins/_ism/add/logs-000001
Explain index status GET logs-000001/_ilm/explain GET _plugins/_ism/explain/logs-000001
Retry a failed step POST logs-000001/_ilm/retry POST _plugins/_ism/retry/logs-000001
Move / change policy POST _ilm/move/logs-000001 POST _plugins/_ism/change_policy/logs-000001
Detach policy POST logs-000001/_ilm/remove POST _plugins/_ism/remove/logs-000001

1. Translating the policy envelope

Start from an ILM policy and rebuild it as an ISM policy. Here is a representative ILM policy that rolls the write index, force-merges and allocates to warm at 7 days, moves to cold at 30 days, and deletes at 365 days:

JSON
{
  "policy": {
    "phases": {
      "hot": {
        "min_age": "0ms",
        "actions": {
          "rollover": { "max_primary_shard_size": "50gb", "max_age": "1d" },
          "set_priority": { "priority": 100 }
        }
      },
      "warm": {
        "min_age": "7d",
        "actions": {
          "forcemerge": { "max_num_segments": 1 },
          "allocate": { "require": { "data": "warm" }, "number_of_replicas": 1 }
        }
      },
      "cold": {
        "min_age": "30d",
        "actions": { "allocate": { "require": { "data": "cold" } } }
      },
      "delete": {
        "min_age": "365d",
        "actions": { "delete": {} }
      }
    }
  }
}

The equivalent ISM policy names one state per phase, moves each phase’s min_age onto the incoming transition as min_index_age, and moves each action into the state body. Note the envelope change: ILM phases (a map keyed by phase name) becomes ISM states (an ordered array of objects with a name), and ILM’s forcemerge/allocate action names become ISM’s force_merge/allocation.

JSON
{
  "policy": {
    "description": "Migrated from ILM 'logs' policy",
    "default_state": "hot",
    "states": [
      {
        "name": "hot",
        "actions": [
          { "rollover": { "min_primary_shard_size": "50gb", "min_index_age": "1d" } }
        ],
        "transitions": [
          { "state_name": "warm", "conditions": { "min_index_age": "9d" } }
        ]
      },
      {
        "name": "warm",
        "actions": [
          { "force_merge": { "max_num_segments": 1 } },
          { "allocation": { "require": { "data": "warm" } } },
          { "replica_count": { "number_of_replicas": 1 } }
        ],
        "transitions": [
          { "state_name": "cold", "conditions": { "min_index_age": "32d" } }
        ]
      },
      {
        "name": "cold",
        "actions": [
          { "allocation": { "require": { "data": "cold" } } }
        ],
        "transitions": [
          { "state_name": "delete", "conditions": { "min_index_age": "367d" } }
        ]
      },
      {
        "name": "delete",
        "actions": [ { "delete": {} } ]
      }
    ]
  }
}

Three things moved that are easy to miss. First, the transition ages gained the ~2-day rollover offset (7d9d, 30d32d, 365d367d). Second, ILM’s allocate bundled a replica change (number_of_replicas) into one action; ISM splits that into a separate replica_count action. Third, ILM’s set_priority has an ISM analogue (index_priority) but it is optional and frequently dropped in a first migration — reproduce it only if you actually rely on recovery ordering.

2. Rollover-alias wiring

Both systems bootstrap a write index behind an alias and roll it, but the setting names differ and are not interchangeable. ILM reads index.lifecycle.rollover_alias; ISM reads plugins.index_state_management.rollover_alias. If you copy an Elasticsearch template unchanged, ISM never finds a rollover alias and the rollover action fails with a missing-alias error.

HTTP
PUT _index_template/logs-template
{
  "index_patterns": ["logs-*"],
  "template": {
    "settings": {
      "index.number_of_shards": 3,
      "index.routing.allocation.require.data": "hot",
      "plugins.index_state_management.rollover_alias": "logs-write",
      "plugins.index_state_management.policy_id": "logs"
    }
  }
}
HTTP
PUT logs-000001
{ "aliases": { "logs-write": { "is_write_index": true } } }

The is_write_index: true bootstrap and the sweep interval (plugins.index_state_management.job_interval) are unchanged concepts from ILM; only the setting keys differ. The full rollover trigger design is covered in Rollover Trigger Configuration.

3. Attach mechanism

This is where ILM and ISM behave differently at runtime, not just in naming. ILM attaches through the index template’s index.lifecycle.name, and only through templates — the policy applies automatically the moment a matching index is created and there is no per-index attach call. ISM supports the template path too (plugins.index_state_management.policy_id, and newer builds honour an ism_template block inside the policy itself), but it also exposes an explicit attach for indices that already exist:

HTTP
POST _plugins/_ism/add/logs-2026.07.*
{ "policy_id": "logs" }

The practical consequence for migration: indices that predate the policy do not pick it up retroactively on OpenSearch. After you create the ISM policy and template, you must run one add pass over the already-existing indices. On Elasticsearch this problem does not arise because ILM was always attached at creation. Bulk-attaching thousands of pre-existing indices is its own workflow.

4. Action parity

Most actions map cleanly; a few do not. Use this as the per-action checklist.

Action ILM (contrast only) ISM Parity notes
Rollover rollover (max_* conditions) rollover (min_* conditions) Direct; condition keys are min_ on ISM
Force merge forcemerge {max_num_segments} force_merge {max_num_segments:1} Direct; underscore in the name
Shrink shrink {number_of_shards} shrink {num_new_shards} Direct; different field name
Allocate allocate (require/include/exclude + replicas) allocation + separate replica_count Replica change splits into its own action
Delete delete delete Direct
Searchable snapshot searchable_snapshot (mounts snapshot) snapshot (takes snapshot only) No 1:1 — mount is a separate step on OpenSearch
Read-only readonly read_only Direct
Priority set_priority index_priority Direct; optional
CCR unfollow unfollow (no action; manage via _plugins/_replication) Handle follower lifecycle out-of-band
Downsample downsample rollup (different config) Reproduce logic manually, not a copy

The searchable_snapshot gap is the one to plan around. On Elasticsearch a single ILM action snapshots the index and remounts it as a low-cost searchable index in the cold or frozen phase. OpenSearch’s ISM snapshot action only writes a snapshot to a repository; making that snapshot searchable is a separate feature you invoke outside the policy. If your ILM policy leans on searchable_snapshot for cold/frozen economics, budget design time for the OpenSearch searchable-snapshot flow rather than expecting a drop-in action. Deciding between compaction actions like force_merge and shrink is covered in ISM Policy Actions Comparison.

Production automation: migrate and verify with opensearch-py

Hand-porting one policy is fine; porting a fleet is not. This opensearch-py helper deploys a translated ISM policy, attaches it to a set of already-existing indices (the retroactive gap ILM never had), and verifies each index reached a managed state. It uses structured logging and narrow exception handling so it is safe to run from CI. Scope its service account per Security & Access Boundaries to just the ISM admin actions on the target patterns.

Python
import logging
from opensearchpy import OpenSearch, exceptions

logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger("ilm-to-ism")


class IsmMigrator:
    def __init__(self, client: OpenSearch):
        self.client = client

    def upsert_policy(self, policy_id: str, policy_body: dict) -> bool:
        """Create or replace the translated ISM policy (idempotent)."""
        try:
            self.client.transport.perform_request(
                "PUT", f"/_plugins/_ism/policies/{policy_id}", body=policy_body
            )
            logger.info("policy %s deployed", policy_id)
            return True
        except exceptions.RequestError as exc:
            # 409 = version conflict; policy already at this version is acceptable.
            if getattr(exc, "status_code", None) == 409:
                logger.info("policy %s already current", policy_id)
                return True
            logger.error("failed to deploy %s: %s", policy_id, exc)
            return False

    def attach(self, policy_id: str, index_pattern: str) -> None:
        """Retroactively attach to indices that predate the policy (no ILM analogue)."""
        try:
            self.client.transport.perform_request(
                "POST", f"/_plugins/_ism/add/{index_pattern}",
                body={"policy_id": policy_id},
            )
            logger.info("attached %s to %s", policy_id, index_pattern)
        except exceptions.TransportError as exc:
            logger.error("attach failed for %s: %s", index_pattern, exc)

    def verify(self, index_pattern: str) -> bool:
        """Confirm every matched index is managed and not in a failed step."""
        explain = self.client.transport.perform_request(
            "GET", f"/_plugins/_ism/explain/{index_pattern}"
        )
        ok = True
        for index, info in explain.items():
            if index.startswith("total_"):
                continue
            policy = info.get("index.plugins.index_state_management.policy_id")
            failed = (info.get("action") or {}).get("failed", False)
            if not policy or failed:
                logger.warning("UNMANAGED/FAILED: %s policy=%s failed=%s",
                               index, policy, failed)
                ok = False
        return ok


if __name__ == "__main__":
    client = OpenSearch(
        hosts=[{"host": "localhost", "port": 9200}],
        http_auth=("admin", "admin"), use_ssl=True, verify_certs=True,
    )
    mig = IsmMigrator(client)
    if mig.upsert_policy("logs", TRANSLATED_POLICY):  # TRANSLATED_POLICY from step 1
        mig.attach("logs", "logs-2026.07.*")
        mig.verify("logs-*")

The verify step is the one ILM users forget, because on Elasticsearch attachment was automatic and total. On OpenSearch, an index that existed before the add call — or that matched no template — sits silently unmanaged until you explicitly attach it. Treat a non-empty “UNMANAGED/FAILED” list as a failed migration, not a warning.

Operational guardrails

The knobs that govern how aggressively each system advances an index have different names and defaults. Align them so a migrated policy behaves the way the ILM one did.

Concern ILM setting (contrast) ISM setting (use this) Guidance
Evaluation cadence indices.lifecycle.poll_interval (10m) plugins.index_state_management.job_interval (5m) Match cadence so ages fire at similar wall-clock times
Rollover alias index.lifecycle.rollover_alias plugins.index_state_management.rollover_alias Rename in every template
Policy setting on index index.lifecycle.name plugins.index_state_management.policy_id Rename in every template
Retry on failure phase step auto-retry retry block in the action + POST _plugins/_ism/retry Add explicit retry blocks; ISM does not infinitely auto-retry
Disk watermarks cluster.routing.allocation.disk.watermark.* same keys Unchanged; tier moves still block past flood_stage

Because ISM has no global _ilm/stop equivalent, the safest way to pause lifecycle work during a migration cutover is to raise job_interval temporarily (slowing the sweep) or to hold off attaching policies until the fleet is stable — not to try to freeze an in-flight state machine.

Troubleshooting common migration mistakes

Failure mode Diagnosis command Fix command
Policy PUT rejected with default_state error GET _plugins/_ism/policies/logs Add a default_state naming an existing state, then re-PUT
Old indices never advance (still unmanaged) GET _plugins/_ism/explain/logs-* (no policy_id) POST _plugins/_ism/add/logs-* {"policy_id":"logs"}
rollover action fails: missing alias GET _plugins/_ism/explain/logs-000001 Set plugins.index_state_management.rollover_alias (not index.lifecycle.rollover_alias) and rebootstrap the write index
Indices tier down ~2 days early GET _plugins/_ism/explain/logs-000001 (check state) Add the rollover offset to each min_index_age, then POST _plugins/_ism/change_policy/logs-*
searchable_snapshot action “unknown” Policy PUT validation error Replace with snapshot + a separate searchable-snapshot mount step
Copied ILM template applies no policy GET _index_template/logs-template Replace index.lifecycle.name with plugins.index_state_management.policy_id

Frequently asked questions

Can I run an Elasticsearch ILM policy directly on OpenSearch?

No. OpenSearch has no _ilm endpoint, does not read index.lifecycle.* settings, and rejects the phases JSON envelope. You must translate the policy into an ISM states/transitions document and re-key the index settings. The _ilm/* samples on this page exist only to show what you are translating from.

Why do my migrated indices change tiers earlier than they did under ILM?

Because ILM measures min_age for post-hot phases from the rollover time, while ISM’s min_index_age is measured from index creation. A 7-day ILM warm phase on an index that rolled at day 2 fires around day 9; the same 7d as an ISM min_index_age fires at day 7. Add the rollover age to each transition condition, or switch to min_size/min_doc_count conditions that do not depend on wall-clock ages.

Do I need to re-attach the policy to indices that already exist?

Yes. Under ILM, attachment happened automatically at index creation, so there was never a retroactive step. On OpenSearch, a template with policy_id only affects indices created after the template exists; anything older is unmanaged until you run POST _plugins/_ism/add/<pattern>. Verify with GET _plugins/_ism/explain that every index reports a policy_id.

What happens to my ILM searchable-snapshot phases?

They do not translate one-to-one. ISM’s snapshot action only writes a snapshot to a repository; it does not remount it as a searchable index the way ILM’s searchable_snapshot does. Reproduce the cold/frozen economics using OpenSearch searchable snapshots as a separate step outside the policy, and gate the ISM delete on that snapshot completing.

Up: OpenSearch ISM Architecture & Fundamentals