All posts
Developer9 min read

Odoo domain notation: a complete developer reference

Domains are how Odoo expresses search filters - in security rules, actions, onchange triggers, and code. Getting the syntax wrong produces silent empty results or Python errors. This reference covers every operator, the AND/OR/NOT combinators, and performance pitfalls.

Domains are Odoo's query language for filtering records. They appear in ir.rules, server actions, action windows (domain field on ir.act_window), onchange domains for Many2one fields, and Python code that calls search() or search_count(). The syntax is compact but has enough edge cases that even experienced Odoo developers occasionally get it wrong.

This post is a complete reference - every operator, every combinator, and the performance considerations that matter in production.


Basic structure#

A domain is a Python list of tuples:

python
[('field_name', 'operator', value)]

Each tuple is a leaf condition. Multiple leaves are combined with logical operators. An empty list [] matches all records.

python
# Single condition
[('state', '=', 'draft')]

# Multiple conditions (implicit AND)
[('state', '=', 'draft'), ('partner_id.country_id.code', '=', 'FR')]

# Explicit AND
['&', ('state', '=', 'draft'), ('partner_id', '!=', False)]

# OR
['|', ('state', '=', 'draft'), ('state', '=', 'cancel')]

Operators#

OperatorDescriptionNotes
=EqualsWorks on all field types
!=Not equals
<, <=, >, >=ComparisonNumeric, date, datetime fields
=likeSQL LIKE with % wildcardsCase-sensitive
likeSQL LIKE with auto-wrapped %Adds % before and after value
=ilikeCase-insensitive LIKEExact pattern
ilikeCase-insensitive LIKE with auto-wrapMost common for name search
not likeSQL NOT LIKE with auto-wrap
not ilikeCase-insensitive NOT LIKE with auto-wrap
inValue is in listValue must be a Python list
not inValue is not in listValue must be a Python list
child_ofRecord is a child of given IDFor models with parent_id
parent_ofRecord is a parent of given IDInverse of child_of

like vs =like: like wraps the value in % automatically, equivalent to SQL LIKE '%value%'. =like uses the value exactly as given - you must include any % wildcards yourself. For most name search cases, ilike is what you want.

child_of: This operator traverses the parent_id hierarchy and returns the given record plus all descendants. It uses a recursive SQL query (WITH RECURSIVE in PostgreSQL). On large hierarchies, this can be slow - avoid using it in domains on large models without an index on parent_id.


Logical operators: &, |, !#

Odoo uses Polish (prefix) notation for logical operators. The operator appears before its operands, not between them.

& (AND) takes exactly 2 operands:

python
['&', ('state', '=', 'draft'), ('amount_total', '>', 1000)]
# Equivalent SQL: state = 'draft' AND amount_total > 1000

| (OR) takes exactly 2 operands:

python
['|', ('state', '=', 'draft'), ('state', '=', 'sent')]
# Equivalent SQL: state = 'draft' OR state = 'sent'

! (NOT) takes exactly 1 operand:

python
['!', ('state', '=', 'cancel')]
# Equivalent SQL: NOT (state = 'cancel')

Chaining multiple OR conditions:

Each | or & consumes exactly 2 operands. To OR three conditions, nest two operators:

python
['|', '|', ('state', '=', 'draft'), ('state', '=', 'sent'), ('state', '=', 'posted')]
# Reads as: | (| draft sent) posted
# SQL: state = 'draft' OR state = 'sent' OR state = 'posted'

The nesting reads left to right: the first | consumes the second | and ('state', '=', 'posted') as its two operands. The second | consumes ('state', '=', 'draft') and ('state', '=', 'sent'). This trips up developers who expect infix notation.

Implicit AND: A list with multiple tuples and no explicit operators uses implicit & for all leaves. [A, B, C] is equivalent to ['&', '&', A, B, C] - AND applied left to right.


False and None in domains#

To check for empty relational fields:

python
# Many2one is not set
[('partner_id', '=', False)]

# Many2one is set
[('partner_id', '!=', False)]

Use Python False, not None or the string 'False'. Odoo converts False to SQL NULL comparisons automatically using IS NULL / IS NOT NULL.

For Many2many and One2many fields, use = with False to find records with an empty relation, but be aware this translates to a subquery - it can be expensive on large datasets.


