Filtering Odoo Relational Fields in Domains
How to filter Odoo domains on relational fields: many2one, one2many, and many2many. Dotted-path traversal, membership tests, child_of, and building domains in code.
ODXProxy Team · Jul 9, 2026 · 7 min read

Filtering a plain text or number field in an Odoo domain is straightforward. The moment you need to
filter across a relational field — "partners in the US", "orders with a discounted line", "contacts
tagged VIP" — the rules get subtler, and a domain that looks right can quietly match nothing. This guide
covers how to write an Odoo relational field domain filter for each of the three relation types
(many2one, one2many, many2many), the dotted-path traversal that makes it concise, and how to build
these domains dynamically from code. It assumes you know the basic triple syntax; if not, start with
Odoo domain filters explained.
The three relation types, quickly
Odoo has three relational field types, and how you filter them depends on which you're facing:
many2one— this record points at one other record (res.partner.country_id,sale.order.partner_id). Stored as the related record's id.one2many— this record is pointed at by many others (sale.order.order_line). A virtual reverse of amany2one.many2many— a set-to-set link (res.partner.category_id, the tags). Both sides hold many.
For all three, a domain leaf is still [field, operator, value]. What changes is what the field name and
value mean.
Filtering by a many2one
The simplest relational filter matches the id of the linked record directly. To find every sale order for partner 57:
"params": [[
["partner_id", "=", 57]
]]You can also match a set of linked ids with in, which is how you filter "orders for any of these
customers":
"params": [[
["partner_id", "in", [57, 63, 88]]
]]But you rarely have the id up front — you have a name, a code, an email. That's where dotted-path
traversal comes in: from a many2one you can reach into the related record and filter on one of
its fields. To find partners whose country's ISO code is US, without a separate lookup for the country
id:
"params": [[
["country_id.code", "=", "US"]
]]The country_id.code path reads "follow the country_id many2one, then match the code field on the
country." Odoo turns that into a SQL join for you. Here it is as a full request:
{
"id": "rel-1",
"action": "search_read",
"model_id": "res.partner",
"params": [[["country_id.code", "=", "US"]]],
"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 credentials still in play: the proxy's x-api-key in the HTTP header, and the Odoo user's
api_key inside odoo_instance. If that split is new, see
how to authenticate to the Odoo API.
Traversing more than one hop
Dotted paths chain as far as the relations go. To filter invoices by their customer's country's region,
you keep following many2one links:
"params": [[
["partner_id.country_id.code", "=", "US"]
]]Each dot is another join. There's no hard limit that bites in practice, but every hop is a join, so keep paths as short as the query needs.
search_read fields list. A domain filters in SQL and can join; fields only reads direct fields on the model you queried. To pull a related value into the response, read the many2one field itself (you get an [id, name] pair back) or make a second call.Filtering by one2many and many2many
Here's the rule that trips people up: with one2many and many2many, a leaf naming the relational field
tests membership, and a dotted path filters on the linked records' fields — not on the count.
To find sale orders that have at least one line for product 41, filter on the one2many field
order_line with a dotted path into the line's own product_id:
"params": [[
["order_line.product_id", "=", 41]
]]That matches an order if any of its lines satisfies the condition. The same shape works for
many2many. To find partners tagged with category 3 (a many2many on category_id):
"params": [[
["category_id", "in", [3]]
]]And to filter partners by a tag's name rather than its id, traverse into the tag record:
"params": [[
["category_id.name", "=", "VIP"]
]]one2many/many2many, a leaf like ["order_line.product_id", "=", 41] means "has some line matching," so combining two such leaves does not mean "one line matches both." ["order_line.product_id", "=", 41] AND ["order_line.discount", ">", 0] matches an order where some line has product 41 and some (possibly different) line is discounted. Matching a single line on multiple conditions needs a query against the line model itself.Hierarchies: child_of and parent_of
Many relational models are hierarchical — res.partner, product categories, and chart-of-accounts point
at a parent_id. The child_of operator matches a record and everything beneath it in that tree,
which is the clean way to get "this company and all its contacts":
"params": [[
["id", "child_of", 3]
]]parent_of is the mirror — a record and everything above it toward the root. Both are covered alongside
the other operators in Odoo domain filters explained.
Emptiness on a relational field
Testing whether a relation is set uses the boolean false, never null. To find partners with no
country:
"params": [[
["country_id", "=", false]
]]Flip to ["country_id", "!=", false] for the ones that do have a country. For a one2many/many2many,
["order_line", "=", false] matches records with an empty set — orders with no lines at all. Writing
null here doesn't raise a clear error; it just quietly fails to match.
Building relational domains in code
Because a domain is plain JSON — a list of triples — building one dynamically is just list assembly. A
common "dynamic domain filter" need is turning optional user input into leaves without hardcoding them.
In Python you accumulate leaves, then send the list as the first element of params:
domain = []
if country_code:
domain.append(["country_id.code", "=", country_code])
if tag_ids:
domain.append(["category_id", "in", tag_ids])
payload = {
"id": "rel-dyn-1",
"action": "search_read",
"model_id": "res.partner",
"params": [domain], # domain is the first positional arg
"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>",
},
}An empty domain list is legal and means "match everything" — so an all-optional filter with nothing
supplied returns all records. Guard that on your side if an unbounded read isn't what you want. When you
need OR across relational leaves, the same prefix-notation "|" from the base guide applies:
["|", ["country_id.code", "=", "US"], ["category_id.name", "=", "VIP"]].
The HTTP 200 trap still applies
A bad relational path — a misspelled field, traversing a non-relational field, or a field that isn't
stored — usually comes back as an Odoo logic error, and those arrive with HTTP 200 and a
populated error object, not a non-200 code. Check the HTTP status first, then the error field, before
you read result. The full two-layer pattern is in
Odoo API error handling.
Where to go next
- Ground yourself in the base syntax and operators in Odoo domain filters explained.
- Put a relational domain to work end to end in the Odoo search_read example, which covers pagination and field selection.
- See the exact request and response contract in the API reference.
- Skip the envelope boilerplate with the Python SDK, which takes a
domainargument directly.