Query plan cache¶
hare caches the structure of a query — its shape — so that calling the same .filter()/
.annotate()/.order_by() chain again with different values skips re-walking the filter tree,
re-resolving every lookup, and rebuilding the underlying hare.sql query object from scratch. This is a
fully internal optimization: there's no Meta option and no Hare.init() kwarg for it — the only knob
is an environment variable capping its size (see Scope and lifetime below). This
page exists to explain what it does and doesn't cover, so a query's actual behavior is never a mystery.
The core idea: shape vs. value¶
Two calls have the same shape when they build the identical filter/annotation/join/ordering structure — same fields, same operators, same relations joined — differing only in the literal values plugged into that structure:
await Book.objects.filter(title__icontains="dispossessed")
await Book.objects.filter(title__icontains="left hand") # same shape, different value
The first call builds the full hare.sql query object (WHERE tree, JOINs, SELECT list, decode plan),
renders it once, and stores the result as the shape's plan, keyed by everything that makes up the
shape: the SQL text, which parameter each value of the query goes into, and what reading the result
needs. The second call recognizes the same shape and runs the plan's SQL text with its own values bound —
never re-walking Q/F/annotation resolution, re-touching the join graph, or rendering SQL.
A query of a shape with a plan is built in full when it can't run on the plan: one shown with
.sql()/explain() or built into another query that keeps no plan of its own, and one with a value that
would change the SQL text (a value converting to another type, a LIMIT present on one side only). The
plan stays for the next query. A None filter value and the boolean of __isnull are part of the shape
— IS NULL/IS NOT NULL queries keep plans of their own.
Pagination is the headline real-world case: the LIMIT/OFFSET values are never part of the shape
key and are bound fresh on every call (only whether the query has a LIMIT and an OFFSET at all is
part of it), so paging through the same filtered/ordered list — easily the single most common query
pattern in an application — hits the cache on every page:
base = Book.objects.filter(in_print=True).order_by("-published_at")
await base.limit(20).offset(0) # builds and caches the shape
await base.limit(20).offset(20) # same shape, cache hit
await base.limit(20).offset(40) # same shape, cache hit
Keyset boundaries on JSON fields also bind fresh values into the saved plan, using the same JSON
ordering as order_by(). This applies to after_cursor() and before_cursor(), including their
values(), count() and exists() queries.
What's part of the shape (the cache key)¶
The model class, the dialect, the connection alias, the query builder the model is bound to, which
columns/relations are selected (select_related/.only()/ .defer()/.values()), ordering,
distinct(), the WHERE tree's structure (join type, negation, which field/lookup at which position —
never the compared value), annotation names and expression structure (never literal annotation values)
and whether each one is an .alias(), the select_for_update() flags, how many
.after_cursor()/.before_cursor() boundary values are given and which of them are None (a None
boundary is an IS NULL/IS NOT NULL test with no value), whether the rows are sliced (a LIMIT, an
OFFSET — present or not, never their values), and the zone the query renders into its SQL — the active
timezone name under use_timezone, the machine's own zone (as tzlocal names it) without it. See
Correctness notes below for why.
Model.objects.get(**filters) runs on the same entry as the queryset it stands for, whatever its
exception parameters, found by the filter keys and the types of their values — see
get(). A model with Meta.soft_delete_field or Meta.tenant_field takes
this path too (the default scope's filters are part of the key, the tenant bound is the one active when
the query runs), and so do get() after select_for_update(), include_deleted(), only_deleted(),
all_tenants() and using() — the lock and the rows seen are part of the key. A get() of plain values
is a call of its own; one with a None, an __isnull boolean or an __in list takes the filter()
call it stands for. Like every queryset, it chooses its connection when it is awaited.
A queryset made from Model.objects by simple calls alone — filter()/exclude() with Q(...) and
Exists(...) conditions among them (filter(Q(pages=3) | Q(title="x")), the tree's joins and negations
part of the key) and values that are plain, expressions (F("other") + 1) or queries
(id__in=Subquery(...), a queryset), annotate()/alias() of expressions, distinct(), order_by()
of field names, limit()/offset(), first()/last(), get(), only(), select_related(),
values()/values_list() of field names, group_by(), prefetch_related(), and
count()/exists()/aggregate() at the end — runs on an entry found by the calls themselves: its
filters aren't even built. A model query with no relation joined and no annotation, or a
values()/values_list() query, runs without a query object at all — its rows are read the way the
entry keeps for its row shape. Each call's values — a filter's, an expression's literals — are found
among the entry's values by where they come from, so the entry's own order of values (the annotations'
before the filters') doesn't have to be the order of the calls. A prefetch_related() call keys the
relations it prefetches — a forward relation's key column is selected for its prefetch even past only()
— and its prefetch queries run as they do for any queryset. A relation read off an instance
(author.books.filter(...)) is a call chain too, its owner's key bound as a value. Any other call (a
hare.sql term built by hand as a value) leaves the queryset to the usual description above. The entry
is the same one — a call chain finds the entry its first query built, and an entry dropped from the cache
isn't found by the calls either.
A forward relation compared with a key value (author=3, author__gt=3), an instance (author=obj) or
a list of keys or instances (author__in=[...]) keeps its entry — an instance stands for its key and is
of another type than a key value, so it has an entry of its own. A relation to a composite key compares
each key column, the instance or the key tuple bound column by column. A generic foreign key
(target=obj, target__in=[...], target__type="book", target__type__in=[...],
target__isnull=True) compares the branch the value names — the branch's name is part of the key. A
filter on a to-many relation's own name (tags=obj, tags__in=[...], books=obj) and reading a
many-to-many relation (obj.tags.all()) keep their entry, an instance standing for its primary key; so
do a composite primary key's pk__in=[(a, b), ...] (by the number of rows), __iexact, PostgreSQL's
__search, and filters on a JSON path (data__a=, data__a__gt=, data__a__in=) and JSON containment
on both dialects.
A one-sided or fully-open __range ((None, 10), (None, None)) keeps an entry for each open side, its
remaining bound a value. A JSON __filter= keeps an entry for the keys of its dict, the values bound per
query; __has_keys= and __has_any_keys= keep one for the number of keys. .contains(obj) keeps an
entry like exists(), the instance's key bound per query.
A filter whose value is a query — field__in=Other.objects.filter(...).values_list("id", flat=True), a
bare queryset (compared by its primary key), Subquery(...) — keeps its entry with the subquery in it:
the key holds the subquery's own shape, and the subquery's values, its LIMIT/OFFSET included, are
bound with the enclosing query's. That holds for a filter on a window function and a union as the
subquery too. A subquery that keeps no entry of its own
shape (see the table below) is built each time, and so is the query it is the value
of.
A query with CTEs (.with_cte(name, query)) — a model query, .values()/.values_list(), count(),
exists(), aggregate() — keeps its entry with them the same way: the key holds each CTE body's shape
and the body's values are bound with the query's own. A RawSQL filter value (the documented way to read
a CTE, filter(id__in=RawSQL('SELECT "id" FROM "cte"'))) is part of the shape by its SQL text; its
parameters are values. update() and delete() with CTEs keep their entry the same way.
A set operation — union(), intersection(), difference() of querysets or of .values()/
.values_list() queries, and count()/exists() over one — keeps an entry of the whole combined
statement: the key holds each branch's shape (a nested set operation's included), the operations, the
ordering and whether it is sliced; every branch's values and the slice are bound per query. A set
operation with a branch that keeps no entry of its shape is built each time.
A query calling a dialect's QuerySet methods — ClickHouse's .final() or .limit_by(3, "author_id") —
keeps an entry with the calls written into its SQL text: the key holds each method's name and arguments,
so .limit_by(3, "author_id") and .limit_by(5, "author_id") are two shapes. A call with an argument
that can't be part of a key (a list, a dict) builds the query each time. A query run on its entry still
checks that its connection's dialect implements every called method.
count(), exists() and aggregate() of the rows a .values()/.values_list() query or a set
operation returns — sliced ([2:5].count()), .distinct() — keep an entry of the whole statement over
those rows: the key holds the rows query's shape (and each aggregate() metric's structure), its values
and the OFFSET exists() skips are bound per query. An aggregate() metric holding a value of its own
(Sum("price", _filter=Q(...)), Coalesce(Max("price"), 0)) over such rows has its values bound after
the rows query's.
aggregate() keeps an entry whatever builds it: prior .annotate()/.alias() keys the metrics or
filters read, a grouped derived table for an aggregate annotation
(.annotate(n=Count(...)) .aggregate(Max("n"))) or .distinct(), a .group_by(), a keyset boundary.
Which annotations each step of the build resolves follows from the shape and is kept with the entry, so a
later query lists its values in that order without resolving anything. Over a .group_by(), a metric
holding a value of its own has it bound the same way.
An annotation reading another one by name — F("bumped") * 2, Sum("bumped"), a filter
bumped__gt=..., a window's partition_by=/order_by= or the field of Lag/Lead — keeps its entry:
resolving the name resolves that annotation again, and its values are bound at every place it is
resolved.
A window function keeps its entry whatever it computes — RowNumber(), CumeDist(), PercentRank(),
NthValue(...), NTile(n), Lag/Lead with a default or without one, an aggregate over the window
(Window(Sum("price")), over an expression or with a _filter= condition). An expression whose SQL
takes no value — Pi(), Now(), PostgreSQL's TransactionNow() and RandomUUID() — keeps its entry
too. A None argument — Case without default, When(then=None), Lag without a default — is part
of the shape: NULL in the SQL text, no value.
A filter on an Exists(...) condition, and a filter whose value is an expression — F("other"),
F("other") + 1, OuterReference("id") in a correlated Exists(...)/Subquery(...) — keeps its entry:
the expression's own literals are bound per query. So does a bare .annotate(x=Value(...)), an
.annotate(x=Subquery(...)), and an ordering, grouping or DISTINCT ON of an annotation that isn't
selected (.annotate(dist=L2Distance("embedding", vector)).order_by("dist")[:10].values_list("id")): the
annotation's expression rendered there again has its values bound too.
An Exists(...) over a .values() query, a slice, an OFFSET or a queryset with its own
.with_cte(...) keeps its entry: the query built into the condition is part of the key, its values — its
CTEs' and its OFFSET included — bound with the enclosing query's. OuterReference(...) across a
relation (OuterReference("author__name")) keeps its entry too — the enclosing query's JOIN of the
relation records its default scope like any other — and so does OuterReference(...) naming an
annotation of the enclosing query, which resolves the annotation again with its values bound where the
reference stands. A many-to-many relation compared with the outer row (~Q(tags=OuterReference("id")),
tags__not=OuterReference("pk") of a relation to a composite key) keeps its entry as well.
with_recursive() keeps its entry: the rows the walk starts from are a subquery bound per query,
max_depth is a value, and each JOIN of the walk records its default scope.
A .values()/.values_list() query filtered on a window function, wrapped as a derived table, keeps its
entry.
A FilteredRelation(...), and a select_related(Select(relation, extra_condition=...)) whose relation
is crossed elsewhere as well (.only()/.defer() naming its fields, a filter, .order_by(),
.values()/.values_list(), group_by(), an annotation), keep their entry: the condition is folded
into every JOIN of the relation — one condition folded into several JOINs (the filter's, the ordering's,
a many-to-many relation's link and its row) binds its values into each, and one folded into no JOIN (a
count() of a relation it doesn't cross) binds them nowhere. The condition's values are the query's own,
so the same holds for a query built into another one — a subquery, Exists(...), a CTE body, a set
operation's branch — even one joining a FilteredRelation(...) under the name the enclosing query uses.
The vector distances, full-text search and PostgreSQL's expressions keep their entry like the built-in
ones: L2Distance/CosineDistance/InnerProduct (the query vector bound, converted the way the dialect
stores the compared VectorField), SearchVector, SearchQuery (a Lexeme query included),
SearchRank and SearchHeadline (the search text bound, on every dialect), STDistance/STDWithin
(the point and the radius bound) and the trigram functions.
count() and exists() past a keyset boundary (.after_cursor()/.before_cursor()), exists() past
an OFFSET, and a filter on a set operation's rows (pk__in=a.union(b)) keep their entry: the boundary
values, the OFFSET and the union's own values are bound per query.
stream() runs on the entry's SQL text the same way as awaiting the query.
update() and delete() keep their whole statement the same way, whatever picks the rows they write —
filters across relations, a filter on an aggregate annotation
(pk IN (SELECT ... GROUP BY ... HAVING ...)), .after_cursor(): the key holds the shape of what picks
the rows and adds each assigned field with the type of its value, or the structure of an assigned
expression (F("price") + 1 and F("price") * 1 are two shapes).
A hare.sql term built by hand (Field("score") * ValueWrapper(factor)) keeps its entry wherever it
stands — annotated, as a function's argument, assigned by update(): its SQL text with every value a
parameter is part of the key, and the values it binds are bound per query.
Model.objects.raw(sql, parameters) keeps a statement of its own for each SQL text, parameter types and
connection: the text is rendered once with the connection's placeholders, and a later call binds its
parameters into it. Which result columns are fields of the model and which become attributes of each
instance is kept for each list of column names. A parameter the text holds as a literal ("*") builds
the statement each time.
A filter across a relation keeps its entry whatever its lookup — author__name="x",
author__name__in=[...], author__name__startswith="a", author__books__rating__range=(1, 2), a
negated one (.exclude(author__name="x")) — and its value is converted for the related model.
A filter on an annotation — .annotate(n=Count("books")).filter(n__gte=2),
.annotate(doubled=F("price") * 2).filter(doubled__gt=10),
.annotate(has_x=Exists(...)).filter(has_x=True) — keeps its entry: the filter's value and the
annotation's own values are bound per query.
How a query describes its plan¶
Every part of a query a plan can be kept for — a filter (Q), an expression (F, Value, arithmetic, a
function or aggregate, Case/When, Window and its window function, Exists, RawSQL), a query
built into another one (a subquery, a CTE body, a set operation's branch) — describes itself
(hare.query.plans.description.Plannable.get_plan_description()). The description (PlanDescription)
has two halves: the structure — everything that changes the SQL text the part builds, which becomes
part of the plan key — and the values the part binds as parameters. A part describes its own parts
through their descriptions, so the key of a whole query is the structure of its description.
Descriptions aren't written by hand. An expression class declares how each of its attributes meets the
plan (plan_parts), and a query class how each of its settings meets the key (plan_slots: its model,
connection and visibility, its filters, ordering, slice, CTEs, ...); the description of each class is
generated from its declaration once, when the class is made. A pytest run with --verify-plans checks
that every attribute of an expression it describes is declared, besides comparing each query run on a
plan with the query built in full.
Each value comes from somewhere — the attribute of an object holding it: a filter's value from its
condition's key, a literal from its expression's attribute, the slice from its query. The first query of
a key records, while it is built, every place its SQL text binds a value together with where the value
comes from, and its plan binds a later query's value into each of them — however many times the build
resolved it: a condition read by a filter, an ordering and .values() across one relation, a nested
queryset built into the query twice. An object the build makes again for each copy of a query (a copy of
a query built into another one, the default scope's condition, a filter call kept unbuilt) stands for the
object it was made from, and a condition the build derives from a value (a filter across a relation, a
key compared column by column) keeps that value's origin.
The key is the full structure, compared by equality — not a hash of it — so two queries share a plan only
when everything that shapes their SQL text is equal. A part that keeps no plan describes itself as
None, and so does every query holding it: such a query is built in full each time.
A class of a part neither describing itself nor declaring plannable = False is rejected when it is
defined — see Writing a custom
expression.
What's a value (never part of the key, free to vary)¶
Every filter's literal value (its Python type is part of the key, though — an int and a
float/Decimal for the same filter build different SQL, so they never share an entry), the
LIMIT/OFFSET values, an __in/__not_in list's own contents (whether it holds a None is
structural — a None ORs in an IS NULL test, and an empty list is a constant condition; a list shorter
than the dialect binds as one parameter — 20 values on PostgreSQL and SQLite — is keyed by the number of
its values other than None, a longer one by nothing more: one entry serves every length), a
fully-bounded __range's two bounds, .after_cursor()'s boundary values, annotation literals (Value()
nested inside an expression, Case/When literal then=/default=, RawSQL parameters,
Coalesce/Concat/NTile/Lag/Lead default arguments, Aggregate(_filter=Q(...))'s own filter
values), select_related(relation, extra_condition=Q(...))'s and FilteredRelation(...)'s condition
values, with_recursive()'s max_depth, the values of a hare.sql term built by hand, .raw()
parameters, an aggregate() metric's own values, the values an update() assigns (converted the way a
write converts them — a DecimalField value is rounded to its decimal places) and the moment it sets
auto_now fields to.
What still falls back to the uncached path¶
A handful of shapes are deliberately excluded — either because caching them would risk a silently wrong result, or because no rebinding hook exists yet. Each of these still works correctly, just without the fast path:
| Shape | Why it's excluded |
|---|---|
Lateral(...) and JsonTable(...) |
The subquery or table function joined under the name has no description — its values sit in the JOIN. |
.sample(...) |
The percent and the seed are written into FROM, not bound. |
| A dialect's QuerySet method called with a list or dict argument | The argument can't be part of a key. |
A register_lookup()-registered lookup whose operator transforms the value, without binds_by_rebuild=True |
Only safe when the criterion embeds the encoder's own output directly — see below. |
A hare.sql term built by hand that renders on its own dialect only, or binds a value no ValueWrapper holds |
Its description is its SQL text rendered by the neutral dialect, with every value a ValueWrapper binds. |
None of these need any code change on your side — each one just runs without the cache.
A JOIN to a model with a default scope — Meta.tenant_field, Meta.soft_delete_field or a custom
Meta.manager get_queryset() on the related model or a many-to-many through= model — takes the fast
path: the plan records the scope's values apart from the query's own, and a query running on it binds the
scope of its own context (the active tenant, what a custom manager filters by now). A scope whose filter
has another shape than when the plan was recorded — another number of tenants under Tenancy.scope(...),
a custom manager filtering differently — builds the query in full.
Scope and lifetime¶
StatementPlans.plans, StatementPlans.call_signature_plans (a call chain's key -> the plan of plans
it runs on, dropped whenever plans drops any plan) and StatementPlans.decode_plans
(hare.query.plans.statement.statement_plans) are Cache instances (hare.core.caching.cache) —
bounded and scoped per model class, not one unbounded global dict. Each model class gets its own
OrderedDict bucket with real LRU behavior: a hit moves its entry to the end
(OrderedDict.move_to_end()), and once a model's bucket exceeds max_size entries the oldest one is
evicted (OrderedDict.popitem(last=False)). The per-model cap defaults to 512 and is configurable
process-wide via the HARE_STATEMENT_PLAN_CACHE_MAX_SIZE_PER_MODEL environment variable
(hare.core.caching.constants.DEFAULT_STATEMENT_PLAN_CACHE_MAX_SIZE_PER_MODEL), a whole number from 1 to
1,000,000 (anything else is a ConfigurationError when hare is imported) — one "noisy" model's shape
diversity (e.g. pk__in= called with ever-different list lengths) can never evict another, unrelated
model's entries the way one shared global limit would.
A model's buckets live on the model itself (Model._meta), and a cache knows the models holding its
buckets only through a WeakSet — so a model class replaced at runtime (e.g. via
Hare.register_live_models() re-registering the same table under a fresh
class) is garbage-collected together with its entries once nothing else references it.
There is no way to bypass it. hare.core.caching.caches.Caches.forget_model_caches([Book, ...]) drops
every entry built for the given models — this cache, the decode plans (StatementPlans.decode_plans),
the cached save/delete SQL, the relation walks of the delete cascade and every other cache built for
models — which hare itself does when a model's relations change after first use, and when a live model is
registered again or unregistered. Reach for it in a test that needs a cold cache; application code has no
reason to.
Every one of those caches is registered in Caches (hare.core.caching.caches) where it is defined: a
cache of values by key is a Cache (it registers itself; a key holding a model class goes into that
model's bucket, any other key into the cache's own one, each bucket least-recently-used), a cache of one
value per model class is a ModelCache (it registers itself too, and with depends_on_other_models=True
is dropped whole when any model changes, for values that describe a relation graph), and anything else
implementing forget_model(model) / forget_all() is passed to Caches.register().
forget_model_caches() and forget_all_caches() (run on every change to a registry — a lookup, a field
type, a dialect method registered) go through the registered caches; no list of caches is kept anywhere
else.
Besides max_size (the most entries a bucket holds), a Cache takes these options. holds_sql=False is
for values that hold no SQL (a generated pydantic model, a description of a filter key): a model's
entries then stay whichever query builder the model is bound to, and for a model not bound yet.
keyed_by_model=False is for keys that never hold a model class — they are not searched for one.
depends_on_other_models=True drops every entry when any model changes, as a ModelCache does.
For model-keyed caches, iteration and len(cache) exclude SQL entries from a different query builder,
just as a lookup treats them as misses.
model_attribute names an attribute of a model's _meta its plain bucket (below) is kept in as well,
owner_attribute the attribute an owner keeps its one value in, and dropped_with=<another cache> drops
every entry of this cache whenever that cache drops any.
Where a lookup must cost no more than a dict's, the cache hands out a plain dict it still owns:
Cache.get_model_bucket(model) is the dict a model keeps for the cache (the descriptions of its filter
keys and ordering names, its parsed lookup paths, its row layouts), dropped with the model's entries, so
it is asked for on every read; Cache.get_owner_bucket(owner) is the dict another object keeps (a
dialect's type registry and term renderers keep what they found through a class's bases there, a request
query class its checked declaration) — the owner holds it in its cache_buckets, and the cache empties
it in place; Cache.new_shared_bucket() is a dict that belongs to whoever holds it (a queryset and its
clones keep their descriptions of keys starting with an annotation there). One value read on every query
stays in an attribute of its owner (owner_attribute, Cache.set_owner_value() — a manager's queryset),
set back to None when the cache drops it. A fact of a model — computed once from its fields and
relations — is a function decorated with ModelCache.fact().
Scoped by connection and query builder
The key includes the dialect of the connection the query runs on, that connection's alias, and the
query builder class the model is currently bound to. Two connections never share entries, even when
they share a dialect and point the same model class at structurally different schemas. A query built
while a nested HareContext had bound the same models to another database's builder is never served
under the outer context's key once the nested context exits. The SQL cached for saves, deletes and
Model.objects.get()-style lookups is keyed the same way.
Correctness notes¶
This cache stores real query structure keyed on filter keys, not values — so its own design principle is that reusing the wrong criterion structure, or a stale/shared value, for a different call would be a silent data-correctness bug, not just a missed optimization. Two rules follow from that:
- A
register_lookup()operator must not transform its value. A custom lookup that embedsfield.to_db_value(value, model)(orvalue_encoder(value)) directly into the criterion is safe to cache — a hit binds a fresh value through the same path. A lookup whose operator computes something else from the value (e.g. reversing a string before comparing) can't be — a shape hit would silently keep applying the first call's transformed value. hare checks this by identity before trusting a shape's values can be bound, so an unsafe custom lookup is automatically excluded rather than silently miscached. A lookup whoseFieldLookupdeclaresbinds_by_rebuild=Trueis planned anyway: a query running on the entry builds the criterion again from its own value and binds what it holds — correct only when the SQL text depends on nothing of the value but its type (and a list's length). - Anything built into the query's structure, rather than stored as a value a hit binds, must be part of
the shape key. The clearest example:
field__year/__month/etc. build the active timezone's name into their criterion (EXTRACT(... AT TIME ZONE $1)) — it reaches the database as a bind parameter, but a cache hit doesn't bind it anew — so the timezone name is part of the shape key, and switching timezones at runtime (Timezone.override(), anotherinit()) can't silently keep extracting in a stale cached zone. Withoutuse_timezonea datetime expression converts from the machine's own zone instead, and that zone's name is part of the key the same way.
Performance¶
There's no single official "N% faster" benchmark for this cache as a whole — its rationale is structural:
skip re-walking and re-resolving the entire filter/annotation/join tree, rebuilding hare.sql term
objects and rendering SQL on every repeated-shape call, binding only the values. The cost this
specifically targets is real — e.g. eagerly building one bind-value wrapper per element of a large list
was measured at roughly 97% of that list's own construction cost, which is why a plan binds an entire
list-valued argument as one parameter rather than rebuilding it element by element — but that number
describes one internal design choice, not an end-to-end speedup claim for the cache as a whole.