Term-level queries

This section documents the current Elasticsearch 9.x line (with 8.19 as the final 8.x release) as published at the Elasticsearch documentation, which is the reference these pages are written and verified against. No specific patch version is pinned. Some capabilities (Kibana-only UIs, the ML/NLP model-management workflow, cross-cluster replication, and parts of the paid / serverless-only surface) are linked, not documented in depth.

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production, as Elasticsearch iterates quickly.

This section’s bibliography lists the reference material consulted while preparing these pages.

Term-level queries find documents by an exact value in a structured field — a keyword, number, date, boolean, IP or range. Unlike full-text queries, they do not run the search term through an analyzer, so what you pass is matched byte-for-byte against the terms in the index. See Term-level queries.

The "term on a text field returns nothing" gotcha

A text field is analyzed at index time: "Wireless Keyboard" is lowercased and split into the terms wireless and keyboard. A term query does no analysis, so searching a text field for the literal "Wireless Keyboard" looks for a single term with that exact casing and spacing — which does not exist — and matches nothing.

PUT /catalog
{
  "mappings": {
    "properties": {
      "name": { "type": "text" }
    }
  }
}

POST /catalog/_doc/1
{ "name": "Wireless Keyboard" }

# Returns 0 hits: the indexed terms are [wireless, keyboard], not "Wireless Keyboard".
GET /catalog/_search
{ "query": { "term": { "name": "Wireless Keyboard" } } }

The fix is to run term-level queries against a keyword field (or the .keyword sub-field that the default dynamic mapping adds beside every text field), which stores the string as one un-analyzed term. Use the analyzed text field with match when you want full-text behaviour instead. See Mapping & field types for the text vs keyword split, and term.

# Matches: the keyword sub-field holds the whole string "Wireless Keyboard" as one term.
GET /catalog/_search
{ "query": { "term": { "name.keyword": "Wireless Keyboard" } } }

term on a keyword field is case- and whitespace-sensitive. For a case-insensitive exact match set "case_insensitive": true on the term query, or map a normalizer (a lowercasing analyzer for keyword) on the field. This mirrors the difference between = and ILIKE/LOWER(col) = in SQL Queries.

The core term-level queries

term and terms

term matches one exact value; terms matches a document whose field equals any value in a list (an IN (…​) set membership test). See terms.

GET /orders/_search
{
  "query": {
    "bool": {
      "filter": [
        { "term":  { "status": "shipped" } },
        { "terms": { "region": ["EU", "UK", "US"] } }
      ]
    }
  }
}

Put term-level clauses in the filter (or must_not) of a bool query, not must: filters skip scoring and are cached. Relevance is covered in Compound queries & relevance.

terms lookup

Instead of an inline list, terms can fetch the values from a field of another document — a terms lookup. This is the idiom for "match documents whose id is in this user’s followed-authors array" without a join.

PUT /users/_doc/alice
{ "following": ["author-7", "author-12", "author-30"] }

# Find posts by any author this user follows: values come from users/alice.following.
GET /posts/_search
{
  "query": {
    "terms": {
      "author_id": {
        "index": "users",
        "id": "alice",
        "path": "following"
      }
    }
  }
}

The looked-up document is read in real time (a GET by id), so keep that list small — the default limit is 65,536 terms. For richer parent/child needs see Joins & relationships.

terms_set

terms_set matches when at least N of the supplied terms are present, where N comes from a field on the document or a script. Useful for "matches at least 2 of these required skills". See terms_set.

PUT /candidates/_doc/1
{ "skills": ["java", "elasticsearch", "kafka"], "required_matches": 2 }

GET /candidates/_search
{
  "query": {
    "terms_set": {
      "skills": {
        "terms": ["java", "elasticsearch", "spark"],
        "minimum_should_match_field": "required_matches"
      }
    }
  }
}

range

range selects a numeric, date, IP or keyword interval with gt, gte, lt, lte. On date fields the bounds accept date math: an anchor (now, or a date followed by ||), then offsets like -7d, and an optional /d rounding unit. now-7d/d means "midnight seven days ago". See range and date math.

GET /orders/_search
{
  "query": {
    "bool": {
      "filter": [
        { "range": { "total": { "gte": 100, "lt": 500 } } },
        { "range": { "ordered_at": { "gte": "now-7d/d", "lte": "now/d" } } }
      ]
    }
  }
}

# Explicit anchor: the first day of 2024, plus one month, rounded to the month.
GET /orders/_search
{
  "query": { "range": { "ordered_at": { "gte": "2024-01-01||+1M/M" } } }
}

Rounding makes the bound stable for a whole day, so the filter cache entry is reused across requests within that day. A range over a text field is possible but expensive and rarely what you want.

exists

exists matches documents that have any non-null value for a field. There is no "is null" query — negate exists inside must_not. A field is considered missing when it is absent, null, or []. See exists.

# Documents that have a value for "deleted_at" ...
GET /orders/_search
{ "query": { "exists": { "field": "deleted_at" } } }

# ... and the inverse: documents missing it.
GET /orders/_search
{ "query": { "bool": { "must_not": { "exists": { "field": "deleted_at" } } } } }

ids

ids fetches documents by their _id values — the Query DSL equivalent of a multi-get inside a larger bool/aggregation request. See ids.

GET /orders/_search
{ "query": { "ids": { "values": ["order-1", "order-2", "order-99"] } } }

Expensive term-level queries

These clauses cannot use the inverted index as a simple lookup: they must enumerate and test many terms, so their cost grows with the field’s cardinality. All of them are gated by the cluster setting search.allow_expensive_queries (default true); set it to false to reject them outright rather than let a slow query reach the shards. See allow_expensive_queries.

Query Cost

prefix

Scans every term starting with the given prefix. Cheaper if the field is mapped with index_prefixes.

wildcard

?/* patterns. A leading wildcard (*phone) forces a full term scan — avoid it.

regexp

Full regular-expression match against every term; complex expressions are the worst case.

fuzzy

Matches terms within a Levenshtein edit distance (fuzziness, default AUTO); expands to many term alternatives.

GET /catalog/_search
{ "query": { "prefix": { "sku.keyword": { "value": "KB-" } } } }

GET /catalog/_search
{ "query": { "wildcard": { "sku.keyword": { "value": "KB-*-EU" } } } }

GET /catalog/_search
{ "query": { "fuzzy": { "name.keyword": { "value": "keyboaord", "fuzziness": "AUTO" } } } }

When you genuinely need substring or pattern matching at scale, map the field as the wildcard field type instead of keyword. It stores n-gram-style structures purpose-built for wildcard and regexp over high-cardinality string data (log lines, URLs, stack traces), making those patterns cheap where they would otherwise be allow_expensive_queries territory.

PUT /logs
{
  "mappings": {
    "properties": {
      "message": { "type": "wildcard" }
    }
  }
}

# Fast substring search that a keyword field would run as an expensive wildcard scan.
GET /logs/_search
{ "query": { "wildcard": { "message": { "value": "*OutOfMemoryError*" } } } }

For analyzed matching, ranked relevance and phrase search, use Full-text queries; to combine term-level filters with scored clauses, continue with Compound queries & relevance.