To reindex Elasticsearch without search downtime, create a replacement index, copy documents with the _reindex API, validate it, then move the application's alias in one _aliases request. Searches keep using the same alias throughout.
An alias swap does not synchronize writes. The copy can miss later inserts, updates, and deletes. This walkthrough keeps search available while pausing Elasticsearch writes from before the copy until after cutover. That is a write maintenance window, not uninterrupted read/write availability. If your application cannot pause or durably queue writes, use the change-capture approach below before following the cutover steps.
Each request has a curl version and a matching Kibana Dev Tools Console version. Run one version of each request, inspect the result, and only then continue.
The pattern: applications query a stable alias
The application uses products, an alias pointing to products_v3. Build products_v4 separately, then move products once the replacement is ready. The application keeps the same endpoint. You can explore both states in the graphic; its labels follow the settings below.
An alias must have a different name from any existing index or data stream. If your application currently addresses a physical index called products, you cannot create an alias with that name alongside it. Introduce a distinct alias, deploy the application change, and verify every reader and writer uses it before migrating. Do not delete the existing index to free the name.
When a mapping change needs a reindex
Changing an existing field's type or its index-time analyzer generally means rebuilding documents in a new index. Adding a new field is different: the mapping API supports it. A new keyword multi-field is also allowed, but old documents need rewriting with _update_by_query or reindexing to populate it. Some mapping parameters, including search_analyzer, can be updated without rebuilding the index. See Elastic's update mapping API and search analyzer documentation.
Adding a dense_vector mapping does not generate embeddings. You need an embedding process or an appropriate ingest pipeline to supply vectors. The example here uses ordinary product fields so it can focus on the migration itself.
Setup: use your own values in curl and Kibana
Set the host, current index, new index, and application alias. Both command versions and the diagram update with these values. Values are saved in this browser; do not enter credentials.
In Kibana: open Dev Tools → Console on the deployment you intend to change. Paste one request, place the cursor inside it, and click its play button or press Ctrl+Enter / Cmd+Enter. Execute each request individually. A request is the GET, PUT, POST, or DELETE line plus its entire JSON body, when present; do not execute JSON lines separately. Do not select and run the whole walkthrough. Console can submit multiple requests sequentially, but an asynchronous reindex returns before the copy finishes. Console request controls.
Kibana uses its connected Elasticsearch cluster and your Kibana permissions. The host field affects curl only; changing it here cannot switch Kibana to another cluster. Compare GET / in Console with the curl response, including cluster_uuid, before continuing. These are REST requests in Console syntax, not KQL or ES|QL queries.
In a terminal: the curl examples use Bash/zsh syntax and curl 7.76+ (--fail-with-body). Set the username once; curl will prompt for its password when each request runs. Use a migration account with the required read, write, create-index, alias-management, settings, and monitoring privileges.
export ES_USER='elastic'For a self-managed cluster with a private certificate authority, configure its trusted CA certificate before running curl:
export CURL_CA_BUNDLE='/absolute/path/to/http_ca.crt'Skip that environment variable when the endpoint already has a certificate trusted by your system. Keep certificate verification enabled. For API-key authentication, replace the --user option with your locally configured authorization header. Do not enter credentials into this page.
Check the cluster identity
curl --fail-with-body --silent --show-error --user "$ES_USER" \
--request GET 'https://localhost:9200/'GET /Before changing anything, confirm all of these:
- You have a recoverable snapshot or source-of-truth rebuild path, enough disk for both indices and replicas, and a tested mapping.
- This is a regular index migration within one cluster. Data streams, remote reindexing, filtered aliases, custom routing, and lifecycle-managed indices need additional planning.
- The source has
_sourceenabled. Reindexing reads it; it does not copy the original mapping, settings, or templates for you. Reindex prerequisites. - Your replacement starts empty, receives no application writes, and does not inherit the application alias from an index template. One operator controls the migration.
- All producers can pause or durably queue writes, including background jobs, bulk importers, deletes, and direct-to-index clients. Blocking an index alone does not queue writes.
Step 1: Check the alias and source configuration
1a. Inspect the application alias
curl --fail-with-body --silent --show-error --user "$ES_USER" \
--request GET 'https://localhost:9200/_alias/products'GET /_alias/productsExpect exactly the current index with this alias under aliases. Stop if it points somewhere else, spans multiple indices, has is_write_index: false, or carries a filter or routing options you have not accounted for. Preserve any required alias options in both forward and rollback changes; the example below assumes a plain, single-index alias.
Only if the response explicitly reports alias not found (404), create the alias. Authentication failures and connectivity errors are not missing aliases.
1b. Create the alias only if it is missing
curl --fail-with-body --silent --show-error --user "$ES_USER" \
--request POST 'https://localhost:9200/_aliases' \
--header 'Content-Type: application/json' \
--data '{
"actions": [
{
"add": {
"index": "products_v3",
"alias": "products",
"is_write_index": true
}
}
]
}'POST /_aliases
{
"actions": [
{
"add": {
"index": "products_v3",
"alias": "products",
"is_write_index": true
}
}
]
}Wait for acknowledged: true, then repeat 1a. If you are introducing this alias, deploy and verify the application's switch to it before continuing.
1c. Inspect the source mapping
curl --fail-with-body --silent --show-error --user "$ES_USER" \
--request GET 'https://localhost:9200/products_v3/_mapping'GET /products_v3/_mapping1d. Record the source settings
curl --fail-with-body --silent --show-error --user "$ES_USER" \
--request GET 'https://localhost:9200/products_v3/_settings?include_defaults=true'GET /products_v3/_settings?include_defaults=trueRecord analyzers, shard count, replica policy, refresh interval, routing, and ingest-pipeline settings. Build the replacement from an explicit, reviewed definition. Do not blindly copy generated settings such as the UUID or creation date.
Step 2: Create the new index with the required mapping
This is a small example product mapping. Replace it with the schema your actual documents require, including custom analyzers and dynamic templates. Here name.keyword supports sorting and aggregations. The shard and replica choices are examples, not sizing advice.
2a. Create the empty replacement index
curl --fail-with-body --silent --show-error --user "$ES_USER" \
--request PUT 'https://localhost:9200/products_v4' \
--header 'Content-Type: application/json' \
--data '{
"settings": {
"number_of_shards": 3,
"number_of_replicas": 1,
"refresh_interval": "60s"
},
"mappings": {
"properties": {
"name": {
"type": "text",
"fields": {
"keyword": {
"type": "keyword"
}
}
},
"description": {
"type": "text"
},
"price": {
"type": "double"
},
"in_stock": {
"type": "boolean"
}
}
}
}'PUT /products_v4
{
"settings": {
"number_of_shards": 3,
"number_of_replicas": 1,
"refresh_interval": "60s"
},
"mappings": {
"properties": {
"name": {
"type": "text",
"fields": {
"keyword": {
"type": "keyword"
}
}
},
"description": {
"type": "text"
},
"price": {
"type": "double"
},
"in_stock": {
"type": "boolean"
}
}
}
}Wait for creation to be acknowledged. If the index already exists, inspect it and choose a fresh destination name; do not delete it to make the example work. A 60-second refresh interval can reduce refresh overhead while copying. It controls search visibility, not a disk commit. Keeping a replica avoids deliberately dropping redundancy; zero replicas is an optional performance tradeoff only when the source is recoverable and you accept rebuilding the destination after a failure. Indexing-speed guidance.
2b. Inspect the destination, including inherited aliases and settings
curl --fail-with-body --silent --show-error --user "$ES_USER" \
--request GET 'https://localhost:9200/products_v4'GET /products_v42c. Refresh the destination before checking that it is empty
curl --fail-with-body --silent --show-error --user "$ES_USER" \
--request POST 'https://localhost:9200/products_v4/_refresh'POST /products_v4/_refresh2d. Confirm zero destination documents
curl --fail-with-body --silent --show-error --user "$ES_USER" \
--request GET 'https://localhost:9200/products_v4/_count'GET /products_v4/_countRequire count: 0, no failed shards, no application alias on the destination, and no unexpected ingest pipeline. A template can add settings or aliases even when the create request does not include them. If the new index unexpectedly received the live alias, stop and repair that routing before proceeding.
Step 3: Pause writes, refresh the source, and start reindexing
Pause producers first and drain their in-flight work. Keep new operations in a durable queue or stop accepting writes for the maintenance window. Reads may continue. Then add a write block as a guard against a forgotten producer:
3a. Block writes to the old index after pausing producers
curl --fail-with-body --silent --show-error --user "$ES_USER" \
--request PUT 'https://localhost:9200/products_v3/_block/write'PUT /products_v3/_block/writeWait for success for every index/shard in the response. The dedicated index-block API accounts for in-flight writes before acknowledging the block. An unexpected writer now gets an error; your application must retain or retry that operation rather than lose it.
3b. Make the last source writes visible to reindex
curl --fail-with-body --silent --show-error --user "$ES_USER" \
--request POST 'https://localhost:9200/products_v3/_refresh'POST /products_v3/_refreshCheck _shards.failed: 0. A refresh makes recent operations searchable, which matters because reindex reads through search. Leave the source write-blocked through validation and cutover.
3c. Start one asynchronous reindex task
curl --fail-with-body --silent --show-error --user "$ES_USER" \
--request POST 'https://localhost:9200/_reindex?wait_for_completion=false&slices=auto&requests_per_second=500' \
--header 'Content-Type: application/json' \
--data '{
"source": {
"index": "products_v3",
"size": 1000
},
"dest": {
"index": "products_v4",
"op_type": "create"
}
}'POST /_reindex?wait_for_completion=false&slices=auto&requests_per_second=500
{
"source": {
"index": "products_v3",
"size": 1000
},
"dest": {
"index": "products_v4",
"op_type": "create"
}
}The response should contain a task such as node_id:12345. This means the task started, not that the copy finished. Save the complete value, including the node ID and colon, in the task field below. Start the task once; repeat the status request instead of resubmitting _reindex.
op_type: create refuses to overwrite existing destination documents. A conflict is a reason to stop in this empty-destination workflow. Do not add conflicts: proceed just to get past it. slices: auto can parallelize work; the parent status can advance in jumps as slices finish. The throttle is an example starting point, not a guarantee of acceptable load. Monitor query latency, heap, disk, and rejections. Reindex execution and throttling.
If the start request times out without returning an ID, inspect running reindex tasks before deciding whether to retry:
3d. Find running reindex tasks only if the start response was lost
curl --fail-with-body --silent --show-error --user "$ES_USER" \
--request GET 'https://localhost:9200/_tasks?actions=*reindex&detailed=true'GET /_tasks?actions=*reindex&detailed=trueStep 4: Check reindex progress and wait for successful completion
4a. Poll this task until it finishes
Paste the complete task ID returned by the reindex request (node ID:number) before copying this request.
curl --fail-with-body --silent --show-error --user "$ES_USER" \
--request GET 'https://localhost:9200/_tasks/<task_id>'GET /_tasks/<task_id>Repeat 4a every 5–10 seconds, or less often for a large copy. It may take minutes or hours. Do not advance to the swap while completed is false.
When it becomes true, inspect the whole response. For this unfiltered copy into an empty, private destination, require:
- No top-level
error. - A
responseobject withtimed_out: falseand an emptyfailuresarray. version_conflicts: 0.createdequal tototal;updated,deleted, andnoopsequal to zero.
completed: true means the task stopped; it does not prove success. A missing task (404), lost node, cancellation, malformed response, or document failure needs investigation. Preserve the old alias and inspect the partial destination before planning a retry. Do not assume that rerunning the request resumes safely. Task status.
Step 5: Validate documents, queries, and replica readiness
5a. Refresh the destination after the copy
curl --fail-with-body --silent --show-error --user "$ES_USER" \
--request POST 'https://localhost:9200/products_v4/_refresh'POST /products_v4/_refresh5b. Count the frozen source
curl --fail-with-body --silent --show-error --user "$ES_USER" \
--request GET 'https://localhost:9200/products_v3/_count'GET /products_v3/_count5c. Count the replacement
curl --fail-with-body --silent --show-error --user "$ES_USER" \
--request GET 'https://localhost:9200/products_v4/_count'GET /products_v4/_countRequire no failed shards and equal counts for this full, write-paused migration. A lower count is not an acceptable expected gap. Equal counts are necessary here but do not prove equal content: an update can change a document without changing the total.
Compare representative document IDs and source values, then run your application's actual queries directly against the new index. Check relevance, sorting, filters, aggregations, routing, and latency. For the example mapping, this is a basic search-and-sort smoke test:
5d. Smoke-test the replacement mapping
curl --fail-with-body --silent --show-error --user "$ES_USER" \
--request GET 'https://localhost:9200/products_v4/_search' \
--header 'Content-Type: application/json' \
--data '{
"size": 5,
"query": {
"match_all": {}
},
"sort": [
{
"name.keyword": "asc"
}
]
}'GET /products_v4/_search
{
"size": 5,
"query": {
"match_all": {}
},
"sort": [
{
"name.keyword": "asc"
}
]
}Check timed_out: false and _shards.failed: 0, then inspect the returned documents. Replace this query with your real application queries as well; one generic search cannot validate the migration.
Restore your intended production settings. The example uses one replica and a one-second refresh interval. Substitute the values you chose for this deployment rather than assuming those are universal defaults.
5e. Apply the intended production settings
curl --fail-with-body --silent --show-error --user "$ES_USER" \
--request PUT 'https://localhost:9200/products_v4/_settings' \
--header 'Content-Type: application/json' \
--data '{
"index": {
"number_of_replicas": 1,
"refresh_interval": "1s"
}
}'PUT /products_v4/_settings
{
"index": {
"number_of_replicas": 1,
"refresh_interval": "1s"
}
}5f. Wait for the destination replica to be ready
curl --fail-with-body --silent --show-error --user "$ES_USER" \
--request GET 'https://localhost:9200/_cluster/health/products_v4?wait_for_status=green&timeout=60s'GET /_cluster/health/products_v4?wait_for_status=green&timeout=60sRequire timed_out: false and the expected health state. This example needs enough eligible data nodes to allocate its replica. A single-node lab with one replica stays yellow; choose an explicit lab replica policy instead of ignoring a production allocation failure. A health wait timing out means inspect allocation and repeat the check, not proceed. Cluster health.
Step 6: Swap the alias atomically, verify, then resume writes
Repeat 1a immediately before cutover. It must still resolve only to the old index. Coordinate with other operators and automation so nobody changes it during the migration.
Run both actions together as one request. Never issue separate remove and add requests.
6a. Move the alias in one request
curl --fail-with-body --silent --show-error --user "$ES_USER" \
--request POST 'https://localhost:9200/_aliases' \
--header 'Content-Type: application/json' \
--data '{
"actions": [
{
"remove": {
"index": "products_v3",
"alias": "products",
"must_exist": true
}
},
{
"add": {
"index": "products_v4",
"alias": "products",
"is_write_index": true
}
}
]
}'POST /_aliases
{
"actions": [
{
"remove": {
"index": "products_v3",
"alias": "products",
"must_exist": true
}
},
{
"add": {
"index": "products_v4",
"alias": "products",
"is_write_index": true
}
}
]
}must_exist: true makes a missing removal fail instead of allowing a partial action list. Check acknowledged: true, no reported action errors, and the resulting alias. This avoids a deliberate gap in alias routing; it does not guarantee every application request succeeds regardless of cluster health or client behavior. Alias action semantics.
6b. Verify the alias points only to the new index
curl --fail-with-body --silent --show-error --user "$ES_USER" \
--request GET 'https://localhost:9200/_alias/products'GET /_alias/productsIf cutover times out or returns an ambiguous result, inspect 6b before retrying. The change may already have applied.
6c. Test search through the application alias
curl --fail-with-body --silent --show-error --user "$ES_USER" \
--request GET 'https://localhost:9200/products/_search' \
--header 'Content-Type: application/json' \
--data '{
"size": 5,
"query": {
"match_all": {}
}
}'GET /products/_search
{
"size": 5,
"query": {
"match_all": {}
}
}Verify hits come from the new _index, check for shard failures, and test the real application. Only then resume producers and replay queued operations through the alias in order. Wait for that queue to drain before declaring the search index current. Keep the old index write-blocked so a missed direct writer fails visibly. Monitor failed writes and search latency.
When writes must continue during reindex
A background reindex is a copy, not an ongoing replication subscription. If uninterrupted writes and up-to-date searches are required, the migration needs a separate consistency protocol:
- Capture a durable change-log position before the baseline copy starts. Retain inserts, updates, and delete events, including document IDs and routing.
- Build the replacement, then replay changes into it in order. If replay overlaps the baseline, use a tested version-ordering strategy so older copied documents cannot overwrite newer changes.
- Establish a cutover barrier: drain in-flight writers and apply changes through a known position before moving reads. A brief ingestion pause or a coordinated writer handoff is usually necessary. Prove there is no untracked gap.
- Route subsequent writes to the replacement, drain remaining events, and monitor lag. Maintain the old index too if you need a rollback that preserves post-cutover writes.
Idempotency alone does not establish ordering. A second _reindex pass or an updated_at query does not reliably catch deletes and can race with new writes. Replaying only after switching reads exposes an incomplete index until replay finishes. These are application-level design decisions; the commands above do not implement change capture.
Rollback: check for new writes before moving back
The retained old index is a fallback for search, but it stops receiving writes after cutover. Once the replacement has accepted writes, an immediate reverse swap exposes stale data. Pause producers, account for in-flight operations, reconcile post-cutover inserts, updates, and deletes into the old index, and validate that its mapping can represent them. If it cannot, fix forward or rebuild from the source of truth.
Only when the old index is current and validated, or while all writes have remained paused since before the original copy, reverse the routing:
Rollback A. Swap back only after validating the old index
curl --fail-with-body --silent --show-error --user "$ES_USER" \
--request POST 'https://localhost:9200/_aliases' \
--header 'Content-Type: application/json' \
--data '{
"actions": [
{
"remove": {
"index": "products_v4",
"alias": "products",
"must_exist": true
}
},
{
"add": {
"index": "products_v3",
"alias": "products",
"is_write_index": true
}
}
]
}'POST /_aliases
{
"actions": [
{
"remove": {
"index": "products_v4",
"alias": "products",
"must_exist": true
}
},
{
"add": {
"index": "products_v3",
"alias": "products",
"is_write_index": true
}
}
]
}Repeat the alias and application checks. The old index is still write-blocked. Before resuming producers after a successful rollback, remove that block:
Rollback B. Unblock the old index before resuming writes
curl --fail-with-body --silent --show-error --user "$ES_USER" \
--request PUT 'https://localhost:9200/products_v3/_settings' \
--header 'Content-Type: application/json' \
--data '{
"index.blocks.write": false
}'PUT /products_v3/_settings
{
"index.blocks.write": false
}The same unblock request applies if you abandon the migration before cutover: confirm the alias still points only to the old index, stop any unwanted copy, inspect the failure, then unblock it and resume producers. Do not leave the application unintentionally stuck in its write maintenance window.
Keep the old index until the rollback window closes
Retain it for your agreed observation period, with a snapshot or reproducible rebuild path. Check all aliases, direct clients, dashboards, background jobs, and recovery requirements before deletion. Keeping an index for a day does not by itself make rollback safe.
Cleanup A. Inspect every alias on the old index
curl --fail-with-body --silent --show-error --user "$ES_USER" \
--request GET 'https://localhost:9200/products_v3/_alias'GET /products_v3/_aliasThe next request is optional and destructive. Run it separately, after the observation period and only when the old index is no longer needed. It deletes the entire named index, not just the application's alias.
Cleanup B. Delete the retired index only after sign-off
curl --fail-with-body --silent --show-error --user "$ES_USER" \
--request DELETE 'https://localhost:9200/products_v3'DELETE /products_v3Optional bash helper: copy and validate, then stop for review
The helper replaces the copy-and-count portion of steps 3–5. Do not run it after already starting 3c. It requires curl 7.76+, jq, an existing empty destination with your reviewed mapping, paused producers, and a write-blocked source. Set ES_USER and your CA trust as above. It prompts once for the password and does not print it.
bash es-zero-downtime-reindex.sh --host 'https://localhost:9200' --old 'products_v3' --new 'products_v4' --alias 'products'It stops on HTTP errors, missing task IDs, task errors, timeouts, document failures, conflicts, unexpected task counts, or mismatched document counts. It leaves settings, write blocks, aliases, and index deletion to the operator. It cannot prove document equality, query correctness, or application readiness. Continue the manual validation in 5d–5f, then review cutover in step 6. The older --yes and --rollback automation options are no longer supported.
Review the helper source
#!/usr/bin/env bash
# Copy and validate only. Alias cutover is intentionally manual.
# https://techearl.com/elasticsearch-zero-downtime-reindex
# Requires Bash, curl 7.76+, jq; regular indices in one Elasticsearch cluster.
set -euo pipefail
HOST="${ES_HOST:-https://localhost:9200}"
OLD_INDEX=""
NEW_INDEX=""
ALIAS=""
TASK=""
usage() {
cat <<'HELP'
Usage: bash es-zero-downtime-reindex.sh --old NAME --new NAME --alias NAME [--host URL]
Prepare the destination mapping, pause all producers, drain in-flight writes,
and add PUT /OLD/_block/write first. The destination must be empty and private.
This helper refreshes the source, starts one reindex, polls it, and checks counts.
It NEVER swaps aliases, changes settings, unblocks writes, or deletes indices.
Auth: ES_USER (default elastic), then a password prompt; or ES_AUTH=user:password.
TLS: set CURL_CA_BUNDLE to your private CA file if needed. Verification stays on.
--yes and --rollback are no longer supported. Review the article's manual steps.
HELP
}
fail() { printf 'ERROR: %s\n' "$*" >&2; exit 1; }
while [[ $# -gt 0 ]]; do
case "$1" in
--old|--new|--alias|--host)
[[ $# -ge 2 && -n "$2" ]] || fail "Missing value for $1"
case "$1" in
--old) OLD_INDEX="$2" ;;
--new) NEW_INDEX="$2" ;;
--alias) ALIAS="$2" ;;
--host) HOST="$2" ;;
esac
shift 2 ;;
-h|--help) usage; exit 0 ;;
*) usage >&2; fail "Unsupported argument: $1" ;;
esac
done
for name in "$OLD_INDEX" "$NEW_INDEX" "$ALIAS"; do
[[ "$name" =~ ^[a-z0-9][a-z0-9._-]*$ && ${#name} -le 255 ]] || fail "Use individual lowercase names, starting with a letter or number (no wildcards)."
done
[[ "$OLD_INDEX" != "$NEW_INDEX" && "$OLD_INDEX" != "$ALIAS" && "$NEW_INDEX" != "$ALIAS" ]] || fail "Index and alias names must all differ."
[[ "$HOST" =~ ^https?://[^/?#@[:space:]\"\']+(/[^?#@[:space:]\"\']*)?$ ]] || fail "Invalid host; do not put credentials, query strings, or fragments in the URL."
HOST="${HOST%/}"
command -v curl >/dev/null || fail "curl is required"
command -v jq >/dev/null || fail "jq is required"
AUTH="${ES_AUTH:-}"
if [[ -z "$AUTH" ]]; then
[[ -t 0 ]] || fail "Set ES_AUTH for non-interactive use, or run in a terminal for the password prompt."
read -r -s -p "Password for ${ES_USER:-elastic}: " password
printf '\n'
AUTH="${ES_USER:-elastic}:$password"
unset password
fi
[[ "$AUTH" != *$'\n'* && "$AUTH" != *$'\r'* ]] || fail "Credentials cannot contain newlines."
# Keep credentials out of command-line arguments and output.
AUTH_CONFIG="${AUTH//\\/\\\\}"
AUTH_CONFIG="${AUTH_CONFIG//\"/\\\"}"
unset AUTH ES_AUTH
es() {
curl --config <(printf 'user = "%s"\n' "$AUTH_CONFIG") \
--fail-with-body --silent --show-error --connect-timeout 15 --max-time 90 \
--header 'Content-Type: application/json' "$@"
}
json_request() {
local response
if ! response=$(es "$@"); then
printf '%s\n' "$response" >&2
fail "HTTP/transport failure. Do not retry a mutation until its cluster state is known."
fi
printf '%s' "$response" | jq -e 'type == "object" and (has("error") | not)' >/dev/null || fail "Invalid or error API response."
printf '%s' "$response"
}
check_shards() {
jq -e '._shards.failed == 0 and ._shards.successful > 0' >/dev/null || fail "Missing shard status or shard failures."
}
check_alias() {
json_request "$HOST/_alias/$ALIAS" | jq -e --arg old "$OLD_INDEX" --arg alias "$ALIAS" '
(keys == [$old]) and (.[$old].aliases[$alias] | type == "object" and
(has("filter") | not) and (has("routing") | not) and
(has("index_routing") | not) and (has("search_routing") | not) and
(.is_write_index != false))' >/dev/null || fail "Alias must point only to the source with no filter/routing and permit writes."
}
check_source_block() {
json_request "$HOST/$OLD_INDEX/_settings?flat_settings=true" | jq -e --arg old "$OLD_INDEX" '
(keys == [$old]) and (.[$old].settings["index.blocks.write"] | . == "true" or . == true)' >/dev/null \
|| fail "Source must be an exact index with a write block. Pause producers, then use PUT /OLD/_block/write."
}
count_index() {
local response
response=$(json_request "$HOST/$1/_count")
printf '%s' "$response" | check_shards
printf '%s' "$response" | jq -er '.count | select(type == "number" and . >= 0)'
}
reminder() {
local code=$?
if [[ -n "$TASK" ]]; then
printf '\nTask ID: %s\n' "$TASK" >&2
if [[ $code -ne 0 ]]; then
printf 'The task may still be running. Inspect it before starting another copy.\n' >&2
fi
fi
printf 'No alias/settings/deletion changes were made by this helper. Keep producers paused until cutover, or restore the old write path deliberately.\n' >&2
}
trap reminder EXIT
printf 'Checking alias, source write block, and destination...\n'
check_alias
check_source_block
json_request "$HOST/$NEW_INDEX" | jq -e --arg new "$NEW_INDEX" '
(keys == [$new]) and (.[$new].aliases == {})' >/dev/null || fail "Destination must be an exact index with no aliases."
json_request -X POST "$HOST/$NEW_INDEX/_refresh" | check_shards
[[ "$(count_index "$NEW_INDEX")" == "0" ]] || fail "Destination is not empty. Choose a new index and inspect the partial copy."
json_request -X POST "$HOST/$OLD_INDEX/_refresh" | check_shards
OLD_COUNT=$(count_index "$OLD_INDEX")
BODY=$(jq -nc --arg old "$OLD_INDEX" --arg new "$NEW_INDEX" \
'{source:{index:$old,size:1000},dest:{index:$new,op_type:"create"}}')
printf 'Starting one asynchronous copy. If no task ID returns, inspect GET /_tasks?actions=*reindex&detailed=true before retrying.\n'
START=$(json_request -X POST "$HOST/_reindex?wait_for_completion=false&slices=auto&requests_per_second=500" --data "$BODY")
TASK=$(printf '%s' "$START" | jq -er '.task | select(type == "string" and test("^[A-Za-z0-9_-]+:[0-9]+$"))') || fail "Missing or invalid task ID; inspect running tasks before retrying."
printf 'Task: %s\n' "$TASK"
while true; do
RESULT=$(json_request "$HOST/_tasks/$TASK")
printf '%s' "$RESULT" | jq -e '.completed | type == "boolean"' >/dev/null || fail "Task response has no completed flag."
if [[ "$(printf '%s' "$RESULT" | jq -r '.completed')" == "true" ]]; then break; fi
printf 'Copy is still running. Waiting 5 seconds...\n'
sleep 5
done
printf '%s' "$RESULT" | jq -e '
.response | type == "object" and .timed_out == false and .failures == [] and
.version_conflicts == 0 and (.total | type == "number") and
.created == .total and .updated == 0 and .deleted == 0 and .noops == 0' >/dev/null \
|| { printf '%s\n' "$RESULT" >&2; fail "Task completed without a clean full-copy result. Inspect it; do not cut over."; }
check_source_block
check_alias
json_request -X POST "$HOST/$NEW_INDEX/_refresh" | check_shards
FINAL_OLD_COUNT=$(count_index "$OLD_INDEX")
NEW_COUNT=$(count_index "$NEW_INDEX")
CREATED=$(printf '%s' "$RESULT" | jq -r '.response.created')
[[ "$OLD_COUNT" == "$FINAL_OLD_COUNT" && "$OLD_COUNT" == "$NEW_COUNT" && "$OLD_COUNT" == "$CREATED" ]] \
|| fail "Count mismatch: source before=$OLD_COUNT, source now=$FINAL_OLD_COUNT, destination=$NEW_COUNT, task created=$CREATED."
printf 'Copy checks passed: %s documents. The alias still points to %s.\n' "$NEW_COUNT" "$OLD_INDEX"
printf 'Next: compare document contents and application queries, restore intended settings, wait for replicas, and review the manual alias swap.\n'Elasticsearch 8.x and 9.x scope
The REST APIs used here are documented for Elasticsearch 8.x and 9.x. This is a documentation-reviewed walkthrough, not a claim that every release and deployment combination has been execution-tested. Rehearse the exact requests against your version and a representative dataset before production. Managed or Serverless deployments can restrict index settings and privileges. OpenSearch has a separate version and security model; use its documentation when adapting the pattern.
Frequently asked questions
See also
- Elasticsearch Cheat Sheet: index operations, search DSL, aggregations, and task commands.
- How to Run Elasticsearch in Docker: a local environment for rehearsing migrations.
- How to Build RAG with Embeddings and Vector Search: generating and indexing vectors when changing the search model.
- MySQL Cheat Sheet: working with a typical source of truth for an Elasticsearch search index.
- Elasticsearch ransomware: how an open port wiped my database: why authentication and network isolation matter.





