A mapping is the schema of an index: which fields exist, what type each one is, and how the text ones are analysed. You do not have to write one. Elasticsearch will infer it from the first document you send — and that is the problem, because it infers from one document.
This lesson shows what that costs with a worked example you can run, then how to write a mapping properly, and finishes with the constraint that shapes every operational decision later in this track: a field's type cannot be changed once it has been written.
What dynamic mapping actually does
Create nothing. Just index a document into an index that does not exist:
curl -XPOST 'localhost:9200/dyn-test/_doc/1?refresh=true' \
-H 'Content-Type: application/json' \
-d '{"price": 120, "city": "San Francisco", "when": "2026-08-22"}'The index was created, and so was a mapping. Ask what it decided:
curl -s localhost:9200/dyn-test/_mapping?pretty{
"dyn-test": { "mappings": { "properties": {
"city": { "type": "text",
"fields": { "keyword": { "type": "keyword", "ignore_above": 256 } } },
"price": { "type": "long" },
"when": { "type": "date" }
} } }
}Three guesses, and they are reasonable guesses. "San Francisco" became
text with a keyword sub-field, so it can be both matched loosely and
grouped exactly. "2026-08-22" was recognised as a date. And 120 became a
long, because it is a whole number.
That last one is the whole lesson.
The bug this causes, in full
The next listing is priced at 119.50. What happens?
curl -XPOST 'localhost:9200/dyn-test/_doc/2?refresh=true' -H 'Content-Type: application/json' \
-d '{"price": 119.50}'
# {"result":"created", ...}
curl -XPOST 'localhost:9200/dyn-test/_doc/3?refresh=true' -H 'Content-Type: application/json' \
-d '{"price": 119.99}'
# {"result":"created", ...}Both accepted. No error, no warning. Read one back and it looks perfect:
curl -s localhost:9200/dyn-test/_doc/3
# "_source": { "price": 119.99 }Which is exactly why this is dangerous. _source is the JSON you sent, stored
verbatim — it is not the indexed value. The indexed value went through the
long field's coercion and was truncated. So the API response your
users see is right, and every query is wrong:
curl -s localhost:9200/dyn-test/_search -H 'Content-Type: application/json' \
-d '{"query": {"term": {"price": 119}}, "_source": ["price"]}'
# hits: [ {"price": 119.5}, {"price": 119.99} ] <- both are 119 in the indexNow the failure a user actually reports. Filter for listings at 119.60 or more:
curl -s localhost:9200/dyn-test/_search -H 'Content-Type: application/json' \
-d '{"query": {"range": {"price": {"gte": 119.6}}}, "_source": ["price"]}'
# hits: [ {"price": 120} ] <- the 119.99 listing is missingA listing that costs 119.99 does not appear in a search for listings costing 119.60 and up. And sorting agrees with the index, not with the source:
[
[ {"price": 119.5}, "sort": [119] ],
[ {"price": 119.99}, "sort": [119] ],
[ {"price": 120}, "sort": [120] ]
]Nothing here logged an error. Nothing looked broken. A support ticket about "search sometimes misses a listing" is roughly the worst possible way to find out, and the fix requires reindexing every document, because the truncation already happened at write time.
Writing the mapping yourself
The alternative is fifteen lines. Create the index before anything writes to it, and say what you mean:
INDEX_SETTINGS: dict[str, Any] = {
"settings": {
"number_of_shards": 1,
"number_of_replicas": 0,
...
"mappings": {
"dynamic": "strict",
"properties": {
"public_id": {"type": "keyword"},
"title": {"type": "text", "analyzer": "stayhub_text"},
"city": {
"type": "text",
"analyzer": "stayhub_text",
"fields": {"raw": {"type": "keyword"}},
},
"property_type": {"type": "keyword"},
"amenities": {"type": "keyword"},
"price_per_night": {"type": "scaled_float", "scaling_factor": 100},
"max_guests": {"type": "integer"},
"location": {"type": "geo_point"},
"created_at": {"type": "date"},
},
},
}Every decision in there is a decision that dynamic mapping would have made differently, and lesson 4 goes through the types one by one. Two of them are worth pulling out now because they are about mapping structure rather than about types.
Multi-fields: one field, indexed twice
"city": {
"type": "text",
"analyzer": "stayhub_text",
"fields": {"raw": {"type": "keyword"}},
},You send city once. Elasticsearch indexes it twice, under two names.
city is analysed — "San Francisco" becomes the terms san and
francisco, so a search for "san fran" can match it loosely. city.raw is a
keyword, stored as the single exact term San Francisco.
You need both, and for different jobs. Matching wants the analysed one. Sorting, exact filtering
and aggregations want the raw one — group listings by city and you get
buckets for "san" and "francisco" separately, because aggregations run on terms and the terms of a
text field are its tokens. Lesson 13 has that mistake in full.
Dynamic mapping does this for you automatically, under the name .keyword rather
than .raw. That is one of the better things it does, and it is worth knowing the name
it uses because half the examples online say .keyword.
dynamic: strict, and its two siblings
"dynamic": "strict",There are three settings and they mean very different things.
"dynamic": true is the default: an unknown field is added to the mapping and
indexed.
"dynamic": false stores an unknown field in _source but does not index
it. So it comes back in results and is invisible to every query — which is the most confusing
of the three, because the data is plainly there.
"dynamic": "strict" rejects the whole document:
curl -XPOST localhost:9200/strict-test/_doc/1 -H 'Content-Type: application/json' \
-d '{"titel": "typo"}'strict_dynamic_mapping_exception
[1:10] mapping set to strict, dynamic introduction of [titel] within [_doc] is not allowedThat is a typo caught at write time, by name, with a line number. Under the default it would
have created a field called titel that nothing will ever query, and the document would
have been missing its title with no complaint from anyone.
Strict is the right default for a mapping you own and write to from your own code. It is the wrong default for logs and metrics, where the whole point is that you do not know the field names in advance.
The constraint everything else follows from
Try to fix the price field from earlier:
curl -XPUT localhost:9200/dyn-test/_mapping -H 'Content-Type: application/json' \
-d '{"properties": {"price": {"type": "double"}}}'illegal_argument_exception
mapper [price] cannot be changed from type [long] to [double]Not "will be slow", not "will apply to new documents". Refused. This is not Elasticsearch being awkward: the type decided how the values were encoded into the Lucene segment files on disk, those files are immutable, and there is no operation that rewrites them in place.
So the answer is always the same shape — build a new index with the mapping you want, copy the documents into it, and switch over. Whether that is a routine afternoon or an outage depends on one decision you make on day one, which is lesson 15.
What you can change
Some things are fine. Adding a new field is always allowed:
curl -XPUT localhost:9200/dyn-test/_mapping -H 'Content-Type: application/json' \
-d '{"properties": {"newfield": {"type": "keyword"}}}'
# {"acknowledged": true}So is adding a multi-field to an existing field. And here is the trap, because it succeeds and then does not work:
curl -XPUT localhost:9200/dyn-test/_mapping -H 'Content-Type: application/json' \
-d '{"properties": {"city": {"type": "text", "fields": {"raw": {"type": "keyword"}}}}}'
# {"acknowledged": true}
curl -s localhost:9200/dyn-test/_search -H 'Content-Type: application/json' \
-d '{"query": {"term": {"city.raw": "San Francisco"}}}'
# hits: 0The mapping change was accepted. The existing documents do not have the new sub-field, because mapping changes apply at index time and those documents were indexed before it existed. New documents will have it; old ones never will until they are rewritten.
This catches people constantly, and the tell is an aggregation that returns fewer buckets than
there are documents. The fix is _update_by_query to reindex documents in place, or a
full reindex — not another mapping change.
Runtime fields: the escape hatch
Reindexing is the real fix, and reindexing takes planning. When you need the query to work this afternoon, a runtime field computes a value at query time instead of reading one from the index:
{
"runtime_mappings": {
"price_exact": {
"type": "double",
"script": "emit(Double.parseDouble(params._source.price.toString()))"
}
},
"query": { "range": { "price_exact": { "gte": 119.6 } } },
"fields": ["price_exact"]
}Because it reads _source — which was never truncated — it sees 119.99
and the query is correct. Nothing was reindexed and nothing was rewritten.
The cost is exactly what you would expect: the script runs for every document the query touches, every time. That is fine for a filter that runs after other filters have narrowed things down, and it is not fine as the primary clause on a large index. Runtime fields are a bridge to a reindex, not an alternative to one — but they are a very good bridge, because they let you ship the fix and schedule the migration separately.
They can also be declared in the mapping rather than per query, which makes them look like real fields to every caller. Same cost, better ergonomics.
What happens to bad data
Two settings decide this, and the defaults are stricter than people expect.
By default, a value that cannot be parsed rejects the whole document:
curl -XPOST localhost:9200/mal-test/_doc/1 -H 'Content-Type: application/json' -d '{"price": "not a number"}'document_parsing_exception
[1:10] failed to parse field [price] of type [long] in document with id '1'.
Preview of field's value: 'not a number'The whole document, not just the field. That is usually right — a listing with an unparseable price is not a listing you want in search results.
When it is not right, ignore_malformed changes the deal:
{ "mappings": { "properties": {
"guests": { "type": "integer", "ignore_malformed": true }
} } }Now the document is indexed, every other field works, and the bad field is silently skipped.
Silently is the word to worry about — _source still shows
"guests": "lots", so the document looks complete, while
{"exists": {"field": "guests"}} matches zero documents.
Elasticsearch does keep a record, in a metadata field most people never hear about:
curl -s localhost:9200/mal-test/_search -H 'Content-Type: application/json' -d '{"query": {"term": {"_ignored": "guests"}}}'
# hits: [ { "_source": {"guests": "lots", "price": 5}, "_ignored": ["guests"] } ]That query is worth putting on a dashboard the day you enable ignore_malformed
anywhere. Otherwise you have chosen to accept broken data and given yourself no way to find out how
much of it there is.
The related setting is coerce, which is what quietly turned 119.99
into 119 and "5" into 5. Setting
"coerce": false on a numeric field makes those hard errors instead. On a field whose
values come from your own code — where a string arriving in a numeric field means a bug, not
a sloppy input — that is the setting you want.
Settings are not mappings, and some are static too
An index create body has two halves and they behave differently. Under mappings you
have the field types. Under settings you have how the index is stored, and those split
again into static and dynamic.
Static settings can only be set at creation. number_of_shards is
the one that matters — you cannot change it later, and a badly chosen shard count is another
reason to reindex. Lesson 18 covers how to pick it.
Dynamic settings can be changed on a live index:
curl -XPUT localhost:9200/dyn-test/_settings -H 'Content-Type: application/json' \
-d '{"index": {"number_of_replicas": 1, "refresh_interval": "30s"}}'number_of_replicas is dynamic, which is genuinely useful: set it to 0 during a big
bulk load and back to 1 afterwards, and the load does not pay to write everything twice.
refresh_interval is the same idea for a different cost — lesson 7 explains what
refreshing is and why raising it speeds up indexing.
Fields you store but never search
Two more per-field settings earn their place once an index gets large, and both are about not building structures you will never use.
"cover_image_url": {"type": "keyword", "index": False},"index": false means the field is stored in _source and returned with
every hit, but no inverted index is built for it. Nobody searches for a listing by its
image URL — it is there to render the result card. Indexing it would build a term dictionary
entry per document for a field with no repeated values, which is the worst shape an inverted index
can have.
The mirror image is doc_values. The inverted index answers "which documents contain
this term"; doc values answer "what is this document's value for this field", which is what sorting
and aggregating need. Both are built by default for most types. If a field is only ever matched and
never sorted or aggregated, "doc_values": false saves the disk.
Neither is worth thinking about on a small index. Both are worth knowing about the first time someone asks why the index is four times the size of the data.
Templates, for indexes you do not create by hand
Writing a mapping works when there is one index. It stops working when there is one index per day, per tenant or per version, because something has to apply the mapping to an index that does not exist yet. An index template does that:
PUT _index_template/stayhub-properties
{
"index_patterns": ["stayhub-properties-*"],
"priority": 200,
"template": {
"settings": { "number_of_shards": 1 },
"mappings": { "dynamic": "strict", "properties": { "title": { "type": "text" } } }
}
}Any index whose name matches the pattern is created with that mapping, whoever creates it. Two
warnings. Templates apply at creation only, so editing one does nothing to indexes that
already exist. And when several templates match, the highest priority wins outright
— they are not merged, which is not what most people assume the first time.
Checking your work
# what the mapping is now
curl -s localhost:9200/stayhub-properties/_mapping?pretty
# one field, across every index that has it — the fastest way to spot a type that
# disagrees with itself between indexes
curl -s "localhost:9200/*/_field_caps?fields=price_per_night&pretty"
# what a value would actually become, without indexing it
curl -s localhost:9200/stayhub-properties/_analyze -H 'Content-Type: application/json' \
-d '{"field": "city", "text": "San Francisco"}'That last one is lesson 5's subject and it is the single most useful debugging tool Elasticsearch has.
Getting a mapping change into an existing index
Since half of what you might want to change is refused and the other half only affects new documents, it is worth knowing the three tools by name and when each applies.
PUT /index/_mapping adds fields and sub-fields. Cheap, instant,
and only affects documents indexed afterwards.
POST /index/_update_by_query reindexes every document in place,
through the current mapping. This is what makes a newly added multi-field actually populate. It is
a real rewrite of every document, so it costs what a reindex costs — but it needs no second
index and no cutover.
POST /_reindex copies into a different index, which is the only
option when a type has to change. Combined with an alias it is also the only one of the three that
can happen with no visible downtime, which is lesson 15.
A useful habit: when you add a field to a mapping, decide in the same commit whether existing
documents need it. If they do, the _update_by_query belongs in that deployment too.
The alternative is a field that works for new listings and not old ones, which surfaces weeks later
as "search is missing some results" and takes a day to diagnose.
The short version
Write the mapping before anything writes a document. Set dynamic: strict for an
index your own code owns. Give any string that will be sorted, filtered exactly or aggregated a
keyword field, and any string that will be searched a text field —
which usually means both, as a multi-field. Never let a numeric field be inferred, because
long is a coin flip that costs a reindex.
And assume from the start that you will one day have to change a type, because the mapping you write today is written with less information than you will have in six months. Lesson 15 makes that cheap; the alternative is discovering it during an incident.