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 (hot → warm → cold → frozen → delete) 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:
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.
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:
{
"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.
{
"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 (7d→9d, 30d→32d, 365d→367d). 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.
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"
}
}
}
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:
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.
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.
Related
- Migrating Elasticsearch ILM policies to OpenSearch ISM — the step-by-step conversion procedure with a full before/after JSON pair.
- Index Lifecycle Basics — the state/transition model the ILM phases translate into.
- ISM Policy Actions Comparison — choosing the right ISM action when an ILM action has no direct twin.
- Hot-Warm-Cold Tier Design — the physical tier layout each migrated state targets.