← Blog
odooormdomainsrelationaljson-rpc

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 Odoo Relational Fields in Domains — ODXProxy blog cover

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 a many2one.
  • 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.

Dotted-path traversal works in a domain, but not in a 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"]
]]
On a 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