TechEarl

Elasticsearch Reindex Without Downtime: curl and Kibana

Reindex Elasticsearch with aliases using matching curl and Kibana requests. Check task results, handle writes, validate the new index, and switch search traffic safely.

Ishan Karunaratne⏱️ 14 min readUpdated
Share thisCopied
Elasticsearch reindexing with an alias connecting the application to a replacement index

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.

One alias. Two index generations.
ApplicationSame search endpoint
Stable aliasproducts
Current index
products_v3Serving searches
New index
products_v4Copy, catch up, validate
Searches continue through products to products_v3 while products_v4 is prepared. Resolve writes before swapping.Illustration only. These controls do not connect to Elasticsearch.

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

Try it with your own values

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.

bash
export ES_USER='elastic'

For a self-managed cluster with a private certificate authority, configure its trusted CA certificate before running curl:

bash
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 · Terminal
curl --fail-with-body --silent --show-error --user "$ES_USER" \
  --request GET 'https://localhost:9200/'
Kibana · Dev Tools
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 _source enabled. 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 · Terminal
curl --fail-with-body --silent --show-error --user "$ES_USER" \
  --request GET 'https://localhost:9200/_alias/products'
Kibana · Dev Tools
GET /_alias/products

Expect 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 · Terminal
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
      }
    }
  ]
}'
Kibana · Dev Tools
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 · Terminal
curl --fail-with-body --silent --show-error --user "$ES_USER" \
  --request GET 'https://localhost:9200/products_v3/_mapping'
Kibana · Dev Tools
GET /products_v3/_mapping

1d. Record the source settings

curl · Terminal
curl --fail-with-body --silent --show-error --user "$ES_USER" \
  --request GET 'https://localhost:9200/products_v3/_settings?include_defaults=true'
Kibana · Dev Tools
GET /products_v3/_settings?include_defaults=true

Record 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 · Terminal
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"
      }
    }
  }
}'
Kibana · Dev Tools
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 · Terminal
curl --fail-with-body --silent --show-error --user "$ES_USER" \
  --request GET 'https://localhost:9200/products_v4'
Kibana · Dev Tools
GET /products_v4

2c. Refresh the destination before checking that it is empty

curl · Terminal
curl --fail-with-body --silent --show-error --user "$ES_USER" \
  --request POST 'https://localhost:9200/products_v4/_refresh'
Kibana · Dev Tools
POST /products_v4/_refresh

2d. Confirm zero destination documents

curl · Terminal
curl --fail-with-body --silent --show-error --user "$ES_USER" \
  --request GET 'https://localhost:9200/products_v4/_count'
Kibana · Dev Tools
GET /products_v4/_count

Require 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 · Terminal
curl --fail-with-body --silent --show-error --user "$ES_USER" \
  --request PUT 'https://localhost:9200/products_v3/_block/write'
Kibana · Dev Tools
PUT /products_v3/_block/write

Wait 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 · Terminal
curl --fail-with-body --silent --show-error --user "$ES_USER" \
  --request POST 'https://localhost:9200/products_v3/_refresh'
Kibana · Dev Tools
POST /products_v3/_refresh

Check _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 · Terminal
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"
  }
}'
Kibana · Dev Tools
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 · Terminal
curl --fail-with-body --silent --show-error --user "$ES_USER" \
  --request GET 'https://localhost:9200/_tasks?actions=*reindex&detailed=true'
Kibana · Dev Tools
GET /_tasks?actions=*reindex&detailed=true

Step 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 · Terminal
curl --fail-with-body --silent --show-error --user "$ES_USER" \
  --request GET 'https://localhost:9200/_tasks/<task_id>'
Kibana · Dev Tools
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 response object with timed_out: false and an empty failures array.
  • version_conflicts: 0.
  • created equal to total; updated, deleted, and noops equal 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 · Terminal
curl --fail-with-body --silent --show-error --user "$ES_USER" \
  --request POST 'https://localhost:9200/products_v4/_refresh'
Kibana · Dev Tools
POST /products_v4/_refresh

5b. Count the frozen source

