Searchable Snapshots & the Frozen Tier

Retention rules on log and metrics data are almost never “keep it hot or delete it” — they are “keep a year of it queryable in case an incident, an audit, or a capacity-planning question needs it, but do not pay hot-tier prices for data nobody reads on a normal day.” A searchable snapshot is the mechanism that resolves that tension. It mounts a snapshot back as a read-only index you can query directly, while the actual Lucene data stays in the repository and is served through a local cache. The frozen tier takes the idea to its economic limit: a data_frozen node keeps only a bounded slice of each index on disk and streams the rest from object storage on demand, so a handful of cheap nodes can front tens of terabytes of rarely-touched metrics-* history. This page is the anatomy of that mechanism — the two storage modes, the two ways to create a mounted index, how to size the cache, and the first-touch latency you trade for the cost saving. It sits under Snapshot Lifecycle Management & searchable snapshots, which frames how scheduled backups and mounted archives share one repository.

Prerequisites

Architecture: Two Storage Modes, Two Ways In

A mounted index is not a restored index. A restore copies data back onto primary storage and re-attaches it as a normal, writable index; a mount registers the snapshot’s metadata in cluster state and leaves the segment data in the repository, reading it lazily. That single difference is what makes the frozen tier possible — you can mount an index far larger than any node’s local disk, because the node never holds the whole thing at once.

Two dimensions define how a mount behaves. The first is the storage mode. With full_copy (the cold-tier default) each data_cold node downloads a complete local copy of the index and keeps it on disk; the repository is retained purely as the source of truth for recovery, and query latency is indistinguishable from an ordinary index because nothing is fetched at query time. With shared_cache (the frozen-tier mode) each data_frozen node keeps only a bounded cache — sized by the xpack.searchable.snapshot.shared_cache.size node setting — and fetches segment blocks from the repository on a cache miss, evicting cold blocks under pressure. The second dimension is how the index gets mounted: automatically by an ILM searchable_snapshot action as an index ages into the cold or frozen phase, or manually through the mount API when you want to promote an existing snapshot — including one a scheduled backup already wrote — without re-snapshotting.

Searchable snapshots: two producers, one snapshot, two storage modesAn ILM searchable_snapshot action (cold or frozen phase) and the manual mount API both reference one snapshot held in cold-archive-repo, whose Lucene segments live in object storage and are read through a local cache. A full_copy mount lands on a data_cold node keeping a complete local copy, so query latency matches a normal index and the repository backs recovery only. A shared_cache mount lands on a data_frozen node keeping a bounded local cache sized by xpack.searchable.snapshot.shared_cache.size, fetching blocks from the repository on a cache miss, letting the node hold far more index data than its local disk.ILM searchable_snapshot actionruns in the cold or frozen phaseautomatic on phase transitionManual mount APIpromote an existing snapshot_mount?storage=shared_cacheSnapshot in cold-archive-repoLucene segments live in object storageread through a local cachefull_copyshared_cachedata_cold · full_copycomplete local copy on diskquery latency ≈ a normal indexrepository backs recovery onlyprefix: restored-metrics-*data_frozen · shared_cachebounded local cache on diskcache miss fetches blocks from repoholds far more than local diskprefix: partial-metrics-*

The prefix in the diagram is not cosmetic. When ILM mounts an index it renames it so the original and the mounted copy never collide: a full_copy cold mount becomes restored-<index>, and a shared_cache frozen mount becomes partial-<index>. The partial- prefix is a reliable signal in _cat/indices output that an index is frozen and only partially resident — a fact worth internalising because it is the fastest way to eyeball which indices are being served from cache versus disk. The tiering here is the same hot-warm-cold architecture that routes live shards, extended two tiers colder into repository-backed storage.

Configuration Reference

The reference deployment ages metrics-* indices through a cold full_copy mount at 30 days and a frozen shared_cache mount at 90 days, both pointing at the same repository. That shared-repository detail is the single most important optimisation on this page: when cold and frozen use one repository, ILM recognises that the snapshot already exists from the cold phase and reuses it for the frozen mount instead of taking a second snapshot, so the frozen transition is a metadata-only remount rather than another expensive write to object storage.

