Many2many Fields in Odoo
Many2many fields represent bidirectional relationships between two models. A product can have many tags, and a tag can be on many products. Odoo provides several widgets to render these relationships in forms, each suited to different use cases.
How Many2many Works Internally
Odoo creates a relation table (junction table) for each Many2many field. By default, the table name is model1_model2_rel:
tag_ids = fields.Many2many(
'project.tags', # comodel
'project_task_tags_rel', # relation table
'task_id', # column1 (this model)
'tag_id', # column2 (comodel)
string='Tags')
You can omit the relation table parameters and Odoo generates them automatically. Specify them explicitly when you need predictable table names for migrations or SQL queries.
Widget: many2many_tags (Default)
The most common widget renders related records as colored pill-shaped tags:
<field name="tag_ids" widget="many2many_tags"
options="{'color_field': 'color'}"/>
The color_field option maps to an Integer field (0-11) on the comodel that determines the tag color. Users can type to search and select tags, or create new ones inline.
Tag Options
<field name="tag_ids" widget="many2many_tags"
options="{
'color_field': 'color',
'no_create': true,
'no_create_edit': true,
'no_quick_edit': true
}"/>
no_create: hides the Create option in the dropdownno_create_edit: hides Create and Editno_quick_edit: disables inline editing of tag names
Widget: many2many_checkboxes
Renders each possible value as a checkbox. Best for small, fixed sets of options:
<field name="category_ids" widget="many2many_checkboxes"/>
This fetches all records from the comodel and displays them as a vertical list of checkboxes. Not suitable for large datasets since it loads everything at once.
Widget: many2many_binary
Specialized widget for file attachments stored via Many2many to ir.attachment:
attachment_ids = fields.Many2many(
'ir.attachment', string='Attachments')
<field name="attachment_ids" widget="many2many_binary"
string="Attach Files"/>
This renders a drag-and-drop file upload zone. Users can attach multiple files, preview images, and download existing attachments.
Widget: many2many (Inline List)
The default widget (without specifying widget) renders a Many2many as an inline list with Add and Remove buttons:
<field name="user_ids">
<list>
<field name="name"/>
<field name="email"/>
<field name="phone"/>
</list>
</field>
Users click Add a Line to open a selection dialog, or Remove to detach records (not delete them).
Widget: many2many_tags_avatar
Shows avatar images alongside tag names, commonly used for user assignments:
<field name="reviewer_ids" widget="many2many_tags_avatar"
options="{'no_create': true}"/>
Programmatic Many2many Operations
Odoo uses special command tuples for Many2many write operations:
# Add a record (link)
record.write({'tag_ids': [(4, tag_id)]})
# Remove a record (unlink, does not delete)
record.write({'tag_ids': [(3, tag_id)]})
# Replace all with a new set
record.write({'tag_ids': [(6, 0, [id1, id2, id3])]})
# Create and link in one step
record.write({'tag_ids': [(0, 0, {'name': 'New Tag'})]})
# Unlink all
record.write({'tag_ids': [(5, 0, 0)]})
The command format is (command_code, id_or_0, values_or_ids):
| Code | Action | Usage |
|---|---|---|
| 0 | Create and link | (0, 0, {vals}) |
| 1 | Update linked record | (1, id, {vals}) |
| 2 | Delete and unlink | (2, id, 0) |
| 3 | Unlink (keep record) | (3, id, 0) |
| 4 | Link existing | (4, id, 0) |
| 5 | Unlink all | (5, 0, 0) |
| 6 | Replace all | (6, 0, [ids]) |
Domain Filtering
Filter which records are available for selection:
<field name="allowed_user_ids" invisible="1"/>
<field name="reviewer_ids" widget="many2many_tags"
domain="[('id', 'in', allowed_user_ids)]"/>
Or use a static domain:
<field name="tag_ids" widget="many2many_tags"
domain="[('active', '=', True)]"/>
Performance Considerations
- many2many_checkboxes: loads all comodel records. Avoid for large models.
- many2many_tags: uses autocomplete search. Good for any size.
- Prefetching: accessing
record.tag_idstriggers a query to the relation table plus the comodel. Usemapped()for batch access. - Count without loading: use
search_countwith a domain on the relation rather thanlen(record.tag_ids).
Common Patterns
Allowed Values from Parent
class ProjectTask(models.Model):
_inherit = 'project.task'
allowed_tag_ids = fields.Many2many(
related='project_id.tag_ids')
tag_ids = fields.Many2many(
'project.tags',
domain="[('id', 'in', allowed_tag_ids)]")
Computed Many2many
related_partner_ids = fields.Many2many(
'res.partner', compute='_compute_related_partners',
store=True)
@api.depends('order_line.partner_id')
def _compute_related_partners(self):
for record in self:
record.related_partner_ids = \
record.order_line.mapped('partner_id')