curl · Terminal
curl --fail-with-body --silent --show-error --user "$ES_USER" \
  --request GET 'https://localhost:9200/products_v3/_count'
Kibana · Dev Tools
GET /products_v3/_count

5c. Count the replacement

curl · Terminal
curl --fail-with-body --silent --show-error --user "$ES_USER" \
  --request GET 'https://localhost:9200/products_v4/_count'
Kibana · Dev Tools
GET /products_v4/_count

Require 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 · Terminal
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"
    }
  ]
}'
Kibana · Dev Tools
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 · Terminal
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"
  }
}'
Kibana · Dev Tools
PUT /products_v4/_settings
{
  "index": {
    "number_of_replicas": 1,
    "refresh_interval": "1s"
  }
}

5f. Wait for the destination replica to be ready

curl · Terminal
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'
Kibana · Dev Tools
GET /_cluster/health/products_v4?wait_for_status=green&timeout=60s

Require 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 · Terminal
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
      }
    }
  ]
}'
Kibana · Dev Tools
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 · Terminal
curl --fail-with-body --silent --show-error --user "$ES_USER" \
  --request GET 'https://localhost:9200/_alias/products'
Kibana · Dev Tools
GET /_alias/products

If 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 · Terminal
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": {}
  }
}'
Kibana · Dev Tools
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:

  1. Capture a durable change-log position before the baseline copy starts. Retain inserts, updates, and delete events, including document IDs and routing.
  2. 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.
  3. 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.
  4. 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 · Terminal
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
      }
    }
  ]
}'
Kibana · Dev Tools
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 · Terminal
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
}'
Kibana · Dev Tools
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 · Terminal
curl --fail-with-body --silent --show-error --user "$ES_USER" \
  --request GET 'https://localhost:9200/products_v3/_alias'
Kibana · Dev Tools
GET /products_v3/_alias

The 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 · Terminal
curl --fail-with-body --silent --show-error --user "$ES_USER" \
  --request DELETE 'https://localhost:9200/products_v3'
Kibana · Dev Tools
DELETE /products_v3

Optional bash helper: copy and validate, then stop for review

es-zero-downtime-reindex.shCopy a write-blocked source into an empty destination, poll the task, and check its result and document counts. Alias cutover stays manual.Download

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 · Terminal
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
bash
#!/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

Yes. Use each Kibana Dev Tools request beside the curl version. Execute one complete request at a time, including its JSON body. After starting the asynchronous reindex, poll the returned task ID and verify successful completion before moving to validation or alias cutover.

No. Create and inspect the destination yourself. Reindex copies source documents; it does not reproduce the source index configuration. Matching index templates may still apply to a newly created destination, so check its actual settings and aliases.

The alias keeps routing them to the old index until cutover, and the copy may not include them. Pause and queue writes for the entire migration, or use durable change capture and an ordered cutover protocol. Updates and deletes matter as much as new documents.

Save the task value returned by POST /_reindex?wait_for_completion=false, then request GET /_tasks/<task_id>. Wait for completed: true and inspect error, response.failures, timed_out, conflicts, and document counters. Completion alone does not mean success.

There is no reliable duration based on document count alone. Document size, analyzers, shard layout, storage, replicas, throttling, and competing traffic all affect throughput. Measure a representative copy and monitor the task and cluster. Slicing may help, but it can also increase production load.

Yes. The reindex request supports a top-level script alongside source and dest, or a pipeline specified under dest. Test transformations separately and adjust validation for intentional changes to IDs, fields, or document counts.

See also

TagsElasticsearchReindexAliasesKibanaZero DowntimeMappingDevOps

Found this useful? Pass it on.

Copied

Ishan Karunaratne

Systems and Network Architect · Chief Technology Officer

Systems and network architect and Chief Technology Officer with more than two decades designing, building, and running production software, cloud and network architecture, Linux systems, and the bare metal underneath them, and lately working AI into the stack. A US Army veteran who served in Operation Iraqi Freedom. What I write here is drawn from the full arc of that work, across architecture, engineering, and operations, not any single job.

Keep reading

Related posts