PUT _ilm/policy/metrics-archive
{
  "policy": {
    "phases": {
      "hot": {
        "actions": {
          "rollover": { "max_primary_shard_size": "50gb" }
        }
      },
      "cold": {
        "min_age": "30d",
        "actions": {
          "searchable_snapshot": {
            "snapshot_repository": "cold-archive-repo",
            "force_merge_index": true,
            "storage": "full_copy"
          }
        }
      },
      "frozen": {
        "min_age": "90d",
        "actions": {
          "searchable_snapshot": {
            "snapshot_repository": "cold-archive-repo",
            "storage": "shared_cache"
          }
        }
      },
      "delete": {
        "min_age": "365d",
        "actions": { "delete": {} }
      }
    }
  }
}

Three fields carry the weight here. snapshot_repository names where the snapshot is written and mounted from; it must be identical across the cold and frozen phases to trigger the reuse behaviour above. storage selects the mode — full_copy in cold (the default, so it can be omitted) and shared_cache in frozen (also the default for that phase). force_merge_index defaults to true and force-merges the index down toward a single segment before the snapshot is taken, which shrinks the searchable snapshot and speeds later queries; the cost is that force-merge rewrites every segment, so it inflates the first snapshot into the repository. If a preceding warm phase already force-merged the index, that cost is already paid and the flag is nearly free. The delete phase deletes the mounted index and, because ILM tracks that the index was created from a searchable snapshot, cleans up the underlying snapshot too — a scheduled backup written by an SLM policy is a separate object and is not touched.

Step-by-Step Implementation

1. Size the frozen shared cache

The shared cache is a node-level setting, not an index or cluster setting, so it lives in elasticsearch.yml (or an equivalent config-management template) on every data_frozen node and takes effect on restart. It is a static setting — it cannot be changed through the _cluster/settings API.

# elasticsearch.yml on each data_frozen node
node.roles: [ data_frozen ]
xpack.searchable.snapshot.shared_cache.size: 90%
xpack.searchable.snapshot.shared_cache.size.max_headroom: 100GB

Expressed as a percentage, the cache is sized relative to the node’s total disk minus a headroom margin capped by max_headroom; you can also give an absolute value such as 2tb. The cache is dedicated — that disk is reserved for frozen segments and is not shared with anything else on the node — so a frozen node is essentially a large, cheap disk fronting object storage.

2. Mount an existing snapshot manually

To turn a snapshot already sitting in the repository into a queryable frozen index without waiting for ILM, call the mount API directly. The renamed_index prevents a name clash with any live index of the same name, and wait_for_completion=true makes the request block until the mount is registered rather than returning immediately.

POST /_snapshot/cold-archive-repo/snap-2026.06.01/metrics-000042/_mount?wait_for_completion=true&storage=shared_cache
{
  "index": "metrics-000042",
  "renamed_index": "partial-metrics-000042",
  "index_settings": {
    "index.number_of_replicas": 0
  }
}

index is the source index name inside the snapshot; renamed_index is the name the mounted index gets in the live cluster. Setting number_of_replicas to 0 is common on the frozen tier because the repository is already the durable copy — a lost frozen node re-mounts from the repository rather than recovering from a replica, so replicas mostly add cache pressure without adding durability.

3. Mount from Python (v8+)

For repeatable, unattended promotion of archived metrics-* snapshots, drive the mount through the v8 client. searchable_snapshots.mount takes the repository, snapshot, and source index as keyword arguments and returns once the mount is registered; API and HTTP failures surface as ApiError, a sibling of TransportError (not a subclass), so catch ApiError explicitly.

import logging
from elasticsearch import Elasticsearch, ApiError, NotFoundError

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


