Feature
Filtering
A filter narrows which documents a query is allowed to match. It
never contributes to the score, it is evaluated against columnar per-segment
storage rather than the inverted index, and it is the one input that can skip
whole segments before they are opened.
{
"query_text": "breach",
"filter": {
"bool": {
"must": [
{"term": {"status": "published"}},
{"range": {"year": {"gte": "2024"}}}
],
"must_not": [
{"terms": {"tenant": ["internal", "sandbox"]}}
]
}
}
}
The same clause shape is accepted by delete by query, so a filter you validated on a search can be reused verbatim to delete exactly what it matched.
Clause types
term
Exact match on a single value.
{"term": {"status": "published"}}
Matching is exact and case-sensitive — no analysis is applied to either side.
Put one field in one term clause: naming several fields in the same
clause is a union, not an intersection, which is almost never what the shape
suggests. Use bool.must for an intersection.
terms
Exact match against any value in a list — the IN of this DSL.
{"terms": {"status": ["published", "archived"]}}range
Bounded comparison, with any combination of gte, gt,
lte, and lt. Bounds are always written as strings; how
they are compared depends on the field's column type.
{"range": {"year": {"gte": "2024", "lt": "2027"}}}
{"range": {"created_at": {"gte": "2026-01-01"}}}bool
Composition. Clauses nest to any depth.
- must clause[]
- Every clause must match. Applied left to right, each narrowing the set the next one sees.
- must_not clause[]
- Documents matching any of these are removed.
- should clause[]
- Optional clauses. A document passes when it matches at least
minimum_should_matchof them. - minimum_should_match int · default 1
- How many
shouldclauses are enough. Ignored whenshouldis empty.
match_all
Passes everything. Useful as an explicit "no constraint" placeholder in generated queries, and as the filter for a delete that is meant to empty a namespace.
{"match_all": {}}What each field type is filterable with
Filters read columnar stores built at flush time, and which store a field lands
in is decided by its field_type. This is the single most important
table on this page: a clause aimed at a column that does not exist matches
nothing, silently.
| Field type | Column | term / terms | range |
|---|---|---|---|
Keyword | string | Yes | Yes, lexicographic |
Date | string | Yes | Yes, lexicographic |
Boolean | string | Yes — "true" / "false" | Not meaningful |
Text | string | Yes, against the whole untokenized value | Yes, lexicographic |
Integer | integer | No | Yes, numeric |
Float | float | No | Yes, numeric |
Vector | — | No | No |
Two consequences that catch people out:
-
Numbers cannot be term-filtered.
termandtermsonly consult the string column, so{"term": {"year": "2026"}}against anIntegerfield matches nothing. Express it as a range —{"gte":"2026","lte":"2026"}— or index the field asKeywordif you filter it by equality more often than you compare it. -
Dates are strings. They compare lexicographically, which is
correct for zero-padded ISO-8601 (
2026-01-09) and wrong for anything else. Store dates in a format that sorts as text, or store an epoch integer and use a numeric range.
Segment pruning
Every segment's footer carries bloom filters over its filter values as well as its terms. Before a segment's inverted index, filter columns, or vectors are opened at all — that is, before any S3 GET or NVMe read for its contents — the filter is tested against those blooms, and a segment that provably cannot contain a match is skipped.
This is the same skipping trick Parquet and Lucene use, and it is what makes a selective filter over a large namespace cheap rather than merely correct. It also means the shape of a filter has real performance consequences:
-
A selective
termon a high-cardinalityKeywordfield prunes hard — most segments never open. -
A
rangeprunes far less, because a bloom answers "is this exact value present", not "does this interval overlap". -
A
must_not-only filter prunes nothing: proving a segment lacks a value cannot prove it lacks documents that need to be checked.
Filter-only queries
A request with a filter and an empty query_text is
valid, and returns every document matching the filter. There is no relevance
signal in that case, so pair it with an
explicit sort — otherwise the order is whatever ranking the scorer produced
for a set that has no ranking to speak of.
Filter-only queries also cannot use term-bloom pruning to skip segments, since there are no query terms to test. They are bounded by the filter blooms alone.