Migrating Elasticsearch ILM policies to OpenSearch ISM

This procedure converts a single Elasticsearch Index Lifecycle Management (ILM) policy into a working OpenSearch Index State Management (ISM) policy end to end: export the ILM JSON, map each fixed phase and its min_age to a named ISM state plus a min_index_age transition, translate the actions, rebuild the rollover-alias wiring under the OpenSearch setting keys, attach with POST _plugins/_ism/add, and verify the index is managed. It is the hands-on companion to the concept comparison in ISM vs Elasticsearch ILM.

The Elasticsearch _ilm/* calls and phases JSON below appear only as the source you are migrating from — OpenSearch has no _ilm endpoint. Each is paired with its OpenSearch _plugins/_ism/* target so it is always clear which side is which.

Prerequisites

Confirm each item before you start — a skipped rollover-alias rename or an unattached legacy index is the usual reason the first converted policy looks deployed but never advances an index.

Step-by-step procedure

The seven steps go source-out: read the ILM policy, translate the envelope, translate the ages, translate the actions, wire the alias, attach, and verify. Keep the source ILM JSON open the whole time — you translate it field by field.

1. Export the source ILM policy

Pull the policy you are migrating from the Elasticsearch cluster. This is the only _ilm call in the whole procedure and it runs against Elasticsearch, not OpenSearch.

HTTP
GET _ilm/policy/logs-30d
JSON
{
  "logs-30d": {
    "policy": {
      "phases": {
        "hot":  { "min_age": "0ms", "actions": {
          "rollover": { "max_primary_shard_size": "50gb", "max_age": "1d" } } },
        "warm": { "min_age": "7d", "actions": {
          "forcemerge": { "max_num_segments": 1 },
          "allocate": { "require": { "data": "warm" }, "number_of_replicas": 1 } } },
        "delete": { "min_age": "30d", "actions": { "delete": {} } }
      }
    }
  }
}

Gotcha: note the rollover conditions use max_* keys and note the policy’s min_age values before they scroll away — you will re-key both. Record the rollover age you actually observe in production (here assume ~1 day) because the delete/warm ages are measured from that point, not from index creation.

2. Build the ISM state skeleton

Create one ISM state per ILM phase, in order, and declare the entry point with default_state. Leave the actions empty for now — you fill them in step 4. The mapping is one phase to one state, as diagrammed below.

Mapping the three-phase ILM logs-30d policy onto three ISM states Left column, Elasticsearch ILM: a hot phase labelled rollover, a warm phase labelled min_age 7 days, and a delete phase labelled min_age 30 days, connected top to bottom. Right column, OpenSearch ISM: a hot state, a warm state, and a delete state connected by explicit transition arrows labelled min_index_age 8 days and min_index_age 31 days. Dashed horizontal arrows map each ILM phase to the ISM state of the same name. A caption notes that each ISM age equals the ILM min_age plus the one-day rollover offset. ILM: logs-30d ISM: logs-30d min_index_age 8d min_index_age 31d hot rollover warm min_age 7d delete min_age 30d hot warm delete Each ISM min_index_age = ILM min_age + rollover offset (~1 day).
JSON
{
  "policy": {
    "description": "Migrated from ILM logs-30d",
    "default_state": "hot",
    "states": [
      { "name": "hot",    "actions": [], "transitions": [
        { "state_name": "warm",   "conditions": { "min_index_age": "8d" } } ] },
      { "name": "warm",   "actions": [], "transitions": [
        { "state_name": "delete", "conditions": { "min_index_age": "31d" } } ] },
      { "name": "delete", "actions": [] }
    ]
  }
}

Gotcha: default_state is mandatory and must name a state that exists. Omitting it, or pointing it at a state you renamed, fails the PUT with a validation error before any index is touched.

3. Offset the ages from the rollover datum

ILM measures each post-hot min_age from the rollover time; ISM measures min_index_age from index creation. Add the rollover age to every translated condition:

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

With $t_{rollover} \approx 1\text{d}$, the ILM warm at 7d becomes 8d and delete at 30d becomes 31d — already reflected in step 2. If your rollover cadence is not stable, drop age-based transitions entirely and gate on min_size or min_doc_count, which are creation-datum-independent.

Gotcha: skip this offset and every index tiers down and deletes one rollover-interval early. On a busy log cluster that can mean deleting data a full day before its contractual retention — treat the offset as a correctness fix, not a rounding nicety.

4. Translate the actions into each state

Move each ILM phase’s actions into the matching ISM state, renaming as you go: forcemergeforce_merge, allocateallocation (plus a separate replica_count for the replica change), rollover max_*min_*.

HTTP
PUT _plugins/_ism/policies/logs-30d
{
  "policy": {
    "description": "Migrated from ILM logs-30d",
    "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": "8d" } } ]
      },
      {
        "name": "warm",
        "actions": [
          { "force_merge": { "max_num_segments": 1 } },
          { "allocation": { "require": { "data": "warm" } } },
          { "replica_count": { "number_of_replicas": 1 } }
        ],
        "transitions": [ { "state_name": "delete", "conditions": { "min_index_age": "31d" } } ]
      },
      { "name": "delete", "actions": [ { "delete": {} } ] }
    ]
  }
}
Text
{ "_id": "logs-30d", "_version": 1, "policy": { ... } }

Gotcha: ILM’s allocate folded a replica change into one action; ISM does not — the number_of_replicas has to become its own replica_count action or the replica count silently never changes. If your ILM policy used searchable_snapshot, there is no drop-in ISM action; snapshot with the snapshot action and mount searchably as a separate step.

5. Rebuild the rollover-alias wiring

ILM reads index.lifecycle.rollover_alias; ISM reads plugins.index_state_management.rollover_alias. Re-key the template and bootstrap the write index so the rollover action can find its alias.

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

Gotcha: if you copy the Elasticsearch template unchanged, index.lifecycle.rollover_alias and index.lifecycle.name are inert on OpenSearch — the policy never attaches and rollover fails with a missing-alias error. Both keys must be replaced with their plugins.index_state_management.* equivalents.

6. Attach the policy to existing indices

The template auto-attaches only to indices created after it exists. Legacy indices that predate the migration must be attached explicitly — a step ILM never required because it attached at creation.

HTTP
POST _plugins/_ism/add/logs-2026.07.*
{ "policy_id": "logs-30d" }
Text
{ "updated_indices": 12, "failures": false, "failed_indices": [] }

Gotcha: "failures": true with a populated failed_indices usually means an index is already managed by another policy — remove the old attachment with POST _plugins/_ism/remove/<index> first, then re-add.

7. Confirm the attach took

Run the explain endpoint immediately; the freshly attached indices should report the policy id and a non-failed action. Full verification is the next section.

HTTP
GET _plugins/_ism/explain/logs-2026.07.01

Verification

Confirm three things: the policy exists, every target index is managed by it, and no index is stuck in a failed step.

Shell
# 1. The translated policy is present and parseable.
curl -s "https://<cluster>:9200/_plugins/_ism/policies/logs-30d?pretty"
# -> "_id": "logs-30d", "policy": { "default_state": "hot", "states": [ ... ] }
Shell
# 2. Each index reports the policy id and its current state.
curl -s "https://<cluster>:9200/_plugins/_ism/explain/logs-*?pretty"
# "logs-2026.07.01": {
#   "index.plugins.index_state_management.policy_id": "logs-30d",
#   "state": { "name": "warm" }, "action": { "name": "allocation", "failed": false } }
Shell
# 3. Nothing is stranded in a failed step.
curl -s "https://<cluster>:9200/_plugins/_ism/explain/logs-*" | grep -c '"failed": true'
# -> 0    (any non-zero count needs a retry after fixing the cause)

A healthy migration shows every index with a non-null policy_id, a state.name consistent with its age, and "failed": false. An index with a null policy_id was never attached (repeat step 6 for it); an index with "failed": true hit an action error (fix the cause, then POST _plugins/_ism/retry/<index>).

Common failures

Symptom Root cause Fix command
PUT policy rejected: default_state missing ILM had no explicit entry point; ISM requires one Add "default_state": "hot" and re-PUT _plugins/_ism/policies/logs-30d
Indices delete/tier one interval early min_index_age copied from ILM min_age without the rollover offset Add $t_{rollover}$ to each condition, then POST _plugins/_ism/change_policy/logs-*
rollover action fails: alias not found Template still uses index.lifecycle.rollover_alias Set plugins.index_state_management.rollover_alias and rebootstrap the write index
Legacy indices stay unmanaged Template only attaches to newly created indices POST _plugins/_ism/add/<pattern> {"policy_id":"logs-30d"}
Replica count never changes in warm ILM allocate replica change dropped in translation Add a replica_count action to the warm state
add returns failed_indices Index already managed by a different policy POST _plugins/_ism/remove/<index> then re-add

Frequently asked questions

Do I run any part of this against the Elasticsearch cluster?

Only step 1 — GET _ilm/policy/<name> — reads the source policy from Elasticsearch. Every other call targets OpenSearch on _plugins/_ism/*. Once you have the exported ILM JSON, the migration is entirely an OpenSearch operation.

How do I pick the rollover offset if my rollover cadence varies?

If rollover is irregular, do not bake a wall-clock offset into min_index_age at all. Convert the age-based transitions to min_size or min_doc_count conditions, which are measured from the index itself and do not depend on when it rolled. That removes the creation-vs-rollover datum mismatch entirely.

What if the ILM policy used a searchable_snapshot action?

There is no one-to-one ISM action. Use the ISM snapshot action to write the snapshot to a repository, then mount it as a searchable snapshot as a separate OpenSearch operation outside the policy, and gate the ISM delete on that snapshot completing. Plan design time for it rather than expecting a drop-in.

Can I migrate many policies at once?

Yes — script the translate/deploy/attach/verify loop with opensearch-py. The parent page ISM vs Elasticsearch ILM includes a reusable IsmMigrator helper that upserts each policy, attaches it to legacy indices, and flags any that stay unmanaged.

Up: ISM vs Elasticsearch ILM