Real searches are not one clause. They are a text query plus five filters, and
bool is how you assemble them. It has four slots, and choosing the right one decides
both whether your results are correct and how fast they come back.
The four clauses
{
"query": {
"bool": {
"must": [ ... ], // must match, and it SCORES
"filter": [ ... ], // must match, no score, cacheable
"should": [ ... ], // may match — see below, this one is subtle
"must_not": [ ... ] // must not match, no score, cacheable
}
}
}Every slot takes a list of queries, and any of those queries can be another
bool.
The two-word summary: must and should run in query context;
filter and must_not run in filter context. Everything else
follows from that distinction.
must versus filter
Both require a match. The difference is what happens to the score, and it is measurable.
Search for "cabin" in the title, no filter:
must only: _score = 2.2335923Now add a filter for cabins — a condition the matching document satisfies:
must + filter: _score = 2.2335923Identical, to seven decimal places. The filter changed which documents came back and contributed exactly nothing to how they were ranked.
Move that same clause into must:
both in must: _score = 3.956359Now it scores, and "is a cabin" is inflating the relevance of a text search. That is the bug this
distinction prevents: put max_guests >= 4 in must and "sleeps more
people" starts outranking "actually matches what you searched for".
And the speed part
Filter context is cacheable. A clause that only decides yes/no produces the same answer for the same segment every time, so Elasticsearch can remember it as a bitset — one bit per document — and reuse it across queries.
A scoring clause never can, because the score depends on the query terms.
So the rule is simple and worth applying mechanically: if the condition is not a matter
of degree, it goes in filter. Price ranges, guest counts, categories,
amenities, dates, booleans, ids. Only free text belongs in must.
StayHub does exactly that, and says so where it happens:
"""Every non-scoring clause, grouped under the facet it belongs to.
Why these are `filter` and not `must`: filter clauses do NOT score, they only include or
exclude — and they are **cacheable**, which a scoring clause can never be. Putting
`max_guests >= 4` in `must` is not wrong, exactly; it just makes every query slower and lets
"sleeps more people" quietly outrank "actually matches what you searched for".
"""should, and the trap in it
should means "may match", and its behaviour changes depending on what else is in the
bool. This surprises everyone once.
With only should clauses, at least one must match —
minimum_should_match defaults to 1, so it behaves as an OR:
{"bool": {"should": [
{"term": {"property_type": "CABIN"}},
{"term": {"property_type": "LOFT"}}
]}}
# 3 hits: CABIN, CABIN, LOFTWith a must or filter alongside,
minimum_should_match defaults to 0, and the should clauses become
purely optional — they boost matching documents but exclude nothing:
{"bool": {
"must": [{"match_all": {}}],
"should": [{"term": {"property_type": "CABIN"}}]
}}
# 12 hits — all of them. The `should` filtered nothing.Twelve, not two. Somebody expecting an AND-of-ORs writes this and gets back the entire index, with the cabins at the top so it briefly looks correct.
The fix is to say what you mean rather than rely on a default that changes:
{ "bool": {
"must": [ { "match": { "title": "loft" } } ],
"should": [ { "term": { "property_type": "CABIN" } },
{ "term": { "property_type": "LOFT" } } ],
"minimum_should_match": 1
} }Better still: if it is a requirement, it is not a should. Put the OR in its own
nested bool inside filter, where it cannot be misread:
{ "bool": { "filter": [
{ "bool": { "should": [ { "term": { "property_type": "CABIN" } },
{ "term": { "property_type": "LOFT" } } ],
"minimum_should_match": 1 } }
] } }Verbose, and it says exactly one thing. Use should at the top level for what it is
actually good at: optional signals that improve ranking — "boost listings with a hot tub"
— without narrowing the results.
must_not
must_not excludes, runs in filter context, and is cached.
{ "bool": { "must_not": [ { "term": { "room_type": "SHARED_ROOM" } } ] } }One thing to watch: must_not on a field means "does not have this value", and a
document where the field is absent also does not have that value, so it matches. That is
usually what you want and occasionally a surprise. Pair it with an exists filter when
you mean "has the field, and it is not this".
term or terms: it is not a style choice
A filter panel with two amenity boxes ticked can be expressed two ways, and they mean different things.
// OR — has wifi, or parking, or both
{ "terms": { "amenities": ["wifi", "parking"] } }
// AND — has both
[ { "term": { "amenities": "wifi" } },
{ "term": { "amenities": "parking" } } ]A single terms clause is an OR. Ticking two boxes in a filter panel means "both", so
StayHub emits one term per amenity:
if req.amenities:
# One `term` per amenity, so they AND together: "wifi AND parking". A single `terms` clause
# with the whole list would be OR — "wifi OR parking" — which is not what a filter panel
# means when a guest ticks two boxes.
groups["amenities"] = [{"term": {"amenities": slug}} for slug in req.amenities]This is easy to get backwards, and the symptom is not an error — it is a filter panel that returns more results as the user narrows their criteria. Worth an explicit test.
The interesting part is that both are correct in general: a property type panel usually does mean OR, because a listing has exactly one type and "cabin or loft" is the only sensible reading. Same widget, opposite semantics, decided by whether the underlying field is single- or multi-valued.
The filter that quietly matches the wrong things
A term filter on a text field is lesson 4's bug arriving in filter
context, where it is harder to spot because filters are not usually the thing you debug.
{ "term": { "city": "San Francisco" } } // 0 hits
{ "term": { "city": "san" } } // matches every San-anything
{ "term": { "city.raw": "San Francisco" } } // correctThe middle one is the dangerous case: it returns results, so nothing looks broken, and it matches
San Francisco, San Diego and San Jose alike. Filters go on keyword fields. If your
filter is on a field without .raw or .keyword in the name, check the
mapping.
The filter cache, concretely
Worth knowing what is actually cached, because the rules are not obvious.
Elasticsearch caches filter results as a bitset per segment, not per index. It does this only for filters it has seen used repeatedly, and only on segments above a size threshold — caching a filter over a tiny segment that is about to be merged away is wasted work.
Because segments are immutable, a cached bitset is never invalidated. New documents go into new segments, which get their own entries; merged segments discard the old ones. There is no staleness problem, which is the elegant part.
What does not cache well:
Anything containing an unrounded now. A range with
"gte": "now-1h" is a different value every millisecond, so every request is a cache
miss. Rounding fixes it: "now-1h/m" changes only once a minute, and
"now/d" once a day. This one change can make a dashboard's queries an order of
magnitude cheaper.
Very high-cardinality term filters, where each distinct value is used once — a filter on user id has no reuse to exploit.
Scripts and runtime fields, which have to be evaluated.
Two habits follow. Round your date maths. And put the reusable filters in
filter even when a query already narrows things down — a tenant filter or a
status filter is used by every query and is exactly what the cache is for.
Nesting, and how deep is too deep
bool composes: any clause can be another bool, so an arbitrary boolean
expression is expressible. "A cabin or a loft, under $300, that is not a shared room, and either has
a hot tub or sleeps six" is one structure:
{ "bool": {
"must": [ { "match": { "description": "mountain" } } ],
"filter": [
{ "bool": { "should": [ { "term": { "property_type": "CABIN" } },
{ "term": { "property_type": "LOFT" } } ],
"minimum_should_match": 1 } },
{ "range": { "price_per_night": { "lt": 300 } } },
{ "bool": { "should": [ { "term": { "amenities": "hot-tub" } },
{ "range": { "max_guests": { "gte": 6 } } } ],
"minimum_should_match": 1 } }
],
"must_not": [ { "term": { "room_type": "SHARED_ROOM" } } ]
} }Note the inner bools sit inside filter, so they inherit filter context
and stay non-scoring even though they use should. That inheritance is the part people
miss: context propagates downwards. A match nested three levels deep
inside a filter does not score.
There is a depth limit (indices.query.bool.max_nested_depth, 30 by default), and you
will not reach it by hand. What you can reach is a query so deeply generated that nobody can read
it, which is a maintainability problem rather than a performance one. If you are generating filters
from user input, keep the generator flat and readable — group by control, as below, and
flatten once.
constant_score
To run a query in filter context without a bool:
{ "constant_score": { "filter": { "term": { "property_type": "CABIN" } }, "boost": 1.0 } }Every match gets the same score. Useful when you want a scoring clause's matching
behaviour without its ranking — and as a deliberate flattening: a browse page that should be
ordered by price, not by an accidental relevance signal, is clearer as a
constant_score than as a bool whose scores are then overridden by a
sort.
Finding out which clause matched
Once a query has eight clauses, "why did this document come back" is a real question. Name the clauses:
{ "bool": {
"must": [ { "match": { "title": { "query": "cabin", "_name": "title_match" } } } ],
"filter": [ { "term": { "property_type": { "value": "CABIN", "_name": "is_cabin" } } } ]
} }Every hit then carries the list:
"matched_queries": ["is_cabin", "title_match"]This costs very little and it turns a guess into a fact. It is especially good with
should clauses, where the whole question is which optional signals fired.
How StayHub assembles a filter set
The filters are built grouped by the control that produced them, rather than as a flat list:
groups: dict[str, list[dict[str, Any]]] = {}
if req.guests:
groups["guests"] = [{"range": {"max_guests": {"gte": req.guests}}}]
if req.min_price is not None or req.max_price is not None:
price_range: dict[str, float] = {}
if req.min_price is not None:
price_range["gte"] = float(req.min_price)
if req.max_price is not None:
price_range["lte"] = float(req.max_price)
groups["price"] = [{"range": {"price_per_night": price_range}}]
if req.property_type:
groups["property_type"] = [{"term": {"property_type": req.property_type}}]Flattened into the query with one helper:
def _flatten(groups: dict[str, list[dict[str, Any]]], *, exclude: str | None = None) -> list[dict[str, Any]]:
return [clause for name, clauses in groups.items() if name != exclude for clause in clauses]The exclude parameter is the reason for the grouping, and it is not tidiness. Lesson
13 needs to rebuild the filter list minus one group for each facet, and a flat list cannot
be un-filtered — once the clauses lose track of which control produced them, there is no way
to ask "what would the results be without this one".
That is a small design decision made early that turns a hard feature into an easy one, which is worth noticing.
Why the grouping is worth the extra indirection
A flat list is shorter to write and it forecloses a feature. Once the clauses are anonymous, the only thing you can do with them is send them; you cannot ask "what would this result set look like without the price filter", which is the question every facet count is secretly asking.
The general shape is worth stealing regardless of Elasticsearch: when you build a query from several independent user controls, keep the association between control and clause until the last possible moment. It costs one dictionary and it keeps every "what if" question answerable.
One filter that is not there
StayHub never filters on status, even though the field is in the mapping:
# Only PUBLISHED documents are ever indexed (see indexer.py), so no status filter is needed
# here. That is the payoff for removing rather than flagging.The alternative — index everything, filter at query time — works, and it costs a clause on every query forever, plus the risk that one query somewhere forgets it and leaks a draft listing into public results. Lesson 7 made that call on the write side; this is where the benefit shows up.
It is the general principle for filters worth remembering: the cheapest filter is the one you can delete because the documents it excludes were never indexed.
Does clause order matter?
A reasonable question, and the answer is mostly no — with one caveat worth knowing.
Within filter, Elasticsearch does not simply evaluate your clauses left to right. It
estimates the cost of each and runs the cheap, selective ones first, using the results to skip work
on the rest. A term filter matching 3 documents runs before a range
matching 10,000, whatever order you wrote them in.
So do not spend effort ordering filters for speed. Order them for readability — group them the way the UI groups them — and let the query planner do its job.
The caveat is that this only works for clauses it can estimate. A script filter has
no estimable cost, so it runs against everything that reaches it. If you must use one, make sure it
sits alongside selective filters rather than being the only clause.
Testing filter logic
Filters are unusually worth unit testing, because the failures are silent: a wrong filter returns results, just the wrong ones. And they are unusually easy to test, because building the query body is a pure function of the request.
def test_amenities_and_together(self):
"""Two ticked boxes mean "both", so two `term` clauses — not one `terms`, which is OR."""
filters = self._filters(amenities=["wifi", "parking"])
assert filters == [{"term": {"amenities": "wifi"}}, {"term": {"amenities": "parking"}}]No cluster, no fixtures, runs in milliseconds, and it pins the exact decision that is easy to get backwards. A handful of these covering each control is the cheapest insurance in a search codebase.
Worth pairing with a small number of tests that do hit a real cluster, for the claims a dict comparison cannot make — that two ticked amenities really do narrow rather than widen the result set:
def test_amenities_and_rather_than_or(self, index):
assert run(index, amenities=["wifi"]).total == 2
assert run(index, amenities=["wifi", "hot-tub"]).total == 1
assert run(index, amenities=["hot-tub", "kitchen"]).total == 0The rules, short
Free text in must. Everything else in filter. Never put a plain
should next to a must and expect it to narrow anything — set
minimum_should_match explicitly, or move the OR into a nested bool under
filter. One term per value for AND, one terms for OR, chosen
by what the UI means. Filters on keyword fields. Round your date maths.
With filtering settled, the remaining question about must is what those scores
actually are — and how to change them when the ranking is wrong.