Two Elasticsearch problems show up again and again. A term query for "status": "Shipped" returns nothing even though the documents clearly contain it. And when you try to fix the field type:
PUT orders/_mapping
{ "properties": { "status": { "type": "keyword" } } }
"type": "illegal_argument_exception",
"reason": "mapper [status] cannot be changed from type [text] to [keyword]"
Both come from the same place: how Elasticsearch mapped your fields, and the fact that a field's mapping can't be changed once data is indexed.
text vs. keyword
When you index a JSON string without an explicit mapping, dynamic mapping creates two fields:
"status": {
"type": "text",
"fields": { "keyword": { "type": "keyword", "ignore_above": 256 } }
}
textis analyzed:"Shipped"is lowercased and split into tokens (shipped). It's used for full-textmatchqueries, relevance scoring and search boxes.keywordis stored as-is:"Shipped". It's used for exact filters,termqueries, sorting and aggregations.
So:
{ "term": { "status": "Shipped" } } // ❌ searches the text field, whose token is "shipped"
{ "term": { "status.keyword": "Shipped" } } // ✅ exact match on the keyword sub-field
{ "match": { "status": "shipped" } } // ✅ full-text, analyzed like the field
Sorting or aggregating on a text field fails with "Fielddata is disabled on text fields by default". Use the .keyword sub-field instead. Don't enable fielddata, which loads everything into heap memory.
Why you can't just change the mapping
Existing documents are already indexed with the old type's data structures (inverted index, doc values). Changing the type would make that data inconsistent, so Elasticsearch only lets you add new fields or sub-fields, and change a few harmless parameters.
The fix: new index + reindex + alias
1. Create a new index with the right mapping:
PUT orders-v2
{
"mappings": {
"dynamic": "strict",
"properties": {
"status": { "type": "keyword" },
"title": { "type": "text", "fields": { "raw": { "type": "keyword" } } },
"customerId": { "type": "keyword" },
"total": { "type": "scaled_float", "scaling_factor": 100 },
"createdAt": { "type": "date" }
}
}
}
2. Copy the data:
POST _reindex?wait_for_completion=false
{ "source": { "index": "orders-v1" }, "dest": { "index": "orders-v2" } }
Track it with the tasks API (GET _tasks/<task_id>). For big indices, set "slices": "auto", and temporarily set number_of_replicas: 0 and refresh_interval: -1 on the destination, restoring them afterwards.
3. Switch traffic atomically with an alias:
POST _aliases
{
"actions": [
{ "remove": { "index": "orders-v1", "alias": "orders" } },
{ "add": { "index": "orders-v2", "alias": "orders" } }
]
}
If the application always talks to the alias orders, never to orders-v1 directly, future reindexes need no code changes. Set that up from day one.
Writes that arrive during the reindex need handling: pause them, dual-write, or run a second catch-up reindex filtered by a timestamp.
Prevent the next one
- Define mappings explicitly, or use index templates for time-based indices, instead of relying on dynamic mapping.
- Consider
"dynamic": "strict"(unknown fields are rejected) or"dynamic": false(stored but not indexed) to avoid mapping explosions from arbitrary JSON keys. - IDs, codes, enums, email addresses and tags should be
keyword. Prose should betext(plus akeywordsub-field if you also sort by it). - Numbers that you only filter by exact value, such as ZIP codes or order numbers, are often better as
keyword. - Runtime fields let you query a field with a different type without reindexing. They're useful as a stopgap, but slower.
Checklist
termqueries ontextfields miss. Use.keywordor map the field askeyword.- Existing field types can't be changed, only new fields added.
- Fix it with a new index,
_reindex, and an atomic alias swap. - Use explicit mappings and templates, and always point applications at aliases.
Get the weekly commit
New database deep dives every week.
