Building Odoo Domain Filters Dynamically in Code
How to build an Odoo dynamic domain filter at runtime: optional filters, date windows, safe user input, and getting prefix-notation OR right when the clause count varies.
ODXProxy Team · Jul 20, 2026 · 8 min read

Every Odoo domain in the documentation is a literal — a hand-written list of triples that matches one fixed question. Real integrations almost never send a literal. They send a domain assembled at runtime from whatever the caller supplied: a search box that may be empty, three filter dropdowns of which the user picked one, a date range with an optional end. Building an Odoo dynamic domain filter correctly is mostly about two things: composing optional leaves without hardcoding them, and getting prefix-notation operators right when you do not know the clause count until runtime. This guide covers both, plus the input-safety rules that matter once the domain is driven by an end user.
It assumes you already know the basic triple syntax; if not, start with Odoo domain filters explained.
A domain is just a list — build it like one
The single most useful fact about Odoo domains over the API is that they are plain JSON: a list whose elements are either three-element leaves or operator strings. Nothing about them needs a query builder or a DSL. You append to a list.
domain = []
if customer_id:
domain.append(["partner_id", "=", customer_id])
if state:
domain.append(["state", "=", state])
if min_total is not None:
domain.append(["amount_total", ">=", min_total])Consecutive leaves are implicitly ANDed, so a list built this way already means "all of the supplied filters must match" — with no operator strings at all. That covers the majority of dynamic-filter needs, and it is the shape you should reach for by default.
The assembled list then goes into params as the first positional argument:
payload = {
"id": "dyn-1",
"action": "search_read",
"model_id": "sale.order",
"params": [domain],
"keyword": {
"fields": ["name", "partner_id", "amount_total", "state"],
"limit": 100,
"order": "date_order desc",
},
"odoo_instance": {
"url": "https://erp.example.com",
"db": "prod",
"user_id": 2,
"api_key": "<the Odoo user API key>",
},
}The nesting catches people out: params is a JSON array of positional arguments, and the domain is itself
an array, so a single-domain call is params: [domain] — a list containing a list. Send params: domain
and Odoo reads your first leaf as the whole domain.
The empty-domain guard
Because that default is so easy to ship by accident, most integrations want one of these two patterns. Either seed the domain with a non-negotiable leaf:
domain = [["state", "!=", "cancel"]] # always applied…or bail out before the request when nothing was supplied:
if not domain:
raise ValueError("at least one filter is required")Pair either one with a limit in keyword. Even a well-guarded domain can match far more rows than you
intended, and limit is the cheap backstop.
Prefix notation, and why dynamic OR is the hard part
AND is free; OR is where dynamic domains break. Odoo uses prefix (Polish) notation: the operator comes
before its operands, and "|", "&" and "!" each take a fixed number of following elements — two for
"|" and "&", one for "!".
So ORing two leaves is one "|":
["|", ["state", "=", "sale"], ["state", "=", "done"]]And ORing three leaves needs two "|" operators, because each one only joins two operands:
["|", "|", ["state", "=", "sale"], ["state", "=", "done"], ["state", "=", "sent"]]That is the rule that makes dynamic OR awkward: the number of operator strings depends on the number of
leaves. For n leaves you need n − 1 leading "|" strings. Write it as a helper once and stop thinking
about it:
def or_domain(leaves):
"""OR a variable number of domain leaves together."""
if not leaves:
return [] # matches everything — caller must handle
return ["|"] * (len(leaves) - 1) + list(leaves)Then combining an ORed block with other ANDed filters is ordinary list concatenation, because the ANDs stay implicit:
domain = []
if customer_id:
domain.append(["partner_id", "=", customer_id])
if states:
domain += or_domain([["state", "=", s] for s in states])or_domain, check whether in does the job. A set of alternative values on the same field is almost always better expressed as ["state", "in", ["sale", "done", "sent"]] — one leaf, no operator arithmetic, and the same SQL. Save the OR helper for alternatives across different fields.Date windows and the half-open interval
A date range is the most common dynamic filter after a text search, and it has one reliable trap: Odoo
datetime fields are stored in UTC, and comparing a datetime column against a bare date string compares
against midnight UTC. "Orders from today" written as ["date_order", ">=", "2026-07-20"] will include or
exclude records at the edges depending on the user's timezone.
Build the window explicitly and make it half-open — inclusive start, exclusive end — so days tile without overlapping:
from datetime import datetime, timedelta, timezone
start = datetime(2026, 7, 1, tzinfo=timezone.utc)
end = start + timedelta(days=31)
fmt = "%Y-%m-%d %H:%M:%S" # Odoo's datetime wire format, UTC
domain += [
["date_order", ">=", start.strftime(fmt)],
["date_order", "<", end.strftime(fmt)],
]Two leaves, implicitly ANDed, no "&" needed. Use < rather than <= on the upper bound: a <= against
a date-only string silently drops everything that happened after midnight on the last day.
For plain Date fields (no time component) the "%Y-%m-%d" form is correct and the timezone question does
not arise. Check the field type with fields_get if you are unsure which you have:
{
"id": "dyn-fields-1",
"action": "fields_get",
"model_id": "sale.order",
"params": [["date_order", "state"]],
"keyword": { "attributes": ["type", "string"] },
"odoo_instance": {
"url": "https://erp.example.com",
"db": "prod",
"user_id": 2,
"api_key": "<the Odoo user API key>"
}
}User-supplied input: what is and is not safe
Domains are data, not SQL strings, so there is no SQL injection risk in putting a user's search text into a leaf value. Odoo parameterises the comparison. This is safe:
if query:
domain.append(["name", "ilike", query])What is not safe to take from a user is the field name or the operator. A caller who can choose the field can read columns you never meant to expose; a caller who can choose the operator can turn a narrow lookup into a table scan. Always map user input through an allowlist you control:
SORTABLE = {"name": "name", "customer": "partner_id.name", "reference": "client_order_ref"}
field = SORTABLE.get(user_field)
if field is None:
raise ValueError(f"unknown filter field: {user_field}")
domain.append([field, "ilike", query])The same applies to keyword — the fields list and the order string are both places where unvalidated
user input leaks model structure. Allowlist them the same way.
Performance: which dynamic leaves are cheap
Not every leaf costs the same, and a dynamic builder makes it easy to ship an expensive one without noticing:
=,in,>=,<on a stored column are indexed-friendly and cheap.ilikewith a leading wildcard (the default for a bare substring) cannot use a standard index — fine onres.partner, painful on a million-row line table. Consider anchoring the pattern ("foo%") when the semantics allow.- Dotted paths across relations become joins. One hop is routine; three hops in a filter the user can toggle on is a query you should measure.
- Non-stored computed fields cannot be searched at all unless the field defines a
searchmethod. A leaf on one raises an error rather than returning wrong data — which at least fails loudly.
If a dynamic filter is optional and expensive, the honest fix is usually a limit plus a search_count
so the caller sees the true total without paging through it.
Confirming what you built
Two habits catch most dynamic-domain bugs before they reach production. First, log the assembled domain
next to the request id — the whole point of a runtime-built filter is that you cannot read it in the
source. Second, when a result set looks wrong, re-run the same domain with search_count before you start
editing leaves; a count is cheap and tells you immediately whether the domain is too narrow or the
pagination is.
And if the count itself looks impossibly low, the domain may not be the problem at all — Odoo's record rules are ANDed into every search you run, invisibly. That case is covered in why Odoo returns fewer records over the API.
Finally, a malformed domain — an unbalanced operator count, an unknown field, a leaf that is not a
three-element list — comes back as an Odoo logic error, which arrives with HTTP 200 and a populated
error object rather than a non-200 status. Check the status first, then the error field, then read
result. The two-layer pattern is in Odoo API error handling.
Where to go next
- Ground the syntax and operator list in Odoo domain filters explained.
- Traverse relations in a dynamic filter with filtering Odoo relational fields in domains.
- Put a built domain to work end to end in the Odoo search_read example.
- See the exact request and response contract in the API reference, or let the
Python SDK take the
domainlist directly and handle the envelope for you.