A mapping is the contract between your documents and Lucene's data structures. Every field you map decides which structures get built (an inverted index, doc values, norms, positions), how much disk and heap they cost, and which queries and aggregations are possible later. Get it wrong and the symptoms arrive months later: aggregations that need fielddata, exact-match lookups that are slower than they should be, an index that has grown to 4,000 fields because a client sent arbitrary JSON keys, or a type that cannot be changed without a reindex. The answer is to treat mappings as a design artifact: map every field you know explicitly, choose keyword or text (or both) deliberately, switch off structures you will never use, control what dynamic mapping is allowed to create, and keep the field count well below the limits. This article covers the model, each decision, and how to recover when a mapping has already gone wrong.
The mental model: what a mapping turns into
For each mapped field, Elasticsearch can build several independent Lucene structures. Which ones exist depends on the field type and its parameters.
| Structure | What it is | Used for | Controlled by |
|---|---|---|---|
| Inverted index (postings) | Term to list of documents | term, match, range on keywords, full-text search |
index (default true) |
| Points (BKD tree) | Multi-dimensional tree for numeric, date, IP and geo values | Range queries, numeric filters | index on those types |
| Doc values | Column store: document to values, on disk | Sorting, aggregations, scripts, fields retrieval |
doc_values (default true for types that support it) |
| Norms | Per-document length factors | Relevance scoring | norms (default true for text, false for keyword) |
| Positions and offsets | Term positions within a field | Phrase queries, some highlighting | index_options |
_source |
The original JSON document | Returning documents, reindex, update | Index-level _source settings |
Two facts make this table actionable:
- Each structure costs disk at index time and I/O at query time, whether you use it or not. A field that is only ever displayed does not need an inverted index or doc values.
- Most of these decisions are fixed once data is indexed. You can add fields and add multi-fields to an existing field. You cannot change a field's type, or turn indexing or doc values on for data already written, without reindexing. See zero-downtime reindexing.
text versus keyword
This is the most consequential choice in most mappings.
text is analyzed. The value goes through an analyzer (by default the standard analyzer: tokenize on word boundaries, lowercase), and each token is indexed. It supports full-text queries (match, match_phrase) and relevance scoring. It has no doc values, so it cannot be sorted or aggregated on without enabling fielddata, which loads the terms into the heap. Do not enable it: see JVM heap and circuit breakers.
keyword is not analyzed. The whole value is one term (optionally passed through a normalizer, for example to lowercase it). It supports exact-match term and terms queries, prefix queries, sorting and aggregations through doc values. It does not support full-text matching of words inside the value.
| Field content | Typical queries | Map as |
|---|---|---|
| Free text: log messages, descriptions, comments | Words and phrases, relevance | text (and consider match_only_text for logs) |
| Identifiers, codes, enums, tags, hostnames, status values | Exact match, filters, aggregations | keyword |
| Names or titles that are both searched and aggregated | Both | text with a keyword multi-field |
| Case-insensitive exact match (emails, usernames) | Exact match ignoring case | keyword with a lowercase normalizer |
| High-cardinality strings searched by substring (URLs, file paths, command lines) | Wildcard and regexp | wildcard field type |
What dynamic mapping does to strings
If a string field arrives without a mapping and dynamic mapping is on, Elasticsearch maps it as text with a keyword sub-field named keyword, with ignore_above: 256:
{
"message": {
"type": "text",
"fields": {
"keyword": { "type": "keyword", "ignore_above": 256 }
}
}
}
That default is safe for exploration and wasteful in production. Every string gets both an analyzed index and a keyword index plus doc values, even fields that are only ever one or the other. A 30-field log document with 20 string fields carries 20 structures it may never use. For a high-volume index, map strings explicitly.
ignore_above means values longer than 256 characters are not indexed in the keyword sub-field (they remain in _source). Queries and aggregations on message.keyword silently skip those documents.
Multi-fields: one value, several structures
A multi-field indexes the same source value in more than one way:
{
"mappings": {
"properties": {
"product_name": {
"type": "text",
"fields": {
"raw": { "type": "keyword", "ignore_above": 512 },
"autocomplete": { "type": "search_as_you_type" }
}
}
}
}
}
Each sub-field is a separate field in the mapping, with its own structures, its own disk cost, and its own entry in the field count. Add them only for query patterns you actually serve.
One useful property: you can add a multi-field to an existing field without reindexing. Documents indexed before the change will not have it populated until they are reindexed or rewritten, which you can do in place:
POST products/_update_by_query?conflicts=proceed
That rewrites every document, so throttle it on a large index.
Switching off what you do not need
doc_values: false
Doc values are on by default for every field type that supports them except text. They are what makes sorting and aggregations cheap. If a field is only ever filtered on and never sorted, aggregated or accessed by scripts, you can turn them off to save disk:
{ "session_token": { "type": "keyword", "doc_values": false } }
The trade-off is permanent for indexed data: to aggregate on that field later, you must reindex. Turn doc values off only for fields where you are confident.
index: false
index: false skips the inverted index or points. The field cannot be searched efficiently. In 8.x, fields of types such as keyword, numerics and date that have index: false but keep doc values can still be queried, using the doc values, but much more slowly; treat that as an escape hatch, not a design. Use index: false for fields you aggregate or sort on but never filter by, or with doc_values: false as well for fields you only return in results.
enabled: false
For an object you want to store and return but never search or aggregate, such as a raw payload or a blob of vendor metadata, set enabled: false on the object. Elasticsearch skips parsing its contents entirely: no fields inside it are mapped, so its contents add nothing to the field limit (the object itself counts as one field), and the object stays in _source.
{
"mappings": {
"properties": {
"vendor_payload": { "type": "object", "enabled": false }
}
}
}
This is one of the most effective tools against mapping explosion from data you do not control.
norms: false
Norms store per-document length information for scoring. On text fields that are only filtered on (never scored), norms: false saves disk. keyword fields already have norms off by default.
Numeric types and identifiers
Choose the smallest type that will always hold the value. Elasticsearch offers long, integer, short, byte, double, float, half_float, scaled_float (a long with a scaling_factor, good for prices and percentages with fixed precision) and unsigned_long.
The more important decision is whether a number is really a number. Numeric fields are indexed as points, a structure optimized for range queries. Exact term lookups on points work but are not what the structure is designed for. Keyword fields are indexed in the inverted index, which is optimized for exact term lookups.
Elastic's search tuning guidance says it directly: consider mapping identifiers as keyword. An order ID, a customer number, an HTTP status code you only filter on, or a ZIP code is an identifier, not a quantity. You will query it with term, not range, and you will never sum it.
| Value | Queried with | Map as |
|---|---|---|
customer_id: 88231457 |
term, terms, aggregations |
keyword |
http.status: 503 |
term filters, terms aggregation |
keyword (or both, with a numeric multi-field, if you need range like 500 to 599) |
bytes: 18233 |
range, sum, avg |
long |
price: 19.99 |
range, avg, sort |
scaled_float with scaling_factor: 100 |
latency_ms: 12.7 |
range, percentiles |
float or double |
If you need both, map one as the primary and the other as a multi-field.
Controlling dynamic mapping
The dynamic parameter decides what happens when a document contains a field that is not in the mapping. It can be set at the top level and overridden on any object.
| Value | New field is | Searchable | In _source |
Use when |
|---|---|---|---|---|
true (default) |
Added to the mapping with an inferred type | Yes | Yes | Exploration, low-volume indices |
runtime |
Added as a runtime field | Yes, evaluated at query time | Yes | You want new fields queryable without indexing cost |
false |
Not added to the mapping | No | Yes | Store unknown fields, never search them |
strict |
Rejected: the document fails to index | Not applicable | Not applicable | Producers you control; the mapping is a contract |
A pattern that works well for application logs is strict at the top level, so a typo or an unannounced field in your own schema fails loudly, combined with false or enabled: false on a specific object reserved for free-form context:
{
"mappings": {
"dynamic": "strict",
"properties": {
"@timestamp": { "type": "date" },
"service": { "type": "keyword" },
"level": { "type": "keyword" },
"message": { "type": "match_only_text" },
"trace_id": { "type": "keyword" },
"context": { "type": "object", "dynamic": false }
}
}
}
The consequence of strict is operational: a producer that adds a field gets mapping errors (strict_dynamic_mapping_exception) until the mapping is updated. That is the point, but the ingest path must route those failures somewhere visible rather than drop them.
Two related defaults: date_detection is on by default, so strings that look like dates in the default formats are mapped as date; and numeric_detection is off by default, so numbers sent as strings are mapped as strings. A field whose first value was "2026-10-21" becomes a date, and a later value of "unknown" will then fail to index. If you do not want that, set date_detection: false or map the field explicitly.
Dynamic templates
When you cannot list every field in advance, dynamic templates let you decide what dynamic mapping creates. Each template matches new fields by detected JSON type, by name, or by path, and supplies the mapping to use. Templates are evaluated in order and the first match wins.
{
"mappings": {
"dynamic_templates": [
{
"labels_as_keywords": {
"path_match": "labels.*",
"mapping": { "type": "keyword", "ignore_above": 1024 }
}
},
{
"ip_suffix_as_ip": {
"match": "*_ip",
"match_mapping_type": "string",
"mapping": { "type": "ip" }
}
},
{
"strings_as_keywords": {
"match_mapping_type": "string",
"mapping": { "type": "keyword", "ignore_above": 1024 }
}
}
]
}
}
The matching parameters are match_mapping_type (the detected JSON type, such as string, long, double, boolean, object), match and unmatch (field name patterns), and path_match and path_unmatch (full dotted path patterns). Order matters here: the *_ip template must come before the catch-all string template, or it would never be reached. The last template changes the default string behavior: new strings become keyword only, with no text field, which suits most structured logging.
A dynamic template does not limit how many fields are created; it only decides their type. It is not protection against mapping explosion by itself.
Put mappings and dynamic templates in component templates, and compose them into index templates, so every new index and data stream backing index gets them. See index templates and data streams.
Runtime fields
A runtime field is computed at query time, usually by a Painless script reading doc values or _source. It is defined in the mapping's runtime section or per request in runtime_mappings:
GET web-logs/_search
{
"runtime_mappings": {
"response_class": {
"type": "keyword",
"script": "emit(doc['http.status'].value.substring(0, 1) + 'xx')"
}
},
"size": 0,
"aggs": {
"by_class": { "terms": { "field": "response_class" } }
}
}
That script assumes http.status is a keyword field, as recommended above. Runtime fields cost nothing at index time and no disk, and they can be added and changed freely. The price is query time: the script runs for every document the query touches. They are ideal for exploring a field before committing to a mapping, for fixing a mistake while a reindex runs, and for rarely used derived values. They are a poor choice for a field on a hot dashboard. Once a runtime field proves useful, index it.
Mapping explosion and the field limits
Mapping explosion is what happens when an index keeps acquiring fields, typically because documents use data as keys: user IDs, URLs, timestamps or arbitrary labels as field names. Each new field updates the mapping, which is part of the cluster state. The master publishes the updated cluster state to every node. With thousands of fields across many indices, cluster state grows, mapping updates queue on the master, indexing stalls waiting for them, and heap usage rises on every node.
Elasticsearch guards against this with per-index limits, all index settings:
| Setting | Default | Limits |
|---|---|---|
index.mapping.total_fields.limit |
1000 |
Total mapped fields in the index |
index.mapping.depth.limit |
20 |
Maximum object nesting depth |
index.mapping.nested_fields.limit |
50 (100 for indices created by the most recent 9.x releases) |
Distinct nested mappings in the index |
index.mapping.nested_objects.limit |
10000 |
Nested objects in a single document, across all nested fields |
index.mapping.field_name_length.limit |
No limit | Field name length |
The total fields limit counts more than leaf fields: object fields (each level of a dotted path), multi-fields, field aliases and mapped runtime fields all count. Metadata fields such as _id and _source do not. A document with 300 leaf strings mapped dynamically uses about 600 of the 1,000, because each has a .keyword multi-field.
When the limit is reached, documents that would add a new field are rejected with an error like:
Limit of total fields [1000] has been exceeded while adding new fields [3]
Recent releases add an index setting, index.mapping.total_fields.ignore_dynamic_beyond_limit (default false). When true, a document that would add dynamic fields beyond the limit is still indexed: the excess fields are left out of the mapping, as with dynamic: false, and their names are recorded in the _ignored metadata field. Recent versions turn it on by default for logsdb indices. It trades a loud failure for silently unsearchable fields, so use it knowingly.
Raising the limit is usually the wrong fix
The limit is a symptom detector. Raising it to 5,000 makes the error go away and leaves the cause in place: the field count keeps growing until the cluster state and the master feel it. Before raising it, find out which fields are growing:
GET my-index/_mapping
Look for families of fields with data-like names: labels.user_88231457, headers.x-request-..., metrics.<url>. Then fix the shape of the data:
| Data shape | Problem | Fix |
|---|---|---|
| Arbitrary keys you need to filter on | One field per key | flattened field type |
| Arbitrary keys you never search | One field per key | Object with enabled: false |
| Key/value pairs with known value types | One field per key | Array of { "key": "...", "value": "..." } objects (as nested only if you must match key and value in the same object) |
| A few known keys plus noise | Noise creates fields | Explicit mapping plus dynamic: false on the noisy object |
The flattened type
flattened maps an entire object as a single field. All its leaf values are indexed as keywords, and they can be queried by path:
{
"mappings": {
"properties": {
"labels": { "type": "flattened" }
}
}
}
GET my-index/_search
{
"query": {
"term": { "labels.team": "payments" }
}
}
The whole object counts as one field toward the limit, however many keys it has. The limits of the type are the price: every value is treated as a keyword, so range queries and sorting compare strings lexicographically ("10" sorts before "9"), there is no full-text analysis, and highlighting is not supported. It supports term, terms, terms_set, prefix, range (on strings), match, multi_match, query_string, simple_query_string and exists queries, and sorting and simple keyword-style aggregations such as terms. It also has its own depth_limit and ignore_above parameters.
Failure modes and anti-patterns
| Anti-pattern | Symptom | Confirm | Fix |
|---|---|---|---|
| Relying on default dynamic mapping in production | Index larger than needed; every string mapped twice | GET index/_mapping shows text plus .keyword everywhere |
Explicit mappings or a strings-as-keyword dynamic template; reindex |
Aggregating on a text field with fielddata |
Heap pressure, fielddata breaker trips | _cat/fielddata names the field |
Aggregate on a keyword field or multi-field |
Numeric IDs mapped as long |
Slower exact lookups than expected | GET index/_mapping shows long or integer for identifier fields |
Map as keyword; reindex |
| Data used as field names | Field count climbing; limit errors | Mapping shows many similar field names | flattened, enabled: false, or key/value arrays |
Raising total_fields.limit repeatedly |
Larger cluster state; slow mapping updates | Count fields over time | Fix the data shape |
| Date detection on a mixed field | Documents rejected with parse errors | Mapping shows date for a field with mixed values |
Map explicitly; date_detection: false |
nested used where object would do |
Slow queries, higher document count | Each nested object is a hidden Lucene document | Use object unless you need per-object matching |
| Same field name, different types across indices | Queries and dashboards fail or ignore an index | GET logs-*/_field_caps?fields=status shows multiple types |
Align mappings through shared component templates |
The _field_caps API deserves a mention on its own. For a field that should have one type across a pattern of indices, it shows every type in use and which indices use each:
GET logs-*/_field_caps?fields=http.status,user.id
A field reported as both keyword and long across indices is a conflict that will break sorting and aggregations across those indices.
Checklist: reviewing a mapping before production
- Every known field is mapped explicitly, with the type chosen for how it is queried.
- Strings are deliberate:
keywordfor identifiers and enums,text(ormatch_only_text) for prose, both only where both are needed. - Identifiers are
keyword, not numeric, unless you need range queries on them. - Unused structures are off:
index: false,doc_values: false,norms: false, orenabled: falsewhere the field's use is certain. - Dynamic behavior is set on purpose:
strictfor schemas you own,falseorruntimefor objects you do not, and dynamic templates for the rest. - Free-form keys are contained in a
flattenedfield or a disabled object. - The field count has room: well under
index.mapping.total_fields.limit, counting multi-fields. date_detectionis off or harmless for string fields with mixed contents.- Mappings live in component templates, so every new index and backing index inherits them.
_field_capsshows one type per field across the index pattern.
Key takeaways
- Each mapped field builds structures that cost disk and I/O whether used or not. Map for the queries you serve.
- Default dynamic mapping turns every string into
textpluskeyword. Fine for exploring, wasteful at volume. - Map identifiers as
keyword; numeric types are built for ranges. dynamic: strictmakes the mapping a contract;enabled: falseandflattenedcontain data you do not control.- The 1,000-field default limit counts multi-fields, objects, aliases and runtime fields. Hitting it means the data's shape is wrong, not that the limit is too low.
- Types cannot be changed in place. Use runtime fields as a bridge and reindex for the real fix.
How Sparced helps
Sparced's pass covers cluster health, nodes, shards, allocation, thread pools, circuit breakers, ILM, snapshots and security, so the effects of a poor mapping show up where they land, for example as fielddata breaker usage or heap pressure on a node, with the node named and the numbers shown. It does not read or rewrite your mappings, and it will not tell you a field is mistyped unless the cluster's own output says so. It installs no agent and writes nothing to the cluster, so mapping changes and reindexing remain your decision, made with the commands in this article.

