Someone mapped order_id as a long and now the upstream system emits A-10493. Or a text field needs to be a keyword so it can be aggregated. Or the analyzer was wrong from day one and nobody noticed until search quality got measured. In Elasticsearch and OpenSearch, most mapping changes to an existing field are not possible in place: you can add a field, you can add a multi-field, you can relax a few settings, and that is roughly it. Everything else means building a new index and moving the data.
The mechanics of _reindex are easy. The hard part is doing it while the index is being written to and queried, without a maintenance window, and knowing before cutover that the new index is actually correct. That is what this runbook covers.
Step 0: never query a concrete index name
If your application talks to products and products is a real index, you have no cutover mechanism and every schema change becomes an outage. Fix that first, independently of whatever change you are trying to make.
The pattern is one index with a version suffix plus two aliases:
PUT /products-000001
POST /_aliases
{
"actions": [
{ "add": { "index": "products-000001", "alias": "products" } },
{ "add": { "index": "products-000001", "alias": "products-write", "is_write_index": true } }
]
}
Reads go to products, writes go to products-write. Both are aliases, so a cutover is one atomic _aliases call that either fully succeeds or fully fails. If you are already stuck on a concrete index name, you can't alias over it — the name is taken. The usual escape is to reindex once into products-000001, delete the old index, and point the alias at the new one during a short, planned window. Do it now, when nothing is on fire, rather than during the change you actually care about.
For data streams this is already handled: the stream name is the write target and backing indices roll over behind it.
Step 1: build the destination with the mapping you want
Create the new index explicitly. Do not let dynamic mapping infer it from the first reindexed document — you will reproduce the original mistake.
PUT /products-000002
{
"settings": {
"number_of_shards": 3,
"number_of_replicas": 0,
"refresh_interval": "-1"
},
"mappings": { "properties": { "order_id": { "type": "keyword" } } }
}
Zero replicas and a disabled refresh during bulk load are the two settings that matter most for throughput: you avoid replicating every document twice and you avoid building segments nobody is reading yet. Restore both before cutover (number_of_replicas back to its real value, refresh_interval back to 1s or whatever you run), and wait for green plus a completed refresh before any traffic lands on it.
If the change involves data transformation — splitting a field, renaming, parsing a date format — put it in an ingest pipeline and reference it from the reindex request rather than scripting inline. Pipelines are testable with _simulate before you move 400 million documents through them.
Step 2: run the reindex, throttled and sliced
The naive call works and is also how people saturate their hot nodes at 3pm:
POST /_reindex?wait_for_completion=false&slices=auto
{
"source": { "index": "products-000001", "size": 2000 },
"dest": { "index": "products-000002", "op_type": "create" },
"conflicts": "proceed"
}
Four things to set deliberately:
wait_for_completion=false. Returns a task ID immediately. Long reindexes outlive HTTP clients, proxies, and load balancer idle timeouts. Track it withGET /_tasks/<task_id>.slices=auto. Parallelizes across source shards. One slice per shard is the default behavior and is usually right; more slices than shards does nothing useful.requests_per_second. Start conservative on a busy cluster — a few thousand per second — and watch search p99 and indexing queue rejections. You can retune a running task:POST /_reindex/<task_id>/_rethrottle?requests_per_second=8000. No need to restart.op_type: createplusconflicts: proceed. This makes the bulk reindex refuse to overwrite documents that already exist in the destination. That is exactly what you want when live writes are also landing in the new index — the backfill must never stomp a newer version with an older one.
Moving between clusters, or from a self-managed cluster into Elastic Cloud or an OpenSearch domain, uses the same API with a remote block in the source; the destination cluster's reindex.remote.whitelist must list the source host. Remote reindex is single-threaded per request, so for large indices slice it manually by a range filter on a date or ID field and run several requests in parallel.
Step 3: catch up with live writes
The backfill is a snapshot of the past. Documents written during the run need to reach the new index too. Two workable options, and the right one depends on how much application change you can afford.
Dual write. Point the application at both aliases for the duration. Simple to reason about, costs an application deploy, and you need to decide what happens when one write succeeds and the other fails. Acceptable when the source of truth is a database you can replay from; risky when Elasticsearch is the only copy.
Backfill then catch-up by timestamp. Requires an updated_at-style field that every writer maintains. Run the full reindex, note the start time, then run a second reindex with a range query for documents modified since that mark, repeating until the delta is small. Then cut over and run one final catch-up. This needs no application change at all, which is why it is usually the one we pick — but it only works if the field is genuinely reliable, including for deletes. Deletes are the trap: a reindex never removes documents from the destination. If the index has deletes, either tombstone them with a soft-delete flag or accept a reconciliation pass after cutover.
Step 4: verify before you cut over
Do not cut over on "the task finished".
- Counts.
GET /products-000001/_countversusproducts-000002, within the drift you expect from in-flight writes. A gap larger than that means version conflicts or pipeline failures — check the reindex task response forfailuresandversion_conflicts. - Mapping.
GET /products-000002/_mappingand confirm the field you came here to fix is actually the new type, and that nothing else was dynamically added. - Relevance. If this is a search index, replay your judgment list against both indices and compare. Analyzer changes move rankings even when you only meant to change one field. A mapping fix that silently degrades NDCG@10 is a bad trade you want to see before users do.
- Query shape. Run your top ten production queries against the new index and diff result IDs, not just HTTP 200s.
Step 5: cut over atomically, and keep the old index
POST /_aliases
{
"actions": [
{ "remove": { "index": "products-000001", "alias": "products" } },
{ "add": { "index": "products-000002", "alias": "products" } },
{ "remove": { "index": "products-000001", "alias": "products-write" } },
{ "add": { "index": "products-000002", "alias": "products-write", "is_write_index": true } }
]
}
One request, applied as a single cluster state update. Rollback is the same request with the indices swapped, which is why the old index stays on disk for a few days rather than being deleted in the same change. It is the cheapest insurance in this whole procedure — keep it until the new index has survived a full traffic cycle, including whatever your Monday morning peak looks like.
What this costs
A reindex reads and writes every document and rebuilds every segment. Budget roughly the disk of the old index again for the duration, plus CPU on both source and destination nodes. On a hot tier running at 70% disk already, add capacity first or the reindex will hit the flood-stage watermark and put your indices into read-only mode — a far worse outage than the mapping bug you were fixing.
The mechanics are the same on Elasticsearch and OpenSearch; the APIs quoted here work on both. If you are planning a mapping overhaul on an index that cannot go offline, or an Elasticsearch-to-OpenSearch move that is really a very large remote reindex, get in touch — rehearsing the cutover is most of the work, and we have the runbook.