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" } } }
|
|
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 |
|---|---|
Scans every term starting with the given prefix. Cheaper if the field is mapped with |
|
|
|
Full regular-expression match against every term; complex expressions are the worst case. |
|
Matches terms within a Levenshtein edit distance ( |
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.