All posts
Developer8 min read

Odoo search view customization: filters, group-by, and domain modifiers for developers

Search views are the underused half of Odoo UI development. Learn how to define custom filters, dynamic group-by options, and domain modifiers that make data exploration fast for end users without a single line of Python.

Search views sit at the junction of XML declaration and domain logic. Most developers know they exist but treat them as boilerplate - paste a tag, define a couple of elements, and move on. The result is that end users get raw column filters and no meaningful groupings, then open support tickets asking how to find things.

This post covers the full search view API: filter elements, group-by, default filters, search defaults, domain modifiers, and the operator overrides that distinguish a polished search experience from a placeholder.

Search view structure#

A search view lives in a with type="search". It is linked to an action via the action's search_view_id or automatically discovered by Odoo when the action lacks an explicit one.

xml
<record id="view_sale_order_search" model="ir.ui.view">
  <field name="name">sale.order.search</field>
  <field name="model">sale.order</field>
  <field name="arch" type="xml">
    <search string="Quotations">
      <field name="name" string="Order" filter_domain="[('name','ilike',self)]"/>
      <field name="partner_id" operator="child_of"/>
      <separator/>
      <filter name="my_orders" string="My Orders"
              domain="[('user_id','=',uid)]"/>
      <filter name="late" string="Late"
              domain="[('commitment_date','<', context_today().strftime('%Y-%m-%d'))]"/>
      <group expand="0" string="Group By">
        <filter name="group_partner" string="Customer" context="{'group_by':'partner_id'}"/>
        <filter name="group_month" string="Order Month" context="{'group_by':'date_order:month'}"/>
      </group>
    </search>
  </field>
</record>

Field elements#

adds a keyword search box that applies to the named field. The default operator depends on the field type: ilike for char/text, = for many2one, integer, date.

Override with operator:

  • operator="child_of" - searches the named field and all its children in the hierarchy (useful for partner_id to match parent + child companies).
  • operator="ilike" - always case-insensitive partial match even on m2o fields.
  • operator="=" - exact match.

Override the applied domain entirely with filter_domain. The special value self refers to what the user typed:

xml
<field name="product_id"
       filter_domain="[
         '|',
         ('product_id.name', 'ilike', self),
         ('product_id.default_code', 'ilike', self)
       ]"/>

This lets a single search box match product name or internal reference - a common UX requirement.

Add string to override the placeholder label. Add domain to pre-filter the autocomplete dropdown on m2o fields (does not affect the domain applied to the list, only the suggestion list):

xml
<field name="product_id" domain="[('sale_ok','=',True)]"/>

Filter elements#

is a toggle: when active, it ANDs its domain into the query. Multiple active filters in the same separator block are ANDed; filters in different blocks are ORed.

xml
<filter name="draft" string="Quotation" domain="[('state','=','draft')]"/>
<separator/>
<filter name="my_orders" string="My Orders" domain="[('user_id','=',uid)]"/>

A user who enables both "Quotation" and "My Orders" gets: state=draft AND user_id=uid. If the separator were absent, they get: state=draft OR user_id=uid.

Dynamic domain expressions#

Odoo evaluates filter domains in a context where several names are available:

NameValue
uidCurrent user ID
context_today()datetime.date for today (server timezone)
datetimePython datetime module
relativedeltadateutil.relativedelta
timePython time module

Examples:

xml
<filter name="this_week" string="This Week"
        domain="[
          ('date','>=',
           (context_today() - relativedelta(days=context_today().weekday())).strftime('%Y-%m-%d')),
          ('date','<',
           (context_today() + relativedelta(days=7-context_today().weekday())).strftime('%Y-%m-%d'))
        ]"/>

Date arithmetic works but becomes verbose. For simple "today", "last 7 days", "this month" patterns, this is fine. For complex fiscal-period logic, use a computed field or a stored date and filter on that instead.

Group-by elements#

Group-by filters use the context attribute instead of domain. The context key is group_by and the value is the field name, optionally with a date granularity suffix.

xml
<group expand="0" string="Group By">
  <filter name="group_partner" string="Customer"
          context="{'group_by':'partner_id'}"/>
  <filter name="group_salesperson" string="Salesperson"
          context="{'group_by':'user_id'}"/>
  <filter name="group_month" string="Order Month"
          context="{'group_by':'date_order:month'}"/>
  <filter name="group_week" string="Order Week"
          context="{'group_by':'date_order:week'}"/>
