← Blog
odooormdomainsjson-rpc

Odoo Domain Filters: Operators and Examples

A practical guide to Odoo domain filters: the triple syntax, every operator you'll actually use, and how to combine conditions with AND, OR, and NOT.

ODXProxy Team · Jul 4, 2026 · 8 min read

Odoo Domain Filters: Operators and Examples — ODXProxy blog cover

Almost every read you make against Odoo is shaped by a domain filter — the little list of conditions that decides which records come back. Get the domain right and search, search_count, and search_read all just work; get it subtly wrong and you either pull the whole table or match nothing at all. This guide is a practical tour of Odoo domain filters: the triple syntax, the operators worth memorizing, and how to combine conditions with AND, OR, and NOT without losing your mind in the prefix notation.

What a domain actually is

A domain is a list of conditions, and each condition is a three-element list — [field, operator, value] — usually called a triple or leaf:

["is_company", "=", true]

That reads "the is_company field equals true."

A domain leaf is a triple: a field name, an operator, and a value — here country_id = false

A domain is a list of these leaves:

[
  ["is_company", "=", true],
  ["customer_rank", ">", 0]
]

When you list several leaves like this, Odoo joins them with AND by default — so the domain above means "is a company and has been a customer at least once." That default is the single most important thing to internalize: stacking conditions narrows the result set.

Everywhere a domain appears through ODXProxy, it is the first positional argument in params, which is itself a JSON array. That's why a domain ends up nested one level deep — params is the argument list, and its first element is the domain:

{
  "id": "dom-1",
  "action": "search_read",
  "model_id": "res.partner",
  "params": [[["is_company", "=", true], ["customer_rank", ">", 0]]],
  "keyword": { "fields": ["name", "email"], "limit": 50 },
  "odoo_instance": {
    "url": "https://erp.example.com",
    "db": "prod",
    "user_id": 2,
    "api_key": "<the Odoo user API key>"
  }
}

Note the two separate credentials in play: the proxy's x-api-key travels in the HTTP header, while the Odoo user's api_key sits inside odoo_instance. If that distinction is new, start with how to authenticate to the Odoo API.

The operators you'll actually use

The operator is the middle element of every leaf. These are the ones that cover the vast majority of real filters:

OperatorMatches when the field…
=, !=equals / does not equal the value
>, >=, <, <=compares numerically or by date/datetime
like, not likecontains the substring (case-sensitive)
ilike, not ilikecontains the substring (case-insensitive) — the usual text search
=like, =ilikematches a pattern where you supply the % wildcards yourself
in, not inis (or isn't) one of a list of values
child_ofis a descendant of the given record in a parent/child hierarchy
parent_ofis an ancestor of the given record in that hierarchy

A few worth calling out:

  • ilike is your default for text. ["name", "ilike", "azure"] matches "Azure Interior", "azure interior", and "AZURE" alike. Reach for like only when case actually matters.
  • =like / =ilike hand you the wildcards. Plain ilike wraps your term in %...% automatically. With =ilike you write the pattern: ["name", "=ilike", "azure%"] matches names that start with "azure", and nothing in the middle.
  • in takes a list value, not a repeated leaf: ["state", "in", ["draft", "sent"]].
Relational fields can be compared through a dotted path. ["country_id.code", "=", "US"] filters partners by their country's ISO code without a second lookup. This traversal works in domains, but not in a search_read fields list — fields reads only direct fields on the model.

Emptiness: use false, not null

Testing whether a relational or optional field is set trips up almost everyone coming from JSON or SQL. Odoo domains use the boolean false, never null:

["country_id", "=", false]

That's "partners with no country set." To find the ones that do have a country, flip it to ["country_id", "!=", false]. Writing null here doesn't raise a clear error — it just quietly fails to match the way you expect.

Combining conditions with AND, OR, and NOT

Stacking leaves gives you AND for free. To express OR or NOT, Odoo switches to prefix (Polish) notation: a logical operator token comes before the operands it applies to.

  • "&" — AND (the default, so you rarely type it)
  • "|" — OR
  • "!" — NOT

Each binary operator ("&", "|") consumes the next two complete expressions; "!" consumes the next one. So an OR between two leaves looks like this:

"params": [[
  "|",
  ["is_company", "=", true],
  ["email", "!=", false]
]]

That matches records that are companies or have an email address. The "|" binds the two leaves that follow it.

Nesting AND inside OR

Real filters mix both operators, and the trick is to read them as a tree. Say you want US-based companies or any partner that belongs to a parent company — that is OR( AND(company, in US), has-parent ):

"params": [[
  "|",
    "&",
      ["is_company", "=", true],
      ["country_id.code", "=", "US"],
  ["parent_id", "!=", false]
]]

Walk it from the front: the leading "|" needs two expressions. The first is the "&" group (a company and in the US); the second is the single parent_id leaf. The indentation is just for humans — Odoo reads the flat list — but laying it out as a tree is the fastest way to keep the operator counting straight.

Negating a condition

"!" negates the single expression that follows it. To match partners that are not companies in the US, wrap the "&" group:

"params": [[
  "!",
    "&",
      ["is_company", "=", true],
      ["country_id.code", "=", "US"]
]]

Often you don't need "!" at all — negating a single leaf is cleaner with the operator's inverse (!=, not in, not ilike). Save "!" for negating a whole compound group.

Miscounting operands is the classic domain bug. Every "&" or "|" must be followed by exactly two complete expressions (a leaf or another operator group). One leaf too few or too many and Odoo either errors or silently matches the wrong set. When a domain misbehaves, count operands before anything else.

Filtering on hierarchies with child_of

Odoo models like res.partner, account.account, and product categories are hierarchical — records point at a parent_id. The child_of operator matches a record and everything beneath it in that tree, which is perfect for "this company and all its contacts" or "this category and its subcategories". Filtering res.partner on its own hierarchy field pulls a company (id 3) together with every contact under it:

"params": [[
  ["id", "child_of", 3]
]]

parent_of is the mirror image: a record and everything above it toward the root. Both save you from walking the tree yourself with repeated queries.

Date ranges

Dates and datetimes compare with the ordinary >, >=, <, <= operators against ISO-8601 strings. A closed range is just two leaves ANDed together:

"params": [[
  ["create_date", ">=", "2026-01-01 00:00:00"],
  ["create_date", "<", "2026-04-01 00:00:00"]
]]

Use a half-open range (>= start, < next-period-start) rather than <= on an end date — it sidesteps the "does 23:59:59 count?" ambiguity entirely.

A subtlety: you can only filter on stored fields

You can read a non-stored computed field (Odoo computes it on the fly for the response), but you cannot put it in a domain — filtering happens in SQL, and a field with no column to query raises an error. If you need to filter on a computed value, define the field with store=True so it lands in the database. Not sure whether a field is stored? Ask the model itself with fields_get, covered in the search_read walkthrough.

The HTTP 200 trap still applies

A malformed domain — an unknown field name, a non-stored field, a bad operator — usually comes back as an Odoo logic error, and those arrive with an HTTP 200 status and a populated error object, not a non-200 code. Always check the HTTP status first, then check for error before you read result. The full two-layer pattern is in Odoo API error handling.

Where to go next

Domains are the lever behind every query: a list of [field, operator, value] leaves, ANDed by default, with "|" and "!" in prefix notation for everything more complex.