Zero-Downtime Mapping Migrations with Aliases
Most mapping changes cannot be applied in place: a field type change, an analyzer change, a shard-count change all require a new index. The alias pattern turns that from an outage into a routine deployment — build the new index alongside the old, verify it, swap in one atomic call, and keep the old one for rollback. This page is the procedure. It sits under schema design and index mapping in search engine selection and architecture.
Prerequisites
- Every read and write path already uses an alias rather than a concrete index name. Without this, nothing else here applies.
- Disk headroom for two copies of the index simultaneously, plus merge working space.
- A source of truth to reindex from, or an existing index that can serve as the source.
- A short list of queries with known-correct results, for verifying the new index before the swap.
Diagnosis: which changes need a new index
Knowing which category a change falls into decides whether this procedure is needed at all, and the boundary surprises people in both directions.
Applied in place: adding a new field, adding a multi-field to an existing field, changing number_of_replicas, changing refresh_interval, adding a query-time synonym set.
Requires a new index: changing a field’s type, changing an analyzer used at index time, changing number_of_shards, removing a field from the mapping, changing doc_values or index on an existing field.
# The engine will tell you directly — try it and read the error.
curl -s -X PUT 'localhost:9200/products/_mapping' -H 'Content-Type: application/json' -d '{
"properties": {"price": {"type": "keyword"}}
}' | jq -r '.error.reason'
# => "mapper [price] cannot be changed from type [float] to [keyword]"
Attempting the change is the fastest way to classify it, and it is safe: the rejection happens before anything is modified.
Solution Steps
1. Create the new index with a version suffix
Never reuse a name. Monotonic version suffixes make the history readable months later and remove any ambiguity about which index is current.
curl -s -X PUT 'localhost:9200/products_v8' -H 'Content-Type: application/json' -d '{
"settings": {"number_of_shards": 4, "number_of_replicas": 0, "refresh_interval": "-1"},
"mappings": {"dynamic": "strict", "properties": {
"title": {"type": "text", "analyzer": "product_v2",
"fields": {"raw": {"type": "keyword"}}},
"price": {"type": "scaled_float", "scaling_factor": 100}
}}
}'
Replicas at zero and refresh disabled make the reindex substantially faster; both are restored before the swap.
2. Reindex, transforming if the change requires it
curl -s -X POST 'localhost:9200/_reindex?wait_for_completion=false' \
-H 'Content-Type: application/json' -d '{
"source": {"index": "products_v7", "size": 5000},
"dest": {"index": "products_v8"},
"script": {
"lang": "painless",
"source": "if (ctx._source.price instanceof String) { ctx._source.price = Float.parseFloat(ctx._source.price); }"
}
}' | jq -r .task
# => Ur6bDQEsQ2u1234:98123
3. Keep the new index current while it builds
A reindex of a large corpus takes hours, during which the source keeps changing. If a change stream feeds the index, point a second consumer at the new index before starting the reindex, exactly as in backfilling a search index without downtime. If writes go through your application, dual-write for the duration.
# Watch progress; the created count should track the source's document count.
curl -s "localhost:9200/_tasks/Ur6bDQEsQ2u1234:98123" \
| jq '.task.status | {created, total, pct: (.created*100/.total | floor)}'
# => { "created": 8412000, "total": 10442310, "pct": 80 }
4. Restore serving settings and verify before swapping
curl -s -X PUT 'localhost:9200/products_v8/_settings' -H 'Content-Type: application/json' \
-d '{"index": {"number_of_replicas": 2, "refresh_interval": "5s"}}'
curl -s 'localhost:9200/_cluster/health/products_v8?wait_for_status=green&timeout=900s' | jq -r .status
# Counts must match, and the known-good queries must return the expected documents.
for i in products_v7 products_v8; do echo -n "$i "; curl -s "localhost:9200/$i/_count" | jq .count; done
python3 verify_queries.py --index products_v8 --expectations expectations.json
# => 24/24 expectations met
5. Swap the alias atomically
Both actions must be in one request. Two separate calls leave a window with no index behind the alias, during which every search fails.
curl -s -X POST 'localhost:9200/_aliases' -H 'Content-Type: application/json' -d '{
"actions": [
{"remove": {"index": "products_v7", "alias": "products"}},
{"add": {"index": "products_v8", "alias": "products"}}
]
}'
6. Keep the old index for a full business cycle
Rollback is the same call with the arguments reversed, and it takes milliseconds. Deleting the old index immediately removes that option to save storage that costs a fraction of what an incident would.
# Rollback — rehearse this in staging before you need it in production.
curl -s -X POST 'localhost:9200/_aliases' -H 'Content-Type: application/json' -d '{
"actions": [
{"remove": {"index": "products_v8", "alias": "products"}},
{"add": {"index": "products_v7", "alias": "products"}}
]
}'
7. Handle write aliases separately when writes are involved
If the index receives writes through an alias, the alias needs is_write_index set on exactly one target, and the swap must move that flag too. Omitting it means writes continue landing in the old index after the read swap, which produces the confusing state where new documents are searchable through neither.
curl -s -X POST 'localhost:9200/_aliases' -H 'Content-Type: application/json' -d '{
"actions": [
{"add": {"index": "products_v8", "alias": "products", "is_write_index": true}},
{"remove": {"index": "products_v7", "alias": "products"}}
]
}'
Verification
Confirm the alias points at exactly one index:
curl -s 'localhost:9200/_cat/aliases/products?v&h=alias,index'
# alias index
# products products_v8
Confirm no code paths reference a concrete index name:
grep -rn "products_v[0-9]" --include='*.py' --include='*.js' src/ | grep -v migrate
# => no output, apart from the migration script itself
Confirm the new mapping is actually in effect on the live alias:
curl -s 'localhost:9200/products/_mapping' | jq -r '.[].mappings.properties.price.type'
# => scaled_float
Adopting aliases on an index that does not have one
The whole procedure assumes an alias already exists, and plenty of indices were created without one. Introducing it is itself a small migration, and it is worth doing before it is needed rather than during an incident.
An alias cannot share a name with an existing index, so the switch has two stages. First, create the new versioned index and reindex into it. Then, in one atomic call, delete the old index and create the alias pointing at the new one. There is a brief window during that call, but it is a single cluster-state update rather than two separate operations.
# Stage 1: build products_v1 from the unaliased "products" index.
curl -s -X POST 'localhost:9200/_reindex?wait_for_completion=true' \
-H 'Content-Type: application/json' \
-d '{"source":{"index":"products"},"dest":{"index":"products_v1"}}' | jq '.created'
# Stage 2: swap the name for the alias in one action list.
curl -s -X POST 'localhost:9200/_aliases' -H 'Content-Type: application/json' -d '{
"actions": [
{"remove_index": {"index": "products"}},
{"add": {"index": "products_v1", "alias": "products"}}
]
}'
Applications continue to query products throughout and never learn that it stopped being an index. From that point on, every future mapping change is the routine procedure above rather than a migration project.
Common Pitfalls
Two separate alias calls instead of one
Removing the alias from the old index and adding it to the new one as two requests creates a window — usually milliseconds, occasionally longer under load — in which the alias resolves to nothing and every search returns an index-not-found error. The atomic form exists precisely for this and costs nothing extra.
Reindexing without keeping the new index current
A reindex captures a point in time. Every write during the following four hours lands only in the old index, and swapping silently reverts them. Either dual-write or run a change-stream consumer against the new index from before the reindex starts — and confirm its lag has converged before swapping.
Running out of disk halfway through
Two full copies plus merge working space can approach three times steady-state usage, and crossing the flood-stage watermark puts every index on the node into read-only mode — turning a zero-downtime migration into an outage caused by the migration. Check available disk against projected peak before starting.
Operational notes
Write the migration as a script with forward and rollback entry points, and run the rollback path in staging as part of the rehearsal. A rollback that exists only as a paragraph in a runbook tends not to work when it is finally needed, usually because of a detail nobody thought about — an alias that also needs a write flag, a second alias used by a reporting job, a search template pinned to the old index.
Give the old index a deletion date rather than deleting it when someone remembers. A calendar entry a fortnight out is enough to cover the period during which a subtle relevance regression would surface, and it prevents both premature deletion and the opposite failure where six abandoned index versions accumulate and nobody dares remove any of them.
Once this pattern is established, mapping changes stop being events. The procedure is identical whether the change is a field type, an analyzer, or a shard count, and its cost is dominated by the reindex duration rather than by any risk — which is exactly the property that lets a team improve a mapping continuously instead of accumulating compromises around one they cannot change.
Related
- Schema design and index mapping — the parent guide, and the decisions that determine how often this procedure is needed.
- Backfilling a search index without downtime — the same alias mechanism applied to a full data rebuild with change replay.
- Sizing shards and replicas for a new index — the settings to reconsider while you are building a new index anyway.