Skip to content

Odoo Selection Field Patterns: Dynamic Choices, Extension, and Best Practices

DeployMonkey Team · March 23, 2026 11 min read

Selection Fields Overview

Selection fields store a single value from a predefined list of options. They map to a VARCHAR column in PostgreSQL and are displayed as dropdowns, radio buttons, or statusbars depending on the widget. Selection fields are fundamental to Odoo's workflow system.

Basic Selection Definition

class SupportTicket(models.Model):
    _name = 'support.ticket'

    priority = fields.Selection([
        ('0', 'Low'),
        ('1', 'Normal'),
        ('2', 'High'),
        ('3', 'Urgent'),
    ], string='Priority', default='1', required=True)

    state = fields.Selection([
        ('new', 'New'),
        ('in_progress', 'In Progress'),
        ('done', 'Done'),
        ('cancelled', 'Cancelled'),
    ], string='Status', default='new',
       required=True, tracking=True, copy=False)

Each option is a tuple of (value, label). The value is stored in the database; the label is displayed to users and is translatable.

Extending Selection Fields (selection_add)

One of the most powerful patterns in Odoo is extending existing Selection fields from inherited models without modifying the original:

class SupportTicketExtended(models.Model):
    _inherit = 'support.ticket'

    state = fields.Selection(
        selection_add=[
            ('waiting', 'Waiting for Customer'),
            ('escalated', 'Escalated'),
        ],
        ondelete={
            'waiting': 'set default',
            'escalated': 'cascade',
        })

The selection_add parameter appends new options. The ondelete parameter specifies what happens to records with these values if the extending module is uninstalled:

  • 'set default': reset to the field's default value
  • 'cascade': delete the records
  • 'set null': set the field to False (only if not required)

Controlling Insertion Order

You can control where new options appear in the list:

state = fields.Selection(
    selection_add=[
        ('review', 'Under Review'),  # inserted after existing options
    ])

# To insert between existing options, reference the option it should follow:
state = fields.Selection(
    selection_add=[
        ('review', 'Under Review'),   # added after last
    ])

In practice, Odoo appends selection_add values at the end of the list. To insert between specific values, you need to redefine the entire selection.

Dynamic Selection from Method

Use a method to generate selection options dynamically:

class DynamicModel(models.Model):
    _name = 'dynamic.model'

    report_type = fields.Selection(
        selection='_get_report_types',
        string='Report Type')

    @api.model
    def _get_report_types(self):
        types = [('sales', 'Sales Report'),
                 ('inventory', 'Inventory Report')]
        if self.env.user.has_group('account.group_account_manager'):
            types.append(('financial', 'Financial Report'))
        return types

The method is called each time the field is rendered, allowing options to change based on user, config, or installed modules.

Related Selection Fields

Access a selection from a related record:

class OrderLine(models.Model):
    _name = 'order.line'

    order_state = fields.Selection(
        related='order_id.state', string='Order Status',
        store=True, readonly=True)

Widget Options

Default Dropdown

<field name="priority"/>

Radio Buttons

<field name="priority" widget="radio"/>
<field name="priority" widget="radio"
       options="{'horizontal': true}"/>

Statusbar

<field name="state" widget="statusbar"
       statusbar_visible="new,in_progress,done"/>

Badge

<field name="state" widget="badge"
       decoration-success="state == 'done'"
       decoration-danger="state == 'cancelled'"
       decoration-info="state == 'new'"/>

Selection in Search Views

<search>
  <field name="state"/>
  <filter name="filter_new" string="New"
          domain="[('state', '=', 'new')]"/>
  <filter name="filter_active" string="Active"
          domain="[('state', 'not in', ['done', 'cancelled'])]"/>
</search>

Conditional Logic Based on Selection

# Python: onchange
@api.onchange('priority')
def _onchange_priority(self):
    if self.priority == '3':  # Urgent
        return {'warning': {
            'title': 'Urgent Priority',
            'message': 'This will notify all managers.',
        }}

# Python: computed field
@api.depends('state')
def _compute_is_editable(self):
    for record in self:
        record.is_editable = record.state not in ('done', 'cancelled')

# XML: conditional visibility
<field name="resolution" invisible="state != 'done'"/>

Getting the Display Label

To get the human-readable label of a selection value:

# Get label for current value
label = dict(self._fields['state'].selection).get(self.state)

# Or using the fields_get method
selection_labels = dict(
    self.fields_get(['state'])['state']['selection'])
label = selection_labels.get(self.state)

Migration Considerations

When adding or removing selection values:

  • Adding values is safe; existing records are unaffected
  • Removing values requires migrating existing records first
  • Renaming values (changing the stored string) requires a migration script
  • The ondelete parameter on selection_add handles cleanup when modules are uninstalled
# Migration script example
def migrate(cr, version):
    cr.execute("""
        UPDATE support_ticket
        SET state = 'done'
        WHERE state = 'completed'
    """)

Best Practices

  • Use short, lowercase values (e.g., 'draft', not 'Draft' or 'DRAFT')
  • Always set required=True for state/status fields
  • Use tracking=True for status fields to log changes in chatter
  • Set copy=False on state fields so duplicated records start fresh
  • Prefer Selection over Many2one for fixed, small option sets since there is no join overhead
  • Use selection_add for extensibility instead of redefining the entire selection