Retention is where logging clusters get expensive. Keeping 13 months of data on the same fast disks, with the same replica count, as the last 24 hours means paying hot prices for data that is searched a few times a month. Data tiers fix that by giving each age of data the hardware it deserves, and ILM moves indices between tiers on a schedule. Searchable snapshots go further: the cold tier can drop replicas because the snapshot is the redundancy, and the frozen tier keeps only a cache locally while the data lives in object storage. This article explains how tier allocation works, what ILM actually does at each phase, the difference between fully and partially mounted indices, and how to compute whether frozen is worth it for your workload.
The mental model: tiers are node roles, movement is a setting
A data tier is nothing more than a set of nodes with a tier role: data_hot, data_warm, data_cold, data_frozen, plus data_content for non-time-series data. Where an index's shards may live is controlled by one index setting:
index.routing.allocation.include._tier_preference
It holds an ordered list of tiers. The allocator places the index's shards on the first tier in the list that has at least one node. So data_warm,data_hot means "warm if a warm tier exists, otherwise hot". That fallback is what lets the same ILM policy run on a cluster with no warm tier at all.
The defaults:
- A data stream backing index is created with
_tier_preference: data_hot. - A regular index is created with
_tier_preference: data_content.
ILM moves data by changing that setting. The migrate action in the warm phase sets it to data_warm,data_hot; in the cold phase, data_cold,data_warm,data_hot. (A cold phase that uses searchable_snapshot skips migrate and mounts the index directly on the cold tier; a frozen mount gets data_frozen.) The allocator then relocates the shards, exactly as it would after any allocation setting change. Moving between tiers is ordinary shard relocation, with ordinary relocation costs: network, disk I/O, and recovery throttling.
The node configuration that makes the tiers exist:
# hot node
node.roles: [ data_hot, data_content, ingest ]
# warm node
node.roles: [ data_warm ]
# cold node
node.roles: [ data_cold ]
# frozen node
node.roles: [ data_frozen ]
Check what tiers you have and what each index prefers:
GET _cat/nodes?v&h=name,node.role,disk.total,disk.used_percent,heap.max&s=node.role
GET _all/_settings/index.routing.allocation.include._tier_preference?flat_settings=true
What each phase is for
ILM divides an index's life into phases: hot, warm, cold, frozen and delete. Phases are about what the data needs, tiers are about where it runs. The two usually line up, but they are not the same thing: a phase can run on any tier that its tier preference allows.
| Phase | Typical tier | What the data needs | Typical actions |
|---|---|---|---|
| hot | data_hot |
Indexing and frequent search | rollover, set_priority, optional forcemerge or shrink |
| warm | data_warm |
Read-only, still queried regularly | migrate (implicit), shrink, forcemerge, allocate (replicas), readonly, set_priority, downsample |
| cold | data_cold |
Rarely queried, must stay searchable | migrate (implicit, unless searchable_snapshot is present), searchable_snapshot (fully mounted), allocate, set_priority, readonly, downsample |
| frozen | data_frozen |
Almost never queried, long retention | searchable_snapshot (partially mounted); unfollow is the only other action allowed |
| delete | n/a | Gone | wait_for_snapshot, delete |
A few behaviors that trip people up:
- The
migrateaction is injected automatically into the warm and cold phases. It is not injected in a cold phase that containssearchable_snapshot(the index is mounted directly on the target tier), and you can turn it off with"migrate": { "enabled": false }. Anallocateaction withinclude,excludeorrequirefilters does not suppress it: a policy written for attribute-based "hot/warm" (the pre-tier style withnode.attr.box_type) then applies both the attribute filter and the tier preference, and the shards need a node that satisfies both. If you keep attribute-based allocation, disablemigrateexplicitly in those phases. - The frozen phase only accepts
searchable_snapshot(andunfollow, for follower indices). There is no frozen phase without a snapshot repository. - The old
freezeaction is gone in practice. Frozen indices in the 7.x sense (the_freezeAPI) were deprecated and removed; the ILMfreezeaction is still accepted in the cold phase for compatibility but does nothing. The 8.x frozen tier means partially mounted searchable snapshots. min_ageis measured from rollover, not from index creation, for indices that roll over. A warmmin_ageof7dmeans seven days after the index stopped being the write index. For indices that never roll over, it is from creation, unless you setindex.lifecycle.origination_date.- ILM checks on a schedule.
indices.lifecycle.poll_intervaldefaults to10m. A phase transition can happen up to that long aftermin_ageis reached; do not build alerting that expects minute precision.
Searchable snapshots: fully versus partially mounted
A searchable snapshot index is an index whose files live in a snapshot repository and which Elasticsearch searches in place, without a full restore. There are two kinds.
Fully mounted (the cold phase)
A fully mounted index keeps a complete copy of the shard data on the local disk of the node holding it, populated from the snapshot. Searches read local disk, so performance is close to a regular index once the copy is complete. The key difference is redundancy: the snapshot in the repository is the backup, so a fully mounted index does not need replicas. If the node holding it fails, the shard is recovered from the repository rather than from a peer.
Storage arithmetic: one local copy plus one copy in object storage, instead of two local copies.
ILM names the mounted index with a restored- prefix, for example restored-.ds-logs-app-default-2026.07.01-000042, and swaps it into the data stream or alias in place of the original.
Partially mounted (the frozen phase)
A partially mounted index stores only a cache of recently used parts of its files on local disk, in a shared cache that all partially mounted indices on the node use. Everything else is fetched from the repository on demand when a search needs it. A frozen node can therefore hold far more data than its local disk.
The shared cache is sized with xpack.searchable.snapshot.shared_cache.size. On a dedicated frozen node (one with only the data_frozen role), it defaults to 90% of the disk, with a default xpack.searchable.snapshot.shared_cache.size.max_headroom of 100 GB of free space reserved when you have not set the size explicitly. On other nodes it defaults to 0, which is another reason to run frozen nodes with nothing else.
ILM names partially mounted indices with a partial- prefix.
Performance: the first search that touches a cold region of a partially mounted shard waits for object storage. Subsequent searches on the same data hit the cache. Expect frozen searches to range from near-normal (cache hit) to seconds or more (cache miss across many shards). Use async search for frozen-tier queries from Kibana or applications so that users are not holding an HTTP connection open while data is fetched.
Requirements
- A registered snapshot repository: S3, GCS, Azure Blob Storage, or a shared filesystem.
- A license that includes searchable snapshots. On self-managed clusters this is the Enterprise subscription level; check your license before designing around it.
- The snapshot must not be deleted while an index is mounted from it. ILM's
deleteaction has adelete_searchable_snapshotoption,trueby default, that deletes the backing snapshot when the mounted index is deleted. Keep that behavior unless the same snapshot is also your backup and has its own retention.
The policy
PUT _ilm/policy/logs-tiered
{
"policy": {
"phases": {
"hot": {
"actions": {
"rollover": { "max_primary_shard_size": "50gb", "max_age": "1d" },
"set_priority": { "priority": 100 }
}
},
"warm": {
"min_age": "3d",
"actions": {
"shrink": { "number_of_shards": 1 },
"forcemerge": { "max_num_segments": 1 },
"set_priority": { "priority": 50 }
}
},
"cold": {
"min_age": "14d",
"actions": {
"searchable_snapshot": { "snapshot_repository": "logs-s3" },
"set_priority": { "priority": 0 }
}
},
"frozen": {
"min_age": "45d",
"actions": {
"searchable_snapshot": { "snapshot_repository": "logs-s3" }
}
},
"delete": {
"min_age": "395d",
"actions": { "delete": {} }
}
}
}
}
Notes on this policy:
set_prioritycontrols the order in which indices recover after a node restart: higher first. Hot data comes back before history.- The
searchable_snapshotaction force merges the index to one segment before taking the snapshot by default (force_merge_index: true). That makes the explicit warmforcemergepartly redundant if every index goes on to cold; keep it if warm is long and you want faster warm searches. - An index that was fully mounted in cold and then moves to frozen is re-mounted as a partially mounted
partial-index. ILM takes care of the snapshot, cloning it where needed; a snapshot clone references the same files in the repository, so the data is not stored twice. - Shrink requires a copy of every shard on one node. On a warm tier with little free disk, shrink can stall. Watch
_ilm/explainthe first week.
A cost model you can compute
Prices differ between providers, regions and hardware, so this model is in units. Fill in your own prices per GB-month at the end.
Inputs (stated assumptions)
- Ingest: 500 GB of primary data per day, on disk after indexing.
- Retention: 395 days (13 months).
- Replicas: 1 on hot and warm.
- Phases: hot 3 days, warm to day 14, cold to day 45, frozen to day 395.
- Frozen cache: sized at 10% of the frozen data set (an assumption for a rarely searched archive; set it from your own query patterns).
- Size reduction from shrink and force merge: ignored, to keep the arithmetic conservative.
Data held in each tier at steady state
- Hot: 3 days x 500 GB = 1,500 GB primary, x 2 copies = 3,000 GB local.
- Warm: 11 days x 500 GB = 5,500 GB primary, x 2 = 11,000 GB local.
- Cold (fully mounted, no replica): 31 days x 500 GB = 15,500 GB local, plus 15,500 GB in object storage.
- Frozen: 350 days x 500 GB = 175,000 GB in object storage, plus a cache of 10% = 17,500 GB local.
Object storage total: cold and frozen share the same repository, and the files snapshotted for cold are the ones the frozen mount reads, so total object storage is (31 + 350) x 500 GB = 190,500 GB.
The same retention without cold or frozen
Keeping everything after day 3 on warm with 1 replica: 392 days x 500 GB x 2 = 392,000 GB of local warm disk, plus the hot 3,000 GB.
Compare
| Layout | Local hot | Local warm | Local cold | Local frozen cache | Object storage |
|---|---|---|---|---|---|
| Hot + warm only | 3,000 | 392,000 | 0 | 0 | 0 (plus whatever backups you keep) |
| Hot, warm, cold, frozen | 3,000 | 11,000 | 15,500 | 17,500 | 190,500 |
All figures are GB. The tiered layout holds 47,000 GB of local disk instead of 395,000 GB. Whether that is cheaper depends on one ratio:
saving per month = (local GB avoided x local price) - (object GB added x object price)
= (348,000 x P_local) - (190,500 x P_object)
Object storage is typically much cheaper per GB than provisioned block storage or local SSD, so for most environments this is decisively positive. If you already keep snapshots for disaster recovery, much of that object storage is a cost you are paying anyway, and the saving is larger.
Also count nodes, not just disk. A warm node with 10 TB of disk at a sensible disk usage target holds a fixed amount; 392,000 GB of warm data needs dozens of nodes, each with RAM, CPU and heap you are paying for. Frozen nodes hold many times their local disk, so the node count for the same 13 months is far smaller. Node count is often the larger part of the bill.
When frozen is not right
- The data is searched often across its whole range. Every cache miss pays object storage latency and request cost. If analysts run 12-month queries daily, the cache churns and frozen behaves badly; cold or warm is better for that data.
- Latency requirements are strict. Frozen cannot promise interactive latency on a cold cache.
- You do not have a reliable repository. The repository becomes primary storage for frozen data. Its durability and availability are now your data's.
- Your license does not include searchable snapshots. Then the realistic options are warm with fewer replicas, or snapshots plus restore on demand.
- Retention is short. For 30-day retention, the gain from frozen is small and the operational surface is not.
Choosing a tier per age of data
| Query pattern for this age of data | Recommended tier | Redundancy | Search latency |
|---|---|---|---|
| Indexed now, dashboards every minute | Hot | Replica | Lowest |
| Read-only, queried daily | Warm | Replica | Low |
| Queried weekly, must be reliably fast when queried | Cold, fully mounted | Snapshot | Low to moderate |
| Queried monthly or for audits, latency tolerant | Frozen, partially mounted | Snapshot | Variable, seconds on cache miss |
| Kept only for compliance, rarely or never queried | Snapshot only, restore when needed | Snapshot | Minutes to hours (restore) |
Sizing each tier
Hot is sized for ingest and the most frequent searches, not just storage: CPU for indexing and merging, fast disks, enough nodes to spread the write index's primaries. Size it from indexing throughput first, then confirm disk. See /guides/elasticsearch-indexing-performance/.
Warm is sized for disk with headroom. Plan to stay under the 85% low disk watermark with room for one node's worth of shards to relocate after a failure. Worked example: 11,000 GB on warm nodes with 8,000 GB disks, targeting 70% use (rule of thumb, leaving room below the 85% watermark for relocation): 11,000 / (8,000 x 0.70) = 1.96, so 2 nodes would hold the data, at 11,000 / 16,000 = 69% each. But two nodes cannot survive the loss of one: 11,000 GB will not fit on one 8,000 GB disk. With 3 warm nodes each sits at 11,000 / 24,000 = 46%, and after losing one the survivors hold 5,500 GB each, 69%, still below the 85% low watermark. With forced zone awareness and three zones, a failed node's replicas stay unassigned instead. Pick the trade-off explicitly; see /guides/elasticsearch-cluster-topology/.
Cold with fully mounted indices is sized for one copy. A node failure triggers recovery from the repository, which is slower than peer recovery; size for that recovery time.
Frozen is sized for cache and shard count. The default cluster.max_shards_per_node.frozen is 3,000. Worked example: at 500 GB a day with 5 primaries per backing index and rollover at 50 GB per primary, each backing index holds 250 GB and you roll over twice a day. Without a shrink, 350 days of frozen data is 350 x 2 x 5 = 3,500 frozen shards, more than the 3,000 the default limit allows for a single frozen node (the limit is enforced as 3,000 x the number of frozen nodes). Shrinking to 1 primary in warm brings it to 700 shards of about 250 GB each. Large frozen shards are workable because only the cache is local, but check the document count per shard stays sensible before shrinking that far. The shrink target must be a factor of the source count: 5 primaries can only shrink to 1, while 6 can shrink to 3, 2 or 1, so pick the hot primary count with the later shrink in mind.
GET _cat/shards/partial-*?v&h=index,shard,prirep,store,node&s=index
Tier preference outside ILM
You can set the tier preference by hand, which is useful for migrating legacy indices or pinning an index:
PUT legacy-audit-2025/_settings
{
"index.routing.allocation.include._tier_preference": "data_cold,data_warm,data_hot"
}
Mount a snapshot yourself (for example, to search an old backup without a full restore):
POST _snapshot/logs-s3/snap-2025.12.31/_mount?storage=shared_cache&wait_for_completion=true
{
"index": "logs-app-2025.12",
"renamed_index": "archive-logs-app-2025.12"
}
storage=full_copy is the fully mounted form, storage=shared_cache the partially mounted form. Partially mounted indices are only allocated to nodes that have a shared cache, which by default means dedicated frozen nodes.
Failure modes and anti-patterns
Index stuck in a phase because no node of the target tier exists or has room.
Symptom: _ilm/explain shows the index waiting in the migrate step; shards stay on hot; hot disks fill.
Confirm:
GET .ds-logs-app-default-2026.10.01-000031/_ilm/explain
GET _cluster/allocation/explain
{
"index": ".ds-logs-app-default-2026.10.01-000031",
"shard": 0,
"primary": true
}
The allocation explanation will show the data_tier decider or a disk_threshold decider refusing the target nodes. See /guides/elasticsearch-unassigned-shards/.
Fix: add capacity to the target tier or free disk there. ILM resumes on its own once the shards relocate.
Policies written for attribute-based allocation silently not migrating.
Symptom: shards stay on hot, or go unassigned, after the warm transition, because the old box_type: warm filter and the injected tier preference point at different nodes.
Confirm: the warm phase has allocate.require.box_type and no "migrate": { "enabled": false }; allocation explain shows the filter or data_tier decider saying no.
Fix: move to tier roles and remove the attribute filters from the policy, or keep the attributes and disable migrate explicitly. Do not mix.
Searchable snapshot step failing on repository errors.
Symptom: _ilm/explain shows an error in a step of the searchable_snapshot action; hot or warm disk keeps growing because nothing leaves.
Confirm: POST _snapshot/logs-s3/_verify; check repository credentials and bucket permissions.
Fix: correct the repository, then POST <index>/_ilm/retry. See /guides/elasticsearch-ilm-troubleshooting/.
Deleting snapshots that back mounted indices.
Symptom: frozen or cold searches fail; shards cannot recover after a node restart.
Confirm: compare SLM retention against mounted indices; check which snapshot each mounted index uses via its index.store.snapshot.* settings.
Fix: never let a backup retention policy delete snapshots that ILM mounted; keep ILM's snapshots in their own repository or their own lifecycle.
Shrink stalling on a full warm tier.
Symptom: index stuck in the shrink steps for hours.
Confirm: _ilm/explain, then _cat/allocation for warm disk.
Fix: free warm disk, or move shrink to the hot phase (it runs before migration then, on hot hardware), or drop shrink and accept more frozen shards.
Frozen queries timing out in Kibana. Symptom: dashboards over long ranges fail or are very slow. Fix: use async search; narrow default time ranges; keep frequently used historical data in cold rather than frozen.
Runbook: introducing a frozen tier to an existing hot-warm cluster
- Confirm the license includes searchable snapshots:
GET _license. - Register and verify the repository on the cluster:
PUT _snapshot/logs-s3 { ... }, thenPOST _snapshot/logs-s3/_verify. - Add frozen nodes with
node.roles: [ data_frozen ]and enough local disk for the cache; leavexpack.searchable.snapshot.shared_cache.sizeat its default on dedicated frozen nodes unless you have a reason. - Confirm the nodes joined with the right role:
GET _cat/nodes?v&h=name,node.role. - Write the new policy with a
frozenphase, test it on one non-critical data stream first by pointing that stream's template at the new policy. - Existing backing indices keep their old policy until you update them:
PUT <data-stream>/_settings { "index.lifecycle.name": "logs-tiered" }. ILM applies the new policy from the index's current phase; an index already past the new frozenmin_agewill move as soon as it reaches that phase. - Watch the first transitions with
GET <data-stream>/_ilm/explainand repository traffic. - Run representative long-range queries against the frozen data and record latency on cold and warm cache.
- Only then shorten warm retention and decommission warm nodes, one at a time, waiting for green after each.
Key takeaways
- Tiers are node roles; an index's location is its
_tier_preference, and ILM's implicitmigrateaction changes it in warm and cold. min_agecounts from rollover; ILM polls every 10 minutes by default.- Fully mounted indices (cold) keep one local copy and use the snapshot as redundancy; partially mounted indices (frozen) keep a shared cache, defaulting to 90% of disk on dedicated frozen nodes.
- The frozen phase accepts only
searchable_snapshot, and searchable snapshots need a reliable repository and a license that includes them. - Compute the cost model in units: local GB avoided against object GB added, and count nodes too.
- Frozen is right for long retention that is rarely searched; it is wrong for data queried often across its full range.
How Sparced helps
Sparced reads ILM state, snapshots, nodes, shards and allocation in one pass, so an index stuck in an ILM step, a snapshot repository problem behind a failing searchable_snapshot action, and a tier whose disks are nearing the watermarks appear together, each with the actual indices and nodes named. When the cluster is red or yellow, it calls _cluster/allocation/explain for every stuck shard and shows the cluster's own explanation, for example a tier preference with no node able to take the shard. Each finding comes with a fix command with names filled in and a check to confirm the fix. It reports; it does not change your policies or move data.