def mount_frozen(es: Elasticsearch, repo: str, snapshot: str, source_index: str) -> bool:
    """Mount one snapshotted index onto the frozen tier as a partial- index."""
    renamed = f"partial-{source_index}"
    try:
        es.searchable_snapshots.mount(
            repository=repo,
            snapshot=snapshot,
            index=source_index,
            renamed_index=renamed,
            index_settings={"index.number_of_replicas": 0},
            storage="shared_cache",       # frozen tier; use "full_copy" for cold
            wait_for_completion=True,
        )
        logger.info("Mounted %s from %s/%s as %s", source_index, repo, snapshot, renamed)
        return True
    except NotFoundError:
        logger.error("Snapshot %s or index %s not found in repo %s", snapshot, source_index, repo)
        return False
    except ApiError as exc:
        logger.error("Mount failed (%s): %s", exc.status_code, exc.info)
        return False


if __name__ == "__main__":
    es = Elasticsearch(
        "https://es-cluster-01:9200",
        api_key="YOUR_BASE64_ENCODED_API_KEY",
        verify_certs=True,
        request_timeout=120,   # a full_copy mount can take a while to download
    )
    mount_frozen(es, "cold-archive-repo", "snap-2026.06.01", "metrics-000042")

A longer request_timeout matters for full_copy mounts, where the node downloads the whole index before the call returns; shared_cache mounts register almost immediately because no bulk download happens up front. The end-to-end manual walkthrough, including how to confirm the mounted index actually answers queries, lives in mounting a searchable snapshot to the frozen tier.

Verification

First, list the mounted frozen indices — the partial- prefix makes them easy to filter:

GET _cat/indices/partial-*?v&h=index,health,status,pri,rep,docs.count,store.size

Then confirm the index is genuinely backed by a snapshot rather than restored to primary storage. The index.store.snapshot settings are the fingerprint of a searchable snapshot; a plain index has none:

GET partial-metrics-000042/_settings?filter_path=**.store.snapshot
{
  "partial-metrics-000042": {
    "settings": {
      "index": {
        "store": {
          "snapshot": {
            "repository_name": "cold-archive-repo",
            "snapshot_name": "snap-2026.06.01",
            "index_name": "metrics-000042"
          }
        }
      }
    }
  }
}

Those three fields tell you exactly which repository, snapshot, and source index the mount reads from — invaluable when debugging a mount that queries but returns nothing. Finally, confirm the deployment actually has a frozen node to host the mount; a frozen phase with no data_frozen node hangs indefinitely:

GET _cat/nodes?v&h=name,node.role

At least one row’s node.role column must contain f (the abbreviation for data_frozen). If none does, no amount of policy tuning will move an index into the frozen tier.

Threshold Tuning & Performance Guidance

The frozen tier trades latency for cost, and the shared cache is the dial that sets where on that curve you sit. Tune it against the query pattern, not the data volume:

  1. Size the cache to the working set, not the archive. The cache does not need to hold all frozen data — that would defeat the point. It needs to hold the hot blocks of a typical query: the recent time slices, the fields a dashboard touches, the segments a common aggregation walks. If your frozen queries usually scan the last few days of a year-long archive, a cache sized to a few days of segments serves most requests from disk. Undersize it and every query thrashes; oversize it and you have paid for cold disk you never touch.
  2. Expect first-touch latency, then amortise it. The first query against a freshly mounted shared_cache index pays a full round-trip to object storage for every block it needs — seconds, sometimes tens of seconds, versus milliseconds on a warm cache. Subsequent queries over the same slice hit the cache and are fast. This is why frozen data feels slow “the first time each morning” and quick thereafter.
  3. Pre-warm deliberately when latency matters. If an analyst always opens the last 30 days of metrics-* at the start of a shift, a scheduled bounded query (a cheap aggregation over that window) run just before pulls those blocks into cache so the human never eats the cold read. Pre-warming is just doing the first-touch fetch on a schedule instead of on a user.
  4. Reach for full_copy when the cold data is genuinely latency-sensitive. The cold tier’s full_copy mode exists precisely for data that must stay queryable at normal speed but no longer needs to be writable. If a compliance dashboard queries 60-day-old metrics-* interactively, cold full_copy gives ordinary latency at a lower cost than hot storage, and you reserve shared_cache/frozen for the truly archival tail where a few seconds of first-touch delay is acceptable.

