← Blog
odooormdomainsjson-rpcpython

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

Building Odoo Domain Filters Dynamically in Code — ODXProxy blog cover

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.

An empty domain is legal and means match everything. An all-optional filter where the caller supplied nothing therefore returns the entire table, not zero rows. Decide deliberately: either keep a mandatory baseline leaf in the list, or refuse to issue the call when the domain is empty.

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])
Before you reach for 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.

Never build a domain by string-formatting JSON. Assemble Python lists (or your language's native arrays) and serialise once at the end. Hand-built domain strings are how stray quotes turn into malformed-domain errors that only appear for the one customer whose company name contains an apostrophe.

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.
  • ilike with a leading wildcard (the default for a bare substring) cannot use a standard index — fine on res.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 search method. 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