Three times so far this track has arrived at the same wall. A field's type cannot be changed. An analyzer cannot be changed. A shard count cannot be changed. In every case the answer was "build a new index and copy the data into it", and every time it was deferred to this lesson.
Here is the thing that decides whether that is a routine afternoon or an outage, and it is a decision you make on day one, long before you need it: never let your application name a concrete index.
What an alias is
An alias is a name that points at one or more indexes. Searches, gets and writes can all use it, and nothing on the outside can tell the difference:
GET /stayhub-properties/_search # the alias
# "_index": "stayhub-properties-000002"The application asked for stayhub-properties; the hit came from
stayhub-properties-000002. The concrete index behind the name can change, and moving it
is a single atomic operation.
That is the entire mechanism, and everything below is a consequence.
It is worth being clear about what an alias is not. It is not a copy, so it costs no disk and no indexing work. It is not a view in the SQL sense — there is no transformation, though a filter can be attached. And it is not a redirect the client can see: the response says which concrete index answered, which is occasionally the only clue that a flip happened.
Aliases live in the cluster state, so creating and moving them is nearly instant and is replicated to every node before the call returns. That is what makes an atomic flip possible at all.
The rule
Configure the alias, never the index:
# ⚠️ An ALIAS, not an index. `app/search/index.py` keeps the concrete indices behind it
# (`stayhub-properties-000001`, …) so a mapping change can be a reindex + alias flip with no
# downtime instead of a delete and a rebuild.
elasticsearch_index: str = "stayhub-properties"Nothing outside the search module needs to know the generations exist. The naming convention is worth a moment though:
def _generation_name(n: int) -> str:
"""`stayhub-properties-000007`. Zero-padded so the names sort lexicographically, which is what
makes `_cat/indices` and a wildcard listing come back in creation order."""
return f"{ALIAS}-{n:06d}"Zero-padding is not cosmetic. Without it ...-10 sorts before ...-9 in
every listing, wildcard and shell glob you will ever use.
Create the index and the alias in one call
index = _generation_name(1)
# Created with the alias attached IN THE SAME CALL. Create-then-alias is two requests, and a
# crash between them leaves an index no reader can find and a startup that thinks it is done.
es.indices.create(index=index, body={**INDEX_SETTINGS, "aliases": {ALIAS: {}}})Two requests would leave a window in which the index exists and the alias does not, and startup code that crashed in that window would come back believing its work was done.
The zero-downtime reindex
Four steps, and the order is the whole point:
"""Build a new index with the CURRENT mapping, copy the data in, and flip the alias.
This is the zero-downtime mapping change, and the order of the four steps is the whole point:
1. create `…-00000N+1` from `INDEX_SETTINGS` as it stands in this file
2. `_reindex` from the alias into it — readers are still on the old index, unaffected
3. `update_aliases` with remove + add **in one call**, which Elasticsearch applies atomically;
there is no instant at which the alias points at nothing
4. only then delete the old index
"""Step three is the one that matters:
es.indices.update_aliases(
body={
"actions": [
{"remove": {"index": old, "alias": ALIAS}},
{"add": {"index": new, "alias": ALIAS}},
]
}
)Both actions in one request. Elasticsearch applies the list atomically, so there is no moment at which the alias resolves to nothing, or to both. Two separate calls give you a window — small, real, and guaranteed to be hit eventually by whatever runs most often.
Running it against the demo index:
$ python -m scripts.reindex
stayhub-properties-000001 -> stayhub-properties-000002 (12 documents)
previous index kept for rollback. Delete it with: curl -XDELETE $ES/stayhub-properties-000001
$ python -m scripts.reindex --status
alias: stayhub-properties
points at: stayhub-properties-000002
indices:
stayhub-properties-000001 12 docs aliases: -
stayhub-properties-000002 12 docs aliases: stayhub-properties <- aliasSearch never stopped.
Keeping the old index is the rollback
⚠️ `drop_old=False` by default. Keeping the previous index costs disk and buys the rollback: if
the new mapping is wrong, flipping the alias back is one call and takes no time at all. Delete
it once the new one has served real traffic.This is the underrated half. A mapping change that turns out to be wrong — a stemmer that
mangles product codes, a type that loses precision — is usually discovered by users, minutes
or hours after the deploy. If the previous index is still there, recovery is one
update_aliases call and is instant. If you deleted it, recovery is another full
reindex while search is wrong.
Disk is cheap. Keep the previous generation until the new one has served real traffic for a day.
Writes that land during the copy
The honest caveat:
⚠️ Writes that land DURING step 2 go to the old index and are not copied. StayHub can shrug
that off — Postgres is the source of truth and `rebuild_index` repairs anything missed — but a
system where the index is the source of truth needs dual writes or a replay from a log here,
and this is the exact gap it needs them for._reindex takes a snapshot at the moment it starts. Anything written after that goes
to the old index and is lost at the flip.
Three ways to handle it, in increasing order of effort. Ignore it, if the index is derived and a rebuild or the next write repairs the gap — which is the case here. Dual-write to both indexes for the duration, which needs your indexing layer to know a migration is happening. Catch up afterwards by reindexing everything modified since the copy started, which needs a reliable timestamp on every document and is the standard answer when the index is authoritative.
Whichever you pick, decide it before you start, because discovering it mid-migration is how a "zero-downtime" reindex becomes a data loss incident.
Migrating a cluster that got this wrong
Most people arrive here with a concrete index already in production, and an alias cannot share a name with an index. So something has to give, and the migration is not zero-downtime — there is a moment when the name resolves to nothing:
$ python -m scripts.reindex --adopt
stayhub-properties is a concrete index with 12 documents.
1. create stayhub-properties-000001
2. reindex stayhub-properties -> stayhub-properties-000001
3. delete stayhub-properties (frees the name; SEARCH IS DOWN from here)
4. create alias stayhub-properties -> stayhub-properties-000001
proceed? [y/N] y
adopted: stayhub-properties -> stayhub-properties-000001 (12 documents)The gap is milliseconds and it is real. It is also why this is a script with a prompt rather than something startup code does:
⚠️ It also refuses to guess when it finds a CONCRETE index sitting on the alias's name. That is
the pre-alias layout — a cluster created before 2026-08-22 — and Elasticsearch will not let an
alias and an index share a name, so something has to give. Fixing it means moving real
documents, which is not a thing a process should do silently while starting up: two API
instances booting at once would both start it.Note also the check before the destructive step:
copied = result.get("created", 0)
if copied != docs:
# Bail before the destructive step. The old index is still there and still serving.
print(f"ERROR: copied {copied} of {docs} documents. Old index untouched; fix and retry.")
es.options(ignore_status=404).indices.delete(index=new)
return 1Count before you delete. A migration script that deletes the source because the copy returned without raising is a migration script that will one day delete the source after copying half of it.
The refusal that catches you first
Once the name is an alias, this stops working:
curl -XDELETE localhost:9200/stayhub-propertiesillegal_argument_exception
The provided expression [stayhub-properties] matches an alias, specify the
corresponding concrete indices instead.Elasticsearch refuses to let a name that could hide many indexes be the target of a destructive call. That refusal is a feature, and it means any code that deletes an index has to resolve the name first:
# ⚠️ Delete the CONCRETE index, not the alias. `DELETE /stayhub-properties` fails with
# "the provided expression matches an alias, specify the corresponding concrete indices
# instead" — Elasticsearch refuses to let a name that many indices could hide behind be the
# target of a destructive call. That refusal is a feature; resolve the name first.
concrete = current_index(client)Rehearsing the flip
A migration you have never run is a migration you are running for the first time in production. Three things make the rehearsal cheap, and all three are worth building into the script rather than into a runbook.
Make it idempotent and re-runnable. A reindex into a fresh generation can be run as many times as you like — each run makes a new index and flips to it. That means you can run it on a copy of production data, inspect the result, and run it again after fixing the mapping, without ever being in a half-migrated state.
Make the status visible in one command.
def status(es) -> None:
print(f"alias: {ALIAS}")
target = current_index(es)
print(f"points at: {target or '(no alias — see --adopt)'}")
print(f"next generation would be: {next_generation(es)}")
print("\nindices:")
for name in sorted(es.indices.get(index=f"{ALIAS}*", ignore_unavailable=True)):
count = es.count(index=name)["count"]
aliases = ", ".join(es.indices.get_alias(index=name).get(name, {}).get("aliases", {})) or "-"
marker = " <- alias" if name == target else ""
print(f" {name:32} {count:6} docs aliases: {aliases}{marker}")Which index is live, how many documents each generation holds, and what the next one will be
called. During a migration that is the only question anyone asks, and answering it by hand from
three _cat calls under pressure is how mistakes happen.
Verify the new index before flipping, not after. The document count is the minimum. Better: run a handful of representative queries against the new index by its concrete name and compare the results to the same queries against the alias. A mapping change that quietly breaks one filter will show up there and nowhere in a count.
What else aliases do
The reindex flip is the headline. Three more uses are worth knowing.
A filtered alias is a saved query attached to a name:
POST /_aliases
{ "actions": [ { "add": {
"index": "stayhub-properties-000001",
"alias": "cabins-only",
"filter": { "term": { "property_type": "CABIN" } }
} } ] }
GET /cabins-only/_count # 2
GET /stayhub-properties/_count # 12Every query through cabins-only silently carries that filter. This is a real
multi-tenancy tool — an alias per tenant over a shared index — and it is stronger than a
filter in application code, because it cannot be forgotten. It is not a security boundary on its
own: anyone who can name the underlying index bypasses it. Pair it with role-based access that only
grants the alias, which lesson 18 covers.
One alias over several indexes searches them together, which is how monthly log indexes are queried as one. Reads are fine. Writes are not, until you say where they go:
no write index is defined for alias [multi]. The write index may be explicitly
disabled using is_write_index=false or the alias points to multiple indices
without one being designated as a write indexPOST /_aliases
{ "actions": [ { "add": { "index": "tmp-c", "alias": "multi", "is_write_index": true } } ] }
# now writes through `multi` land in tmp-cRollover builds on exactly that. POST /alias/_rollover creates the
next generation and moves the write alias to it when a condition is met — age, document count,
size. Reads keep hitting every generation, writes go to the newest. That is the whole model behind
time-series indexes and, in its modern form, data streams. Lesson 18.
Two aliases are better than one
A refinement worth adopting once the basic pattern is in place: give the index a read alias and a write alias, rather than one alias doing both.
stayhub-properties -> read -> …-000002
stayhub-properties-write -> write -> …-000002Most of the time they point at the same index and nothing is different. During a migration they can be moved separately, which unlocks the dual-write strategy from earlier: point the write alias at the new index first so incoming changes land there, let the reindex catch up the backlog, then move the read alias.
It also makes a whole class of accident impossible. Code that only holds the read alias cannot write, because a read-only alias with no write index refuses writes with the error shown above. That is a stronger guarantee than a code review.
The cost is one more name to keep straight, and it is worth it on any index where the migration is expected to be more than a flip.
Reindexing something large
The demo copies twelve documents instantly. Two parameters matter once it is millions.
POST /_reindex?wait_for_completion=false&slices=auto
{
"source": { "index": "stayhub-properties", "size": 1000 },
"dest": { "index": "stayhub-properties-000003" }
}wait_for_completion=false returns a task id immediately instead of holding an HTTP
connection open for an hour. Poll it with GET /_tasks/<id>, which reports progress,
and cancel it with POST /_tasks/<id>/_cancel if it is hurting the cluster.
slices=auto parallelises the copy, one slice per shard. On a multi-shard index this
is most of the speed available.
You can also throttle it — requests_per_second — which is the polite
thing to do when reindexing a production cluster that is also serving traffic. And
_reindex can read from a remote cluster, which is how a version upgrade that
crosses an incompatible index format is usually done.
One more capability worth knowing: a script in the reindex body transforms documents
on the way through. Renaming a field, splitting one into two, or backfilling a default is a
migration and a mapping change in one pass, which beats doing them separately.
When a reindex is not the answer
Everything here assumes copying is the cheapest way to get the new index. Sometimes it is not, and it is worth recognising the two cases.
When the index is derived and the source is fast. StayHub's twelve listings come
out of Postgres in milliseconds, so --rebuild is simpler than a reindex and produces a
guaranteed-current index rather than a snapshot with a gap. The crossover is roughly where reading
the source system becomes slower than copying segments — usually a lot further out than people
assume, because a reindex re-analyses every field while a rebuild also has to serialise every row.
The tell is whether the new mapping needs data the old index does not have. A new field backfilled from the database cannot come from a reindex at all, whatever its size.
When the change does not need a new index. Adding a field is a plain mapping
update. Populating a newly added multi-field on existing documents is
_update_by_query, which rewrites documents in place through the current mapping and
needs no second index, no cutover and no alias flip. Reach for those first; a reindex is for what
they cannot do.
The habits
Point applications at an alias from the very first deploy, even when there is one index and no plan to change it. It costs nothing and it is the only part of this that cannot be added later without downtime.
Number your indexes with zero-padded generations. Create index and alias in one call. Flip with
remove and add in one update_aliases. Keep the previous generation until the new one has
served real traffic. Count documents before deleting anything. And decide what happens to writes
during the copy before starting the copy.
Do that and the three walls this track kept hitting stop being walls. A type change, an analyzer change and a shard-count change all become the same routine operation — which is the point of learning it before you need it rather than during an incident.