Skip to content

Elasticsearch keyword vs text: Why My term Query Returned Nothing

Published on
Reading time
4 mins read

The document is in the index, GET by id returns it, the value in _source is exactly what I'm searching for, and a term query returns zero hits. Nothing is broken. The query and the mapping just disagree about what the value is.

The smallest reproduction

Kibana Dev Tools syntax, Elasticsearch 8.x, no index created up front:

PUT coupons/_doc/1?refresh=true
{ "code": "SUMMER-25", "status": "ACTIVE" }

GET coupons/_search
{ "query": { "term": { "status": "ACTIVE" } } }

hits.total.value is 0.

What dynamic mapping created

GET coupons/_mapping shows what Elasticsearch guessed for status, and the same for code:

"status": {
  "type": "text",
  "fields": {
    "keyword": { "type": "keyword", "ignore_above": 256 }
  }
}

Every new string field that doesn't look like a date gets this shape: a text field, analyzed for full-text search, plus a keyword sub-field, status.keyword, that indexes the value as it is.

What's actually in the index

The _analyze API shows the terms a field produces, the way explain() shows what a MongoDB index did:

GET coupons/_analyze
{ "field": "code", "text": "SUMMER-25" }
{
  "tokens": [
    { "token": "summer", "start_offset": 0, "end_offset": 6, "type": "<ALPHANUM>", "position": 0 },
    { "token": "25", "start_offset": 7, "end_offset": 9, "type": "<NUM>", "position": 1 }
  ]
}

The standard analyzer splits on word boundaries (the hyphen is one) and lowercases every token. So status was indexed as active, and code as two terms, summer and 25. Point the same request at code.keyword and you get one token, SUMMER-25, untouched.

term vs match

term doesn't analyze its input: it looks for ACTIVE in a field that only contains active. match runs the query text through the field's analyzer first, then searches for the resulting terms.

QueryFieldValueResult
termstatusACTIVEno hits
termstatusactivea hit, by accident
termcodeSUMMER-25no hits, ever
matchcodeSUMMER-25also matches SUMMER-50, WINTER-25
termcode.keywordSUMMER-25exactly the one document

The docs warn against term on text fields, and the reverse matters just as much: match on a text field is the wrong tool for identifiers, because SUMMER-25 becomes summer OR 25. For exact values, query the keyword field:

GET coupons/_search
{ "query": { "term": { "code.keyword": "SUMMER-25" } } }

Case-insensitive exact matching

A keyword field is exact, so it's also case-sensitive: summer-25 finds nothing on code.keyword. For a one-off query, term accepts "case_insensitive": true (since 7.10, ASCII only). If every query needs it, put a normalizer on the field. A normalizer is an analysis chain that always emits a single token, and Elasticsearch applies it both at index time and to the input of term and match queries on that field. The built-in lowercase normalizer covers the common case; a custom one can add filters such as trim or asciifolding.

One side effect: aggregations and sorting see the normalized value, summer-25. _source keeps the original.

The real fix: map it yourself

PUT coupons-v2
{
  "mappings": {
    "dynamic": "strict",
    "properties": {
      "code":   { "type": "keyword", "normalizer": "lowercase" },
      "status": { "type": "keyword" },
      "title":  { "type": "text", "fields": { "raw": { "type": "keyword" } } }
    }
  }
}
  • code and status are identifiers. Nobody full-text searches them, so they're keyword only.
  • title needs both: match on title, sorting and aggregations on title.raw.
  • "dynamic": "strict" rejects documents with unmapped fields instead of guessing. Use false if you'd rather ignore them.

You can't change a field from text to keyword in place: create the new index, _reindex into it and move an alias. Adding a sub-field to an existing field is allowed, but documents already in the index won't have it until you run _update_by_query.

The 256-character trap

The dynamic .keyword sub-field has ignore_above: 256: longer values aren't indexed there. They're still in _source, so the document looks fine, but term queries and aggregations on .keyword never see them. Harmless for codes and statuses; a real trap for URLs, file paths or long names. Elasticsearch does record the skip in the _ignored metadata field, so an exists query on _ignored finds the affected documents.

Takeaways

  • Dynamic mapping turns every new string field that isn't a date into text plus a .keyword sub-field with ignore_above: 256.
  • term doesn't analyze its input and match does. Use term on keyword fields, match on text fields.
  • When a query surprises you, run _analyze against the field. It shows exactly what's in the index.
  • For case-insensitive exact matching, use a normalizer, or case_insensitive for a one-off.
  • Map identifier fields explicitly before the first document arrives. Fixing it later means a reindex.