The economics only work because the repository is the durable copy: frozen nodes carry no replicas and can be small and cheap, since losing one costs a remount, not a recovery. That property pairs naturally with fallback routing for data retention, where a read path survives the loss of a whole tier because the repository — not the node — holds the data.

Troubleshooting

Frozen phase stuck on mount-snapshot

Symptom: GET metrics-000042/_ilm/explain shows the index in the frozen phase parked on the mount-snapshot step and never advancing. Resolution:

  1. Confirm a frozen node exists: GET _cat/nodes?v&h=name,node.role must show a node whose role includes f.
  2. Confirm that node has a shared cache — a data_frozen node with no xpack.searchable.snapshot.shared_cache.size set cannot host a shared_cache mount, and the step waits forever for allocation.
  3. Set the shared cache in elasticsearch.yml and restart the node; the setting is static and cannot be applied through the settings API.

Writes to a mounted index rejected

Symptom: an indexing or update request against partial-metrics-000042 fails with a read-only cluster-block error. Resolution: this is expected, not a fault. A searchable snapshot is read-only by definition — the data lives in an immutable snapshot. If you need to modify the data, reindex it into a fresh writable index; you cannot edit a mounted snapshot in place, and no setting makes one writable.

Frozen queries are consistently slow, not just on first touch

Symptom: every frozen query is slow, even repeated ones over the same time range — cache misses never seem to stop. Resolution: the working set exceeds the shared cache, so blocks are evicted before they are reused (cache thrash). Either raise xpack.searchable.snapshot.shared_cache.size on the frozen nodes, narrow the queries (tighter time ranges, fewer fields), or move the frequently-queried slice back to a cold full_copy mount where the whole index is resident. Watch cache behaviour with GET _nodes/stats/thread_pool and the frozen search thread pool for queue build-up.

snapshot_missing_exception when mounting

Symptom: a manual mount or the ILM frozen step fails complaining the snapshot no longer exists. Resolution: an SLM retention run or a manual delete removed the snapshot the mount depends on. Searchable snapshots pin the snapshot they read from — a snapshot that still backs a mounted index must not be deleted. Exclude mount-backing snapshots from aggressive retention, and register the repository read-only for consumers who only mount, per managing snapshot repositories.

FAQ

What is the difference between full_copy and shared_cache storage?

full_copy (the cold-tier mode) downloads and keeps a complete local copy of the index on each hosting node, so query latency matches a normal index and the repository is used only for recovery. shared_cache (the frozen-tier mode) keeps only a bounded local cache sized by xpack.searchable.snapshot.shared_cache.size and fetches segment blocks from the repository on a cache miss, so a node can front far more index data than it stores — at the cost of first-touch latency on cache misses.

Do I still need the freeze action to use the frozen tier?

No — and you can’t. The freeze index action was removed in Elasticsearch 8.0. The modern frozen tier is reached exclusively by mounting a searchable snapshot with shared_cache storage, either through an ILM searchable_snapshot action in the frozen phase or a manual mount. There is no separate freeze step anymore.

Why do my cold and frozen indices have restored- and partial- prefixes?

ILM renames a mounted index so it cannot collide with the source index. A cold full_copy mount becomes restored-<index> and a frozen shared_cache mount becomes partial-<index>. The partial- prefix is a useful at-a-glance signal in _cat/indices that an index is frozen and only partially resident on disk, served from the shared cache.

If cold and frozen point at the same repository, is a second snapshot taken?

No. When both the cold and frozen phases name the same snapshot_repository, ILM recognises the snapshot the cold phase already took and reuses it for the frozen mount rather than writing a second copy to object storage. The frozen transition becomes a near-free metadata remount. Pointing the two phases at different repositories defeats this and doubles your snapshot cost.

Can I write to or update a mounted searchable snapshot?

No. A searchable snapshot is read-only because its data lives in an immutable snapshot in the repository. Indexing, updating, or deleting documents in a mounted index is rejected by a read-only cluster block. To change the data, reindex it into a new writable index; there is no setting that makes a mounted snapshot writable.

← Back to Snapshot Lifecycle Management & Searchable Snapshots