Relational field traversal#

You can traverse relations in field paths using dots:

python
# Partner's country is France
[('partner_id.country_id.code', '=', 'FR')]

# Sale order line's product category
[('order_line.product_id.categ_id.name', '=', 'All / Software')]

Each dot traversal generates a JOIN in the SQL query. Traversing into a One2many (e.g., order_line) generates an EXISTS subquery - the filter matches the parent record if any child matches. This is correct for "has at least one line with product X" but incorrect for "all lines have product X." For the latter, you need a different approach (usually a computed field or a SQL view).

Limit dot traversal to 2-3 levels maximum. Deeper traversals generate increasingly complex SQL and are harder to index efficiently.


Dynamic domains#

In ir.rules and some view attributes, you can use Python expressions. The available variables depend on context:

In ir.rules domain_force:

  • user - the current res.users record
  • time - Python's time module
  • company_id - the current company ID (integer)
  • company_ids - all accessible company IDs (list of integers)
python
# Records belonging to the current user
[('user_id', '=', user.id)]

# Records created within the current user's company
[('company_id', 'in', company_ids)]

# Records modified in the last 30 days
[('write_date', '>=', (datetime.now() - timedelta(days=30)).strftime('%Y-%m-%d %H:%M:%S'))]

In Python code, dynamic domains:

python
def _get_active_partner_domain(self):
    return [
        ('active', '=', True),
        ('country_id', '=', self.env.user.country_id.id),
    ]

partners = self.env['res.partner'].search(self._get_active_partner_domain())

Build domains as data structures, not strings. String-based domain construction is fragile and breaks on values that contain quotes.


Using domain_add and AND / OR helpers#

When combining domains programmatically, use Odoo's expression module:

python
from odoo.osv import expression

domain_a = [('state', '=', 'draft')]
domain_b = [('partner_id.country_id.code', '=', 'FR')]

combined_and = expression.AND([domain_a, domain_b])
combined_or = expression.OR([domain_a, domain_b])

expression.AND and expression.OR handle edge cases like empty lists correctly:

  • AND([[], domain_b]) returns domain_b (empty list matches everything, so AND with it is a no-op)
  • OR([[], domain_b]) returns [] (empty list matches everything, OR with it matches everything)

This is the correct way to merge domains when either might be empty.


Performance considerations#

Index coverage. A domain on a field without an index triggers a full table scan. For models with millions of records, unindexed search fields are a production problem. Add index=True to fields you regularly search or filter on.

python
priority = fields.Selection([...], string='Priority', index=True)

Many2many subqueries. Filtering on a Many2many field generates a subquery with a JOIN against the relation table. For large M2M relations, this can be slow. A computed Many2one field that denormalizes frequently-filtered M2M data is sometimes faster.

ilike on large text fields. ilike generates a LIKE '%value%' query which cannot use a standard B-tree index. For full-text search on large columns, consider PostgreSQL GIN indexes with pg_trgm extension, or move the search logic to a dedicated search index.

Chained relational traversal. Each dot in a field path is a JOIN or subquery. A domain like [('order_line.product_id.categ_id.parent_id.name', '=', 'Software')] generates 4 JOINs. If this domain is used in an ir.rule, it runs on every single ORM call for that model.


Common mistakes#

Using string 'True' or 'False'. [('active', '=', 'True')] does not work - use Python boolean True.

Forgetting prefix notation for nested OR. ['|', A, B, C] is not valid - | takes exactly 2 operands. Use ['|', '|', A, B, C].

Building domains via string concatenation. "[('name', 'ilike', '" + user_input + "')]" is an injection vulnerability. Always build domains as Python data structures.

Using not in with an empty list. [('id', 'not in', [])] logically matches everything but generates a NOT IN () SQL clause which is invalid in some database drivers. Use expression.AND to skip the clause when the list is empty.

Expecting child_of to be fast. child_of uses a recursive CTE. On deep hierarchies (1000+ nodes), this can take seconds. Flatten the hierarchy with a stored parent_path field if performance matters.


ERPeek can answer questions like "what domain is used by this ir.rule?" or "which search views include this field?" across any installed Odoo version and codebase. See the contact page for details.

Try ERPeek on your own Odoo module - ask questions, scaffold tests, and explore your codebase in plain language.

Get started free