04 — Mapping and analyzers
Mapping is the schema of your Elasticsearch index. It controls what you can search, filter, sort, and aggregate — and getting it wrong early means a painful reindex later. This chapter gives you a precise mental model of how Elasticsearch understands your data, right down to the individual token.
Goals for this chapter
By the end, you should be able to:
- Read and interpret the mapping of any index
- Choose the right field type for each use case
- Explain the text analysis pipeline (character filters → tokenizer →
token filters) and run it live with the
_analyzeAPI - Know which built-in analyzers are available and when to prefer each one
- Decide between dynamic and explicit mapping — and explain why explicit wins in production
- Know what to expect from Chapter 05 (custom analyzers) and Chapter 07 (reindexing)
4.1 What a mapping is and why it matters
The basic idea
Every time you index a document, Elasticsearch maps each field to a data type. The type determines:
- How the field is stored (term dictionary, doc values, BKD tree, …)
- Which queries work on it (
match,term,range,geo_distance, …) - Whether you can sort or aggregate on it
- How much disk and memory it uses
Think of it as a contract between your data producers and your query consumers.
Viewing the mapping
You can inspect the current mapping of any index at any time:
GET /shop-orders-demo/_mapping
The response shows every field, its type, and any extra options (analyzers, multi-fields, doc_values, etc.):
{
"shop-orders-demo": {
"mappings": {
"properties": {
"order_id": { "type": "keyword" },
"created_at": { "type": "date" },
"channel": { "type": "keyword" },
"total_amount": { "type": "double" },
"returned": { "type": "boolean" },
"rating": { "type": "float" },
"notes": {
"type": "text",
"analyzer": "czech",
"fields": {
"std": { "type": "text", "analyzer": "standard" },
"keyword": { "type": "keyword" }
}
},
"customer": {
"properties": {
"id": { "type": "keyword" },
"name": {
"type": "text",
"analyzer": "czech",
"fields": {
"keyword": { "type": "keyword" }
}
},
"loyalty_tier": { "type": "keyword" }
}
},
"delivery": {
"properties": {
"city": { "type": "keyword" },
"country_code": { "type": "keyword" },
"location": { "type": "geo_point" },
"days": { "type": "integer" }
}
},
"items": {
"type": "nested",
"properties": {
"product_id": { "type": "keyword" },
"name": {
"type": "text",
"analyzer": "czech",
"fields": {
"keyword": { "type": "keyword" }
}
},
"category": { "type": "keyword" },
"unit_price": { "type": "double" },
"quantity": { "type": "integer" }
}
},
"categories": { "type": "keyword" },
"tags": { "type": "keyword" }
}
}
}
}Key rule: many mapping changes — especially changing a field’s type — require creating a new index and reindexing your data. We cover that process in Chapter 07. Design your mapping thoughtfully the first time.
4.2 Core data types — a practical tour
text — for full-text
search
"notes": { "type": "text", "analyzer": "czech" }- The value is analyzed (tokenized, lowercased, stemmed, …) before indexing.
- Powers
matchandmatch_phrasequeries with relevance scoring. - Cannot be used for sorting or aggregations without significant extra cost (fielddata must be enabled, which loads the whole field into heap memory — avoid this).
- Use it when humans write the content and humans search it.
keyword — for exact
values
"order_id": { "type": "keyword" },
"channel": { "type": "keyword" },
"country_code": { "type": "keyword" },
"loyalty_tier": { "type": "keyword" },
"categories": { "type": "keyword" },
"tags": { "type": "keyword" }- Stored as-is, never analyzed.
- Efficient for
term,terms,rangefilters,sort, and aggregations. - Elasticsearch uses a compact column store (doc values) for keyword fields — fast and memory-efficient.
- Use it for IDs, status codes, enum values, tags, country codes — any value you match exactly or group by.
The classic mistake: mapping a status field like
"order_status"astext. You lose the ability to aggregate by status cleanly, and full-text search on a 5-character code is meaningless.
text vs
keyword — the deep comparison
| Dimension | text |
keyword |
|---|---|---|
| Stored as | Inverted index of tokens | Inverted index of exact term + doc values |
| Analyzed? | Yes (tokenizer + filters applied) | No |
match query |
Yes, with relevance scoring | Works but matches whole value only |
term / terms filter |
Unreliable (tokenized value differs from input) | Reliable |
sort |
No (unless fielddata enabled — expensive) | Yes |
Aggregations (terms, cardinality) |
No (same reason) | Yes |
| Full-text scoring | Yes | No |
| Best for | User-written content | IDs, codes, tags, enums, finite sets |
The multi-field pattern
Many fields need both behaviors. The solution is multi-fields — a single logical field that is indexed in two or more ways simultaneously:
PUT /shop-orders-demo/_mapping
{
"properties": {
"customer": {
"properties": {
"name": {
"type": "text",
"analyzer": "czech",
"fields": {
"keyword": { "type": "keyword" }
}
}
}
}
}
}
Now you can: - Search customer.name with a
match query (full-text, analyzed, stemmed Czech) -
Aggregate or sort on customer.name.keyword (exact, up to
256 bytes by default)
The notes field in our dataset goes further with a
three-way multi-field:
"notes": {
"type": "text",
"analyzer": "czech",
"fields": {
"std": { "type": "text", "analyzer": "standard" },
"keyword": { "type": "keyword" }
}
}notes— full-text search with Czech stemming and stopword removalnotes.std— full-text search with the standard (language-neutral) analyzernotes.keyword— exact match and aggregation
Storage cost: each sub-field is indexed separately. Multi-fields increase index size proportionally. Add them intentionally, not by default.
Numeric types
| Type | Range / Precision | Typical use |
|---|---|---|
integer |
±2.1 billion | counts, quantities, small IDs |
long |
±9.2 × 10^18 | Unix timestamps in ms, large counts |
double |
64-bit float | general decimal values |
float |
32-bit float | ratings, scores (lower precision acceptable) |
scaled_float |
integer + scaling factor | money, metrics with fixed decimal places |
From our dataset: delivery.days and
items.quantity are integer;
total_amount, subtotal_amount,
discount_amount, shipping_fee, and
unit_price are double; rating is
float.
Money tip: consider
scaled_floatwithscaling_factor: 100if you want to store prices in cents internally. It uses less disk thandoubleand avoids floating-point representation surprises. Example:"unit_price": { "type": "scaled_float", "scaling_factor": 100 }. A value of19.99is stored internally as the integer1999.
date
"created_at": { "type": "date" }- Accepts ISO 8601 (
"2024-11-23T14:05:00Z"), epoch milliseconds, or custom formats. - Stored internally as a
long(epoch millis) regardless of input format. - Supports
rangequeries,date_histogramaggregations, and date math (now-7d/d).
Always store in UTC. Elasticsearch stores dates as UTC internally. Mixing timezones in ingestion is a common source of “my date histogram is off by 1 hour” bugs. Be explicit: send
Zor+00:00on every timestamp.
boolean
"returned": { "type": "boolean" }Simple, small, and efficient. Used for binary flags. Accepts
true/false JSON booleans (not strings). Filter
queries on booleans are extremely fast.
object —
nested JSON without the cost
By default, any JSON sub-object becomes an object field.
Elasticsearch flattens its properties into the parent
document’s field namespace:
{
"delivery": {
"city": "Prague",
"country_code": "CZ",
"days": 2
}
}This is stored internally as delivery.city,
delivery.country_code, delivery.days. You
access them with dotted paths in queries:
GET /shop-orders-demo/_search
{
"query": {
"term": { "delivery.country_code": "CZ" }
}
}
Cost: low. Object is just a namespace — no special data structure.
Limitation: when object fields appear
in arrays, the flattening destroys the per-object
relationship. This is where nested becomes necessary.
nested
— arrays of objects with preserved relationships
Consider an order with multiple items:
{
"items": [
{ "product_id": "P001", "name": "Wireless Headphones", "quantity": 1, "unit_price": 79.99 },
{ "product_id": "P002", "name": "USB-C Cable", "quantity": 3, "unit_price": 9.99 }
]
}With a plain object mapping, Elasticsearch flattens this
to:
items.product_id: ["P001", "P002"]
items.name: ["Wireless Headphones", "USB-C Cable"]
items.quantity: [1, 3]
items.unit_price: [79.99, 9.99]
A query for “product P001 with quantity > 2” would incorrectly match this document, because P001 and quantity=3 happen to coexist in the same arrays — even though they belong to different items.
With nested, each item is stored as a hidden
separate Lucene document, preserving the object’s internal
relationships. A nested query will only match items where
ALL conditions hold within the same object.
"items": {
"type": "nested",
"properties": {
"product_id": { "type": "keyword" },
"name": { "type": "text", "analyzer": "czech",
"fields": { "keyword": { "type": "keyword" } } },
"category": { "type": "keyword" },
"unit_price": { "type": "double" },
"quantity": { "type": "integer" }
}
}Cost:
nestedfields require specialnestedqueries and aggregations — they cannot be used in regular top-level queries. They also increase index size because each nested object is a separate document internally. Chapter 05 coversnestedqueries in detail.
geo_point —
geographic coordinates
"delivery": {
"properties": {
"location": { "type": "geo_point" }
}
}Accepts coordinates in several formats:
{ "lat": 50.0755, "lon": 14.4378 } // object
"50.0755,14.4378" // string
[14.4378, 50.0755] // array — note: [lon, lat] order!Array format warning: geo arrays use
[longitude, latitude]order (GeoJSON convention) — the opposite of human intuition. Stick with the object{"lat": ..., "lon": ...}format to avoid subtle bugs.
Enables geo_distance queries,
geo_bounding_box filters, and geo_centroid
aggregations.
4.3 What happens when a document is indexed — text analysis
When Elasticsearch receives a text field, it does not
store the raw string in the inverted index. Instead, it runs the value
through an analyzer — a pipeline with three stages.
The analysis pipeline
Raw text
│
▼
┌─────────────────────┐
│ Character filters │ (optional) modify raw characters before tokenization
│ │ e.g. strip HTML tags, replace & → and
└─────────────────────┘
│
▼
┌─────────────────────┐
│ Tokenizer │ splits the text into individual tokens
│ │ e.g. split on whitespace, punctuation
└─────────────────────┘
│
▼
┌─────────────────────┐
│ Token filters │ (optional, chainable) transform tokens
│ │ e.g. lowercase, remove stopwords, stem
└─────────────────────┘
│
▼
Tokens → stored in inverted index
Walking through an example
Let us analyze the string
"The Wireless Headphones, 2-pack!" with the
standard analyzer using the _analyze
API:
POST /_analyze
{
"analyzer": "standard",
"text": "The Wireless Headphones, 2-pack!"
}
Response (simplified):
{
"tokens": [
{ "token": "the", "position": 0 },
{ "token": "wireless", "position": 1 },
{ "token": "headphones", "position": 2 },
{ "token": "2", "position": 3 },
{ "token": "pack", "position": 4 }
]
}What happened: - The standard tokenizer split on whitespace and
punctuation (Headphones, → headphones;
2-pack! → 2, pack) - The
lowercase token filter turned everything to lowercase - The comma,
hyphen, and exclamation mark were discarded
These 5 tokens are what goes into the inverted index.
Analysis at query time too
Here is the crucial insight: analysis happens both at index
time and at query time (for match and
match_phrase queries). When you send:
GET /shop-orders-demo/_search
{
"query": { "match": { "notes": "Wireless Headphones" } }
}
Elasticsearch applies the same analyzer to
"Wireless Headphones", producing tokens
["wireless", "headphones"], and then looks those up in the
inverted index.
The analyzer used at query time must be compatible with the
one used at index time. If you index with an English stemmer
(turning headphones into headphon) but query
without it, the tokens won’t match.
Rule of thumb: unless you explicitly need different index/search analyzers, use the same one for both — this is the default behavior.
Why keyword
fields are never analyzed
keyword fields skip the entire analysis pipeline. The
raw string is stored exactly as-is. This is intentional: when you filter
on order_status: "pending", you want an exact byte-for-byte
match, not tokenized fragments.
Try it on your own index
You can test the analyzer configured on any specific field in your index:
POST /shop-orders-demo/_analyze
{
"field": "notes",
"text": "Bezdrátová sluchátka nebyla součástí balení."
}
POST /shop-orders-demo/_analyze
{
"field": "notes.std",
"text": "Bezdrátová sluchátka nebyla součástí balení."
}
Compare the tokens from the Czech analyzer versus the standard analyzer on the same text — you will see stemming and stopword removal in the Czech version.
4.4 Built-in analyzers
Elasticsearch ships with several ready-to-use analyzers. Here is a practical guide to the ones you will encounter most often.
standard (the default)
Used by default for all text fields that do not specify
an analyzer.
Pipeline: - No character filters - Standard tokenizer (splits on Unicode word boundaries — handles most European languages reasonably) - Lowercase token filter
POST /_analyze
{
"analyzer": "standard",
"text": "The Wireless Headphones, 2-pack!"
}
Result:
["the", "wireless", "headphones", "2", "pack"]
Good general-purpose choice for multilingual content where language-specific stemming is not critical.
simple
Splits on any non-letter character and lowercases. Discards numbers entirely.
POST /_analyze
{
"analyzer": "simple",
"text": "The Wireless Headphones, 2-pack!"
}
Result: ["the", "wireless", "headphones", "pack"] — the
2 is gone.
whitespace
Splits only on whitespace. Does not lowercase. Keeps punctuation attached to tokens.
POST /_analyze
{
"analyzer": "whitespace",
"text": "The Wireless Headphones, 2-pack!"
}
Result:
["The", "Wireless", "Headphones,", "2-pack!"]
Useful when case and punctuation are meaningful (e.g., log analysis,
code tokens). Note that "Headphones," includes the comma
and "The" is capitalized — matching must account for
this.
keyword analyzer
Treats the entire input as a single token. Equivalent to no analysis at all.
POST /_analyze
{
"analyzer": "keyword",
"text": "The Wireless Headphones, 2-pack!"
}
Result: ["The Wireless Headphones, 2-pack!"] — one
token, exact string.
This is different from the keyword field
type (though the effect is similar). You would use this
analyzer in edge cases like a text field where you still
want fielddata behavior but no tokenization.
stop
Like standard, but also removes common stopwords
(the, a, is, and,
…).
POST /_analyze
{
"analyzer": "stop",
"text": "The Wireless Headphones, 2-pack!"
}
Result: ["wireless", "headphones", "2", "pack"] —
"the" is gone.
Language analyzers
(english, czech, and others)
Language analyzers combine tokenization, lowercasing, stopword removal, and stemming for a specific language.
Our shop-orders-demo index uses the czech
analyzer for notes, customer.name, and
items.name. Let us compare standard vs
english vs czech on a product name:
Standard:
POST /_analyze
{
"analyzer": "standard",
"text": "wireless headphones premium"
}
Result: ["wireless", "headphones", "premium"]
English:
POST /_analyze
{
"analyzer": "english",
"text": "wireless headphones premium"
}
Result: ["wireless", "headphon", "premium"]
Stemming reduced headphones → headphon. Now
a search for headphone (singular) also matches documents
containing headphones (plural). This is the power of
language analyzers — they normalize morphological variants.
Stopword effect:
POST /_analyze
{
"analyzer": "english",
"text": "the best headphones for running"
}
Result: ["best", "headphon", "run"] — "the"
and "for" removed as stopwords; "running"
stemmed to "run".
Pitfall: if you use the
englishorczechanalyzer at index time, you must also use the same analyzer at query time. Thematchquery does this automatically (it uses the field’s configuredsearch_analyzer, which defaults to the index analyzer). Buttermqueries bypass analysis — atermquery for"headphones"against an English-analyzed field will not match, because the indexed token is"headphon". Usematchfor analyzed fields.
Available language analyzers include: arabic,
bulgarian, catalan, czech,
danish, dutch, english,
estonian, finnish, french,
galician, german, greek,
hindi, hungarian, indonesian,
irish, italian, latvian,
lithuanian, norwegian, persian,
portuguese, romanian, russian,
sorani, spanish, swedish,
thai, turkish.
Quick comparison table
| Analyzer | Splits on | Lowercases | Removes stopwords | Stems |
|---|---|---|---|---|
standard |
Unicode word boundaries | Yes | No | No |
simple |
Non-letters | Yes | No | No |
whitespace |
Whitespace only | No | No | No |
keyword |
Never (one token) | No | No | No |
stop |
Unicode word boundaries | Yes | Yes | No |
english |
Unicode word boundaries | Yes | Yes (English) | Yes (English) |
czech |
Unicode word boundaries | Yes | Yes (Czech) | Yes (Czech) |
4.5 Dynamic vs explicit mapping — the decision
Dynamic mapping: convenient but dangerous
When you index a document without an explicit mapping, Elasticsearch inspects the first value of each field and guesses the type:
| JSON value | Guessed type |
|---|---|
"hello" |
text + keyword multi-field |
42 |
long |
3.14 |
float |
true / false |
boolean |
"2024-11-23" |
date (if it matches a date pattern) |
{} |
object |
This is enabled by default ("dynamic": true) and is
great for quick prototyping and exploration. For production, it has
serious pitfalls:
Pitfall 1 — Wrong type guessed from the first
document. The first document sends
"order_status": "200" as a string, so the field is mapped
as text + keyword. Six months later someone
sends "order_status": 200 as a number. Elasticsearch
rejects those documents with a mapping conflict error.
Pitfall 2 — Mapping explosion. If your documents contain arbitrary keys (user-defined labels, parsed JSON blobs, HTTP headers), the number of fields in the mapping can grow into the thousands. Each field consumes memory in the cluster state and heap. This is a known cause of cluster instability.
Pitfall 3 — Text guessed when keyword is needed.
Dynamic mapping maps plain strings as text +
.keyword. If you never need full-text search on a field
(e.g., a tag or status code), you are wasting an inverted index.
Dynamic mapping settings
You can control dynamic mapping at the index or object level:
PUT /shop-orders-demo-v2
{
"mappings": {
"dynamic": "strict",
"properties": {
"order_id": { "type": "keyword" },
"channel": { "type": "keyword" },
"created_at": { "type": "date" },
"total_amount": { "type": "double" }
}
}
}
| Value | Behavior |
|---|---|
true (default) |
Unknown fields are automatically mapped and indexed |
false |
Unknown fields are silently ignored (not indexed, still in
_source) |
"strict" |
Unknown fields cause the indexing request to fail with an error |
Use "dynamic": "strict" for any index where you control
the producer — it acts as a schema enforcer and surfaces ingestion bugs
immediately instead of silently creating wrong types.
Use "dynamic": false for objects where you want to keep
raw data in _source but do not need to query the arbitrary
keys — e.g., a metadata blob from a third-party system.
Dynamic templates — a brief mention
Dynamic templates let you customize how dynamically-detected fields
are mapped, based on patterns or detected types. For example, you can
say “any field whose name ends in _at should be mapped as
date” or “any field that Elasticsearch would map as
text should instead be keyword”. Dynamic
templates are a powerful middle ground between fully dynamic and fully
explicit mapping. They are a Day 2 topic worth exploring once you are
comfortable with explicit mappings.
Explicit mapping — the production standard
The recommended approach for any index that matters in production is to define the mapping explicitly before you start ingesting data:
PUT /shop-orders-demo-v2
{
"mappings": {
"dynamic": "strict",
"properties": {
"order_id": { "type": "keyword" },
"created_at": { "type": "date" },
"channel": { "type": "keyword" },
"currency": { "type": "keyword" },
"payment_method": { "type": "keyword" },
"order_status": { "type": "keyword" },
"shipping_method": { "type": "keyword" },
"subtotal_amount": { "type": "double" },
"discount_amount": { "type": "double" },
"shipping_fee": { "type": "double" },
"total_amount": { "type": "double" },
"returned": { "type": "boolean" },
"rating": { "type": "float" },
"promotion_code": { "type": "keyword" },
"categories": { "type": "keyword" },
"tags": { "type": "keyword" },
"notes": {
"type": "text",
"analyzer": "czech",
"fields": {
"std": { "type": "text", "analyzer": "standard" },
"keyword": { "type": "keyword" }
}
},
"customer": {
"properties": {
"id": { "type": "keyword" },
"name": {
"type": "text",
"analyzer": "czech",
"fields": { "keyword": { "type": "keyword" } }
},
"loyalty_tier": { "type": "keyword" }
}
},
"delivery": {
"properties": {
"city": { "type": "keyword" },
"country_code": { "type": "keyword" },
"location": { "type": "geo_point" },
"days": { "type": "integer" }
}
},
"items": {
"type": "nested",
"properties": {
"product_id": { "type": "keyword" },
"name": {
"type": "text",
"analyzer": "czech",
"fields": { "keyword": { "type": "keyword" } }
},
"category": { "type": "keyword" },
"unit_price": { "type": "double" },
"quantity": { "type": "integer" }
}
}
}
}
}
You cannot change an existing field’s type
Once a field is mapped, you cannot change its type. Attempting to do so returns an error:
illegal_argument_exception: mapper [channel] of different type,
current_type [text], merged_type [keyword]
The only safe fix is: 1. Create a new index with the correct mapping
2. Reindex the data (Chapter 07 covers this with the
_reindex API) 3. Flip an alias to point to the new
index
The one exception: you can add new fields to an existing mapping at any time (as long as
dynamicis notstrict). You can also add new multi-fields to an existing field. You just cannot change the type of an already-mapped field.
Labs
Lab 1 — Inspect and compare mapping behavior
- Run
GET /shop-orders-demo/_mappingand identify: which fields arekeyword, which aretext, which have multi-fields, and which use a language analyzer. - Try aggregating on a
textfield (e.g.,customer.name) and observe the error. - Repeat the aggregation on
customer.name.keywordand see it succeed. - Explain in your own words why the
.keywordsub-field exists.
Takeaway: the field type — not the data — determines what queries and aggregations are possible.
Lab 2 — Explore analysis
with _analyze
- Run the same phrase (e.g.,
"Wireless Headphones, 2-pack!") throughstandard,whitespace,englishanalyzers usingPOST /_analyze. Compare the token lists. - Run
POST /shop-orders-demo/_analyze { "field": "notes", "text": "..." }andPOST /shop-orders-demo/_analyze { "field": "notes.std", "text": "..." }on the same text. Note differences in stemming and stopword removal. - Run
POST /_analyze { "analyzer": "keyword", "text": "Order-2024-XYZ" }— confirm it produces exactly one token.
Takeaway: the _analyze API is your
primary debugging tool for understanding why a search query does or does
not match a document.
Lab 3 — Dynamic vs strict mapping
- Create a test index with
"dynamic": true(or no mapping at all). Index a document with a field"delivery_note": "leave at door". CheckGET /test-dynamic/_mapping— observe the auto-generatedtext+keywordmulti-field. - Now index a second document that sends
"delivery_note": 42(a number). Observe the mapping conflict error. - Create a second test index with
"dynamic": "strict"and onlyorder_iddefined. Try indexing a document with an extra field. Observe the rejection error. - Fix it: add the field to the explicit mapping, then index again successfully.
Takeaway: dynamic mapping is convenient for
exploration but causes type conflicts and field explosion in production.
Explicit mapping with strict mode enforces your schema.
Lab 4 — Multi-field design
- Create a new index with
customer_namemapped astextonly (no multi-field). - Index 5 documents with different customer names.
- Try a
termsaggregation oncustomer_name— observe the fielddata error. - Create a new version of the index with
customer_nameastext+.keywordmulti-field. - Reindex the 5 documents. Now run both a
matchquery (full-text) and atermsaggregation (exact) on the same logical field.
Takeaway: multi-fields give you both full-text search and exact operations on the same data — a pattern you will use constantly in production.
Summary
- A mapping defines field types and options. It controls what queries, sorts, and aggregations are possible, and it largely determines index performance.
- Use
keywordfor IDs, codes, tags, and any value you match exactly or group by. Usetextfor human-written content you search with relevance. - The multi-field pattern (
text+.keyword) is the standard solution when you need both full-text search and exact operations on the same field. - Text analysis is a pipeline: character filters → tokenizer → token filters. Analysis runs at both index time and query time; the analyzers must be compatible.
- Use the
_analyzeAPI liberally — it shows you exactly what tokens are stored for any analyzer and field combination. - Built-in analyzers cover most needs:
standardfor general use, language analyzers (english,czech, …) for stemming and stopword removal in a specific language. - Dynamic mapping is dangerous in production: it
guesses wrong types and can cause field explosion. Prefer
explicit mappings with
"dynamic": "strict"for important indices. - You cannot change an existing field’s type — design carefully, and use a reindex (Chapter 07) when you must change the schema.
Coming up in Day 2: In Chapter 05 you will build your own custom analyzers — combining character filters, tokenizers, and token filters to handle specific requirements like synonym expansion, edge n-gram prefix search, or language-specific normalization that the built-in analyzers do not cover.