</group>

Date granularity options: year, quarter, month, week, day.

Wrap filters in a element to put them in the "Group By" dropdown. expand="1" shows the group expanded by default; expand="0" collapses it. Groups can be nested - a second produces a second dropdown header.

Multiple group-by filters can be active simultaneously. The ORM applies them in order: first by partner, then by month, produces a two-level grouping.

Search defaults#

Use search_default_ keys in the action context to activate filters automatically when the view loads:

xml
<record id="action_sale_order" model="ir.actions.act_window">
  <field name="context">{'search_default_my_orders': 1}</field>
</record>

The key format is search_default_ + the filter's name attribute. Value 1 activates it; 0 or omission leaves it inactive.

For group-by defaults, the same convention applies:

xml
{'search_default_group_partner': 1}

This is how Odoo ships many of its built-in list views pre-grouped by salesperson or month.

Passing search defaults from Python actions#

When opening an action programmatically from a button or wizard, pass search defaults in context:

python
return {
    'type': 'ir.actions.act_window',
    'res_model': 'sale.order',
    'view_mode': 'list,form',
    'context': {
        'search_default_partner_id': self.partner_id.id,
        'search_default_my_orders': 1,
    },
}

search_default_partner_id with an integer value applies a domain filter that matches that specific partner - this works on elements as well as elements when the field name matches.

Invisible and conditional filters#

The invisible attribute on a hides it from the dropdown when the expression evaluates to true. The expression uses the same context variables as domain:

xml
<filter name="my_orders" string="My Orders"
        domain="[('user_id','=',uid)]"
        invisible="not uid"/>

This is rarely needed for basic search views but useful in inherited views where a filter only makes sense in certain contexts.

Extending a search view via inheritance#

Inherit with inherit_id and use XPath or positional placement:

xml
<record id="view_sale_order_search_custom" model="ir.ui.view">
  <field name="name">sale.order.search.custom</field>
  <field name="model">sale.order</field>
  <field name="inherit_id" ref="sale.view_sales_order_filter"/>
  <field name="arch" type="xml">
    <filter name="my_orders" position="after">
      <filter name="confirmed_today" string="Confirmed Today"
              domain="[
                ('state','=','sale'),
                ('date_order','>=',context_today().strftime('%Y-%m-%d 00:00:00'))
              ]"/>
    </filter>
    <group expand="0" string="Group By" position="inside">
      <filter name="group_warehouse" string="Warehouse"
              context="{'group_by':'warehouse_id'}"/>
    </group>
  </field>
</record>

Use position="inside" to append inside an existing group element. Use position="after" / position="before" for siblings. Use position="replace" to overwrite - rarely correct for search views since it removes other filters in the replaced node.

Favorite searches#

Odoo stores user-saved searches as ir.filters records. From the developer perspective, the important thing is that saved searches serialize the active domain, context (group-by), and order into the record. Naming conflicts between a user's saved "My Orders" and a view-level filter named "my_orders" are harmless - they are independent objects.

Programmatically creating a default favorite for all users:

python
env['ir.filters'].create({
    'name': 'Open Orders',
    'model_id': 'sale.order',
    'domain': "[('state', 'in', ['draft','sent','sale'])]",
    'context': "{}",
    'is_default': True,
    'user_id': False,  # False = shared across all users
})

user_id=False makes it a global default. is_default=True activates it when the view loads if the user has not set their own default for that model.

Common mistakes#

Duplicated filter names. Odoo silently ignores the second filter with the same name in a view. If a filter inherited from a parent module has name="my_orders" and your extension adds another name="my_orders", the search default will activate the first one only.

Wrong context for group-by. Using domain instead of context on a group-by filter does nothing - the filter renders but applying it has no effect.

Missing separator between exclusive filters. Two mutually exclusive state filters ("Draft" and "Confirmed") without a separator will OR when both are enabled - producing a union instead of only showing one. Use a separator if OR is undesirable.

Dynamic domains with Python syntax errors. Odoo evaluates filter domains at read time with Python eval(). A syntax error in the domain string raises an obscure 500 error on the list view. Test with odoo-bin shell and env['ir.filters'].search([]) to catch these early.

For domain notation syntax, see the Odoo domain notation guide. For view inheritance patterns, see the view inheritance and XPath guide.

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

Get started free