The ORM — Part 2: Relational Fields & Domains

Series: Odoo 18 Development for Python Developers Target Audience: Python developers who are new to web development and want to learn Odoo Prerequisites: Lesson 1–3 completed, library.book model with basic fields


Table of Contents

Why Relational Fields Matter

In Lesson 3, we built a library.book model with fields like name, pages, and price. But a book doesn’t exist in isolation — it has an author, a publisher, and it belongs to categories.

In a database, these connections are called relationships. If you’ve worked with SQL, you know about foreign keys and join tables. Odoo’s ORM gives you the same power, but through Python fields.

The Three Types of Relationships

Let’s use the library as an example:

One Book has ONE Author           → Many2one   (book → author)
One Author has MANY Books         → One2many   (author → books)
One Book has MANY Tags,
  and One Tag has MANY Books      → Many2many  (book ↔ tags)

Or visually:

┌──────────────┐         ┌──────────────┐
│  library.book│ Many2one│library.author │
│              │────────►│              │
│ author_id    │         │ name         │
│ name         │◄────────│ book_ids     │
│ tag_ids      │ One2many│              │
└──────┬───────┘         └──────────────┘
       │
       │ Many2many
       │
┌──────▼───────┐
│ library.tag  │
│              │
│ name         │
│ book_ids     │
└──────────────┘

Let’s learn each one in detail.


Many2one Fields — The Foreign Key of Odoo

Many2one is the most common relational field. It creates a link from one record in your model to one record in another model.

The Concept

Think of it this way: Many books can have one author. So from the book’s perspective, it points to one author.

In database terms, this creates a foreign key column in the library_book table that references the library_author table.

Basic Syntax

# In library_app/models/book.py

class LibraryBook(models.Model):
    _name = 'library.book'
    _description = 'Library Book'

    name = fields.Char(string='Title', required=True)

    # Many2one: Each book has ONE author
    author_id = fields.Many2one(
        comodel_name='library.author',   # The model we're pointing to
        string='Author',                 # Label in the UI
    )

What’s happening step by step:

  1. fields.Many2one(...) — Tells Odoo this field links to another model
  2. comodel_name='library.author' — The target model (must exist or be defined in a dependency)
  3. In the database, this creates a column author_id of type INTEGER that stores the id of the related author record
  4. In the UI, this renders as a dropdown (or search-enabled select box) showing all authors

Naming Convention

# Convention: field_name ends with '_id' for Many2one fields
author_id = fields.Many2one('library.author', string='Author')         # ✅ Good
publisher_id = fields.Many2one('library.publisher', string='Publisher') # ✅ Good
category_id = fields.Many2one('library.category', string='Category')   # ✅ Good

# Not following convention (works, but confusing):
author = fields.Many2one('library.author', string='Author')            # ❌ Avoid

The _id suffix is a strong convention in Odoo. When you see author_id, you immediately know it’s a Many2one field.

Using Many2one in Python

# Create a book with an author
book = env['library.book'].create({
    'name': 'The Hobbit',
    'author_id': 1,          # Set by ID (the author's database id)
})

# Access the related record
print(book.author_id)         # Returns a recordset: library.author(1,)
print(book.author_id.name)    # 'J.R.R. Tolkien' — access the author's name directly
print(book.author_id.id)      # 1 — the author's database id

# You can chain through relations:
print(book.author_id.email)   # Access the author's email field

# Check if a Many2one is set
if book.author_id:             # ✅ Truthy if set, Falsy if empty
    print(f"Author: {book.author_id.name}")
else:
    print("No author assigned")

Key insight: When you access book.author_id, you don’t get an integer (the ID). You get a recordset — an Odoo object that represents the related author. This means you can directly access any field on the author without writing a separate query.

What Happens in the Database

-- Odoo creates this column:
ALTER TABLE library_book ADD COLUMN author_id INTEGER REFERENCES library_author(id);

-- When you set author_id = 1:
UPDATE library_book SET author_id = 1 WHERE id = 42;

-- When you access book.author_id.name, Odoo runs:
SELECT name FROM library_author WHERE id = 1;

You never write this SQL — the ORM does it all. But understanding what happens underneath helps you debug issues.

Common Many2one Attributes

author_id = fields.Many2one(
    comodel_name='library.author',
    string='Author',
    required=True,               # A book MUST have an author
    ondelete='restrict',         # What happens when the author is deleted?
    index=True,                  # Create an index (recommended for Many2one)
    default=lambda self: self.env.ref('library_app.default_author', raise_if_not_found=False),
    # Default to a specific author defined in XML data
)

The ondelete Parameter

This is crucial. It defines what happens to the book if its author is deleted:

Value Behavior When to Use
'set null' Set author_id to NULL (empty) Default. Safe choice — the book stays, just loses its author
'restrict' Block the deletion — show an error When the relation must always exist. “You can’t delete this author because books reference them”
'cascade' Delete the book too Dangerous! Only use when the child can’t exist without the parent
# Examples:
author_id = fields.Many2one('library.author', ondelete='set null')
# If the author is deleted → book.author_id becomes empty

author_id = fields.Many2one('library.author', ondelete='restrict')
# If someone tries to delete the author → ERROR: "Cannot delete, books reference this author"

author_id = fields.Many2one('library.author', ondelete='cascade')
# If the author is deleted → ALL their books are deleted too! ⚠️

Recommendation: Use 'restrict' for important relationships and 'set null' (the default) for optional relationships. Avoid 'cascade' unless you’re sure.


One2many Fields — The Reverse Relation

One2many is the inverse of Many2one. If a book has author_id pointing to an author, the author can have book_ids pointing back to all their books.

The Concept

One author has many books. From the author’s perspective, they can see all books that reference them.

Important: One2many does NOT create a column in the database. It’s a virtual field that queries the other table’s Many2one field.

Basic Syntax

# In library_app/models/author.py

class LibraryAuthor(models.Model):
    _name = 'library.author'
    _description = 'Library Author'

    name = fields.Char(string='Name', required=True)
    email = fields.Char(string='Email')

    # One2many: This author has MANY books
    book_ids = fields.One2many(
        comodel_name='library.book',       # The model that has the Many2one
        inverse_name='author_id',          # The Many2one field name in that model
        string='Books',
    )

What’s happening:

  1. comodel_name='library.book' — Look in the library.book model
  2. inverse_name='author_id' — Find all books where author_id points to this author
  3. No column is created in library_author — this field is “computed” by querying library_book

The Many2one ↔ One2many Pair

These two fields always work as a pair:

# In library.book:
author_id = fields.Many2one('library.author', string='Author')
#           ↕ These are connected ↕

# In library.author:
book_ids = fields.One2many('library.book', 'author_id', string='Books')

The Many2one field (author_id) is the real field — it creates a database column. The One2many field (book_ids) is the virtual reverse — it just queries the database using the Many2one.

Naming Convention

# Convention: field_name ends with '_ids' for One2many and Many2many fields
book_ids = fields.One2many(...)     # ✅ Good — plural with _ids
book_id = fields.One2many(...)      # ❌ Bad — singular, looks like Many2one
books = fields.One2many(...)        # ❌ Bad — no _ids suffix

Using One2many in Python

# Get an author
author = env['library.author'].browse(1)

# Access all their books
print(author.book_ids)              # Returns a recordset: library.book(1, 2, 3)
print(len(author.book_ids))         # 3 — the author has 3 books

# Iterate over the books
for book in author.book_ids:
    print(f"  - {book.name} ({book.pages} pages)")
# Output:
#   - The Hobbit (310 pages)
#   - The Lord of the Rings (1178 pages)
#   - The Silmarillion (365 pages)

# Filter the author's books
available_books = author.book_ids.filtered(lambda b: b.state == 'available')
print(f"Available: {len(available_books)}")

Adding and Removing One2many Records

When creating or updating records with One2many fields, Odoo uses a special command syntax:

# Creating an author WITH books (in one operation):
author = env['library.author'].create({
    'name': 'J.R.R. Tolkien',
    'book_ids': [
        (0, 0, {'name': 'The Hobbit', 'pages': 310}),
        (0, 0, {'name': 'The Lord of the Rings', 'pages': 1178}),
    ],
})
# (0, 0, {values}) means: CREATE a new book with these values

Here’s the full list of commands:

Command Syntax Meaning
Create (0, 0, {values}) Create a new record and link it
Update (1, id, {values}) Update an existing linked record
Delete (2, id, 0) Delete the record from the database
Unlink (3, id, 0) Remove the link (set foreign key to NULL)
Link (4, id, 0) Link an existing record
Unlink all (5, 0, 0) Remove all links
Replace (6, 0, [ids]) Replace all links with this list of IDs

The most commonly used commands:

# Add an existing book to the author
author.write({
    'book_ids': [(4, book_id, 0)],          # Link existing book
})

# Create a new book and add it
author.write({
    'book_ids': [(0, 0, {'name': 'New Book', 'pages': 200})],
})

# Remove a book from the author (unlink, don't delete)
author.write({
    'book_ids': [(3, book_id, 0)],          # Sets book.author_id = NULL
})

# Delete a book entirely
author.write({
    'book_ids': [(2, book_id, 0)],          # Deletes the book record
})

# Replace all books with a specific set
author.write({
    'book_ids': [(6, 0, [1, 2, 3])],       # Author now has exactly books 1, 2, 3
})

This command syntax looks strange, but you’ll use it often. The tuple format (command_number, id, value) is one of Odoo’s most distinctive patterns. Bookmark this table — you’ll come back to it.


Many2many Fields — Join Tables Made Easy

Many2many creates a relationship where many records on both sides can be linked to many records on the other side.

The Concept

A book can have many tags (Fiction, Fantasy, Classic), and a tag can apply to many books. Neither side “owns” the relationship — it’s symmetric.

In database terms, this creates a join table (also called a junction table or pivot table).

Basic Syntax

# In library_app/models/book.py

class LibraryBook(models.Model):
    _name = 'library.book'
    # ... other fields ...

    # Many2many: A book can have many tags, a tag can have many books
    tag_ids = fields.Many2many(
        comodel_name='library.tag',      # The other model
        string='Tags',
    )
# In library_app/models/tag.py

class LibraryTag(models.Model):
    _name = 'library.tag'
    _description = 'Library Tag'

    name = fields.Char(string='Tag Name', required=True)
    color = fields.Integer(string='Color Index')
    # 'color' is a special field used by the tag widget in the UI
    # It's an integer (0-11) that maps to predefined colors

What happens in the database:

Odoo automatically creates a join table:

-- Odoo creates this table automatically:
CREATE TABLE library_book_library_tag_rel (
    library_book_id  INTEGER REFERENCES library_book(id),
    library_tag_id   INTEGER REFERENCES library_tag(id),
    PRIMARY KEY (library_book_id, library_tag_id)
);

-- When you add tag 3 to book 1:
INSERT INTO library_book_library_tag_rel (library_book_id, library_tag_id) VALUES (1, 3);

You never create or manage this table — the ORM handles it automatically.

Using Many2many in Python

# Create some tags
fiction = env['library.tag'].create({'name': 'Fiction', 'color': 1})
fantasy = env['library.tag'].create({'name': 'Fantasy', 'color': 2})
classic = env['library.tag'].create({'name': 'Classic', 'color': 3})

# Add tags to a book
book = env['library.book'].browse(1)
book.write({
    'tag_ids': [(4, fiction.id, 0), (4, fantasy.id, 0)],
    # (4, id, 0) = link existing record
})

# Or replace all tags at once
book.write({
    'tag_ids': [(6, 0, [fiction.id, fantasy.id, classic.id])],
    # (6, 0, [ids]) = set exactly these tags
})

# Read the tags
print(book.tag_ids)                    # library.tag(1, 2, 3)
for tag in book.tag_ids:
    print(f"  Tag: {tag.name}")

# Check if a specific tag is assigned
if fiction in book.tag_ids:
    print("This book is fiction!")

# Remove a tag (without deleting it)
book.write({
    'tag_ids': [(3, classic.id, 0)],   # (3, id, 0) = unlink
})

The same command syntax from One2many applies to Many2many. The commands (0,0,{}), (3,id,0), (4,id,0), (5,0,0), and (6,0,[ids]) are the most commonly used.

Optional: Both Sides Can Have a Many2many

You can optionally add a Many2many on the tag side too:

# In library_app/models/tag.py

class LibraryTag(models.Model):
    _name = 'library.tag'
    _description = 'Library Tag'

    name = fields.Char(string='Tag Name', required=True)
    color = fields.Integer(string='Color Index')

    # Optional: see all books with this tag
    book_ids = fields.Many2many(
        comodel_name='library.book',
        string='Books',
    )

Wait — will this create TWO join tables? No! Odoo is smart enough to detect that both sides reference each other and uses the same join table. You can define Many2many on one side or both — it’s the same underlying data.


Relational Field Parameters Deep Dive

Many2one Parameters

author_id = fields.Many2one(
    comodel_name='library.author',     # Required: target model
    string='Author',                   # UI label
    ondelete='restrict',               # What happens when target is deleted
    index=True,                        # Database index (recommended)
    domain=[('active', '=', True)],    # Filter: only show active authors in dropdown
    context={'default_country': 'US'}, # Pass context when opening related form
    check_company=True,                # Multi-company: restrict to same company
)

One2many Parameters

book_ids = fields.One2many(
    comodel_name='library.book',       # Required: target model
    inverse_name='author_id',          # Required: the Many2one field in target model
    string='Books',                    # UI label
    domain=[('state', '!=', 'lost')],  # Only show non-lost books
    copy=True,                         # Copy child records when duplicating parent
    # Note: One2many has NO 'ondelete' — that's on the Many2one side
)

Many2many Parameters

# Simple version (Odoo auto-generates the join table):
tag_ids = fields.Many2many(
    comodel_name='library.tag',
    string='Tags',
)

# Explicit version (you control the join table name and column names):
tag_ids = fields.Many2many(
    comodel_name='library.tag',
    relation='library_book_tag_rel',       # Join table name
    column1='book_id',                     # Column for this model's ID
    column2='tag_id',                      # Column for the other model's ID
    string='Tags',
)

When to use the explicit version? Usually never — the auto-generated names work fine. But if you have two Many2many fields on the same model pointing to the same comodel, you need to specify different relation names to avoid conflicts:

# Two Many2many fields pointing to the same model → must specify different relations
favorite_tag_ids = fields.Many2many(
    'library.tag',
    relation='library_book_favorite_tags',   # Custom join table 1
    column1='book_id',
    column2='tag_id',
    string='Favorite Tags',
)

all_tag_ids = fields.Many2many(
    'library.tag',
    relation='library_book_all_tags',        # Custom join table 2
    column1='book_id',
    column2='tag_id',
    string='All Tags',
)

Summary: Relational Field Comparison

Feature Many2one One2many Many2many
Creates DB column INTEGER FK ❌ Virtual ❌ Join table
Direction This → One target Target → This (reverse) This ↔ Target
Requires partner field No Yes (inverse_name) No
Returns Single record (or empty) Recordset (0+) Recordset (0+)
Naming convention _id suffix _ids suffix _ids suffix
ondelete ✅ Yes ❌ N/A ❌ N/A
UI widget Dropdown Inline list/table Tag chips

Delegation Inheritance with _inherits (Brief Intro)

Before moving to domains, let’s briefly mention _inherits — Odoo’s delegation inheritance. This is not something you’ll use often, but knowing it exists prevents confusion.

What is _inherits?

_inherits lets one model embed another model. The child model gets all the fields of the parent, but they’re stored in the parent’s table via a Many2one link.

class LibraryMember(models.Model):
    _name = 'library.member'
    _inherits = {'res.partner': 'partner_id'}
    # This means: a library.member IS a res.partner
    # The member "delegates" to the partner for fields like name, email, phone

    partner_id = fields.Many2one(
        'res.partner',
        string='Related Partner',
        required=True,
        ondelete='cascade',
    )

    # Additional fields specific to library members
    membership_number = fields.Char(string='Membership Number')
    member_since = fields.Date(string='Member Since')

What this does:

  • Creates a library_member table with partner_id, membership_number, member_since
  • A library.member record automatically creates a res.partner record
  • You can access member.name, member.email, member.phone — these are actually stored in res.partner, but accessed transparently through delegation

When to use it: When your model is a specialized version of an existing model and you want to reuse all its fields. For example, a library member is a contact (partner) with extra fields.

Don’t confuse with _inherit: _inherit (without the ‘s’) is Odoo’s extension inheritance — much more common and covered in Lesson 9. _inherits (with the ‘s’) is delegation and is used rarely.

We won’t use _inherits in our hands-on for now, but we’ll revisit it in Lesson 9 when we cover all inheritance patterns.


Domains: Odoo’s Filter Language

Now let’s learn one of the most distinctive features of Odoo: domains. If you’ve used Django’s filter() or SQLAlchemy’s filter(), domains are the Odoo equivalent — but with a very different syntax.

What is a Domain?

A domain is a list of conditions used to filter records. It’s like a WHERE clause in SQL, but written in a specific format.

# SQL:
# SELECT * FROM library_book WHERE pages > 200 AND state = 'available'

# Odoo domain:
domain = [('pages', '>', 200), ('state', '=', 'available')]
books = env['library.book'].search(domain)

The Tuple Format

Each condition in a domain is a tuple with three elements:

(field_name, operator, value)

# Examples:
('name', '=', 'The Hobbit')           # name equals 'The Hobbit'
('pages', '>', 200)                    # pages greater than 200
('state', 'in', ['available', 'borrowed'])  # state is 'available' OR 'borrowed'
('author_id.name', 'like', 'Tolkien') # author's name contains 'Tolkien'

All Available Operators

Operator Meaning Example
= Equals ('state', '=', 'draft')
!= Not equals ('state', '!=', 'lost')
> Greater than ('pages', '>', 200)
>= Greater than or equal ('pages', '>=', 200)
< Less than ('price', '<', 50.0)
<= Less than or equal ('price', '<=', 50.0)
like Contains (case-sensitive) ('name', 'like', 'hobbit')
ilike Contains (case-insensitive) ('name', 'ilike', 'hobbit')
not like Does not contain (case-sensitive) ('name', 'not like', 'test')
not ilike Does not contain (case-insensitive) ('name', 'not ilike', 'test')
=like Pattern match with % and _ ('isbn', '=like', '978-%')
=ilike Pattern match (case-insensitive) ('name', '=ilike', 'the %')
in Value in list ('state', 'in', ['draft', 'available'])
not in Value not in list ('state', 'not in', ['lost'])
child_of Is a descendant of (hierarchical) ('category_id', 'child_of', 5)
parent_of Is an ancestor of (hierarchical) ('category_id', 'parent_of', 5)

Searching Through Relations (Dotted Paths)

One of the most powerful features of domains is the ability to traverse relationships using dot notation:

# Find books by a specific author name
# (traverses book → author_id → name)
domain = [('author_id.name', '=', 'J.R.R. Tolkien')]

# Find books by authors from the UK
# (traverses book → author_id → country_id → name)
domain = [('author_id.country_id.name', '=', 'United Kingdom')]

# Find books with a specific tag
# (traverses book → tag_ids → name)
domain = [('tag_ids.name', 'in', ['Fiction', 'Fantasy'])]

How deep can you go? There's no strict limit, but each dot adds a database JOIN. Going more than 2-3 levels deep can impact performance.

Empty Domain = All Records

# An empty domain returns ALL records
all_books = env['library.book'].search([])

# This is equivalent to: SELECT * FROM library_book (with security filters applied)

Writing Complex Domains with &, |, !

By default, when you write multiple conditions in a domain, they're combined with AND:

# Implicit AND: pages > 200 AND state = 'available'
domain = [('pages', '>', 200), ('state', '=', 'available')]

But what if you need OR logic? Or NOT? That's where domain operators come in.

Polish Notation (Prefix Notation)

Odoo domains use Polish notation (prefix notation) for logical operators. This means the operator comes before the operands, not between them.

If you've never seen Polish notation, here's the comparison:

# Infix notation (what you're used to):
# pages > 200 AND state = 'available'

# Polish notation (Odoo domains):
# AND (pages > 200) (state = 'available')
# Written as:
['&', ('pages', '>', 200), ('state', '=', 'available')]

The & (AND) Operator

# Explicit AND (same as implicit, but explicit):
domain = ['&', ('pages', '>', 200), ('state', '=', 'available')]

# You usually don't write '&' explicitly because AND is the default.
# These are equivalent:
domain = [('pages', '>', 200), ('state', '=', 'available')]          # Implicit AND
domain = ['&', ('pages', '>', 200), ('state', '=', 'available')]     # Explicit AND

The | (OR) Operator

# Books that are available OR borrowed
domain = ['|', ('state', '=', 'available'), ('state', '=', 'borrowed')]

# This is equivalent to SQL:
# WHERE state = 'available' OR state = 'borrowed'

# Note: For this specific case, 'in' is simpler:
domain = [('state', 'in', ['available', 'borrowed'])]

The ! (NOT) Operator

# Books that are NOT in draft state
domain = [('state', '!=', 'draft')]         # Simple: use != operator

# Using the ! operator (negates the next condition):
domain = ['!', ('state', '=', 'draft')]     # Same result, different syntax

Combining Operators: Where It Gets Tricky

The key rule: each & and | operator applies to the next TWO conditions.

# (state = 'available') OR (state = 'borrowed')
domain = ['|', ('state', '=', 'available'), ('state', '=', 'borrowed')]
#          |   ──────── first ──────────   ──────── second ─────────
#          └── applies to these two conditions

# (pages > 200) AND ((state = 'available') OR (state = 'borrowed'))
domain = [
    ('pages', '>', 200),
    '|',
        ('state', '=', 'available'),
        ('state', '=', 'borrowed'),
]
# Remember: implicit AND between ('pages', '>', 200) and the '|' block
# The '|' applies to the next two conditions after it

# SQL equivalent:
# WHERE pages > 200 AND (state = 'available' OR state = 'borrowed')

More Complex Examples

# (A AND B AND C) — three conditions with AND (implicit):
domain = [
    ('pages', '>', 100),
    ('state', '=', 'available'),
    ('price', '<', 30),
]
# SQL: WHERE pages > 100 AND state = 'available' AND price < 30

# (A OR B OR C) — three conditions with OR:
domain = [
    '|', '|',
        ('state', '=', 'available'),
        ('state', '=', 'borrowed'),
        ('state', '=', 'draft'),
]
# Wait, why two '|' operators?
# Because each '|' only joins TWO things:
#   First '|': joins (second '|' result) with ('state', '=', 'draft')
#   Second '|': joins ('state', '=', 'available') with ('state', '=', 'borrowed')
# Result: available OR borrowed OR draft
#
# But again, for this case, 'in' is much simpler:
domain = [('state', 'in', ['available', 'borrowed', 'draft'])]

# (A AND B) OR (C AND D):
domain = [
    '|',
        '&', ('pages', '>', 200), ('state', '=', 'available'),
        '&', ('pages', '<', 100), ('price', '<', 10),
]
# SQL: WHERE (pages > 200 AND state = 'available') OR (pages < 100 AND price < 10)

Domain Debugging Tip

If you're confused by a domain, read it like a tree:

domain = ['|', '&', ('A'), ('B'), ('C')]

# Tree structure:
#        |
#       / \
#      &   C
#     / \
#    A   B
#
# Result: (A AND B) OR C

Each & or | consumes the next two items (which can themselves be operators consuming more items).

Common Domain Patterns

# Books by a specific author
[('author_id', '=', author_id)]

# Books without an author
[('author_id', '=', False)]

# Books that have an author (any author)
[('author_id', '!=', False)]

# Books published this year
[('date_published', '>=', '2024-01-01'), ('date_published', '<=', '2024-12-31')]

# Search by partial name (case-insensitive)
[('name', 'ilike', 'hobbit')]

# Active books that are available or borrowed
[('active', '=', True), '|', ('state', '=', 'available'), ('state', '=', 'borrowed')]

# Books with more than 3 tags (count-based — requires computed field, shown in Lesson 5)
# This can't be done with a simple domain — you'd need a computed field + store=True

Using Domains in Fields, Actions, and Security Rules

Domains aren't just for search() in Python. They appear everywhere in Odoo:

Domain on Many2one Fields

# Only allow selecting active authors in the dropdown
author_id = fields.Many2one(
    'library.author',
    domain=[('active', '=', True)],
    # The dropdown will only show active authors
)

# Dynamic domain (using current record values):
# In XML views, you can use dynamic domains that reference other fields
# <field name="author_id" domain="[('country_id', '=', country_id)]"/>
# This shows only authors from the same country as the book

Domain on One2many Fields

# Only show available books in the author's book list
book_ids = fields.One2many(
    'library.book', 'author_id',
    domain=[('state', '=', 'available')],
)

Domain in Window Actions (XML)

<!-- Show only available books when this menu is clicked -->
<record id="library_book_available_action" model="ir.actions.act_window">
    <field name="name">Available Books</field>
    <field name="res_model">library.book</field>
    <field name="view_mode">list,form</field>
    <field name="domain">[('state', '=', 'available')]</field>
    <!-- This pre-filters the list view -->
</record>

Domain in Search Views (Default Filters)

<!-- In the search view, define a quick filter button -->
<filter name="available" string="Available"
        domain="[('state', '=', 'available')]"/>

<filter name="this_year" string="Published This Year"
        domain="[('date_published', '>=', '2024-01-01')]"/>

Domain in Security Rules (Record Rules)

<!-- Users can only see books from their own company -->
<record id="library_book_company_rule" model="ir.rule">
    <field name="name">Books: company rule</field>
    <field name="model_id" ref="model_library_book"/>
    <field name="domain_force">[('company_id', '=', company_id)]</field>
    <!-- 'company_id' here refers to the current user's company -->
</record>

Domains are truly Odoo's universal filter language. Learning to write them fluently will make you productive in every part of Odoo development.


Hands-on: Add Author, Publisher, and Tags to library.book

Let's put everything together. We'll create new models and add relational fields to our library.book model.

Step 1: Create the Author Model

Create the file library_app/models/author.py:

# library_app/models/author.py

from odoo import models, fields


class LibraryAuthor(models.Model):
    """An author who writes books."""

    _name = 'library.author'
    _description = 'Library Author'
    _order = 'name ASC'

    # ---------------------------
    # Fields
    # ---------------------------
    name = fields.Char(
        string='Name',
        required=True,
        index=True,
    )

    email = fields.Char(string='Email')

    biography = fields.Text(
        string='Biography',
        help='A short biography of the author',
    )

    date_of_birth = fields.Date(string='Date of Birth')

    nationality = fields.Char(string='Nationality')

    active = fields.Boolean(string='Active', default=True)

    # ---------------------------
    # Relational Fields
    # ---------------------------
    book_ids = fields.One2many(
        comodel_name='library.book',     # Target model
        inverse_name='author_id',        # The Many2one field in library.book
        string='Books',
        help='Books written by this author',
    )

Step 2: Create the Tag Model

Create the file library_app/models/tag.py:

# library_app/models/tag.py

from odoo import models, fields


class LibraryTag(models.Model):
    """A tag/category for classifying books (e.g., Fiction, Science, History)."""

    _name = 'library.tag'
    _description = 'Library Tag'
    _order = 'name ASC'

    name = fields.Char(
        string='Tag Name',
        required=True,
        index=True,
    )

    color = fields.Integer(
        string='Color Index',
        default=0,
        help='Color index for the tag badge (0-11)',
        # In the UI, Odoo uses this number to assign a color to tag badges.
        # 0 = no color, 1 = red, 2 = orange, 3 = yellow, ... up to 11.
    )

    description = fields.Text(string='Description')

    active = fields.Boolean(string='Active', default=True)

Step 3: Create the Publisher Model

Create the file library_app/models/publisher.py:

# library_app/models/publisher.py

from odoo import models, fields


class LibraryPublisher(models.Model):
    """A publishing house that publishes books."""

    _name = 'library.publisher'
    _description = 'Library Publisher'
    _order = 'name ASC'

    name = fields.Char(
        string='Publisher Name',
        required=True,
        index=True,
    )

    website = fields.Char(string='Website')

    country = fields.Char(string='Country')

    active = fields.Boolean(string='Active', default=True)

    # One2many: all books published by this publisher
    book_ids = fields.One2many(
        comodel_name='library.book',
        inverse_name='publisher_id',
        string='Published Books',
    )

Step 4: Add Relational Fields to the Book Model

Update library_app/models/book.py — add these fields after the existing fields:

# library_app/models/book.py
# ADD these fields to the existing LibraryBook class (don't replace the file!)

    # ---------------------------
    # Relational Fields
    # ---------------------------
    author_id = fields.Many2one(
        comodel_name='library.author',
        string='Author',
        ondelete='restrict',
        # 'restrict' prevents deleting an author who has books
        index=True,
        help='The main author of the book',
    )

    publisher_id = fields.Many2one(
        comodel_name='library.publisher',
        string='Publisher',
        ondelete='set null',
        # 'set null' means: if the publisher is deleted, just clear this field
        index=True,
    )

    tag_ids = fields.Many2many(
        comodel_name='library.tag',
        string='Tags',
        help='Classify the book with tags like Fiction, Science, etc.',
    )

Step 5: Update __init__.py

Update library_app/models/__init__.py to import the new files:

# library_app/models/__init__.py

from . import book
from . import author
from . import tag
from . import publisher

Step 6: Upgrade the Module

# Docker method:
docker compose exec odoo odoo -d odoo18dev -u library_app --stop-after-init
docker compose restart odoo

# Source method:
python odoo/odoo-bin -c odoo.conf -d odoo18dev -u library_app

Step 7: Verify in the Odoo Shell

# Open the shell
# Docker: docker compose exec odoo odoo shell -d odoo18dev

# ------ Create Authors ------
tolkien = env['library.author'].create({
    'name': 'J.R.R. Tolkien',
    'nationality': 'British',
    'date_of_birth': '1892-01-03',
    'biography': 'English writer and philologist, best known for The Hobbit and The Lord of the Rings.',
})

rowling = env['library.author'].create({
    'name': 'J.K. Rowling',
    'nationality': 'British',
    'date_of_birth': '1965-07-31',
})

print(f"Created authors: {tolkien.name} (ID: {tolkien.id}), {rowling.name} (ID: {rowling.id})")

# ------ Create Tags ------
fiction = env['library.tag'].create({'name': 'Fiction', 'color': 1})
fantasy = env['library.tag'].create({'name': 'Fantasy', 'color': 2})
classic = env['library.tag'].create({'name': 'Classic', 'color': 3})
children = env['library.tag'].create({'name': "Children's", 'color': 4})

# ------ Create a Publisher ------
publisher = env['library.publisher'].create({
    'name': 'Allen & Unwin',
    'country': 'United Kingdom',
})

# ------ Create Books with Relations ------
hobbit = env['library.book'].create({
    'name': 'The Hobbit',
    'isbn': '978-0547928227',
    'pages': 310,
    'price': 14.99,
    'author_id': tolkien.id,
    'publisher_id': publisher.id,
    'tag_ids': [(6, 0, [fiction.id, fantasy.id, classic.id])],
    # (6, 0, [ids]) = set exactly these tags
    'state': 'available',
    'date_published': '1937-09-21',
})

print(f"\nCreated: {hobbit.name}")
print(f"  Author: {hobbit.author_id.name}")
print(f"  Publisher: {hobbit.publisher_id.name}")
print(f"  Tags: {[tag.name for tag in hobbit.tag_ids]}")

# ------ Navigate Relations ------
# From author → books (One2many)
print(f"\nBooks by {tolkien.name}:")
for book in tolkien.book_ids:
    print(f"  - {book.name}")

# ------ Use Domains ------
# Find all fantasy books
fantasy_books = env['library.book'].search([('tag_ids.name', '=', 'Fantasy')])
print(f"\nFantasy books: {[b.name for b in fantasy_books]}")

# Find books by British authors
british_books = env['library.book'].search([('author_id.nationality', '=', 'British')])
print(f"Books by British authors: {[b.name for b in british_books]}")

# Find available books with more than 200 pages
big_available = env['library.book'].search([
    ('pages', '>', 200),
    ('state', '=', 'available'),
])
print(f"Available books > 200 pages: {[b.name for b in big_available]}")

# Don't forget to commit!
env.cr.commit()

Summary & What's Next

Key Takeaways

  1. Many2one creates a foreign key column. One book → one author. Named with _id suffix.
  2. One2many is the virtual reverse of Many2one. One author → many books. Named with _ids suffix. Needs inverse_name.
  3. Many2many creates a join table. Many books ↔ many tags. Named with _ids suffix.
  4. Command syntax for writing to One2many and Many2many: (0,0,{values}) create, (4,id,0) link, (3,id,0) unlink, (6,0,[ids]) replace.
  5. Domains are Odoo's filter language: a list of (field, operator, value) tuples.
  6. Polish notation for complex domains: '|' for OR, '&' for AND, '!' for NOT — each applies to the next two conditions.
  7. Dot notation lets you filter through relations: ('author_id.name', '=', 'Tolkien').
  8. Domains are everywhere: search(), field definitions, window actions, search views, and security rules.

Relational Cheat Sheet

# Many2one — "this book has one author"
author_id = fields.Many2one('library.author', ondelete='restrict')

# One2many — "this author has many books"
book_ids = fields.One2many('library.book', 'author_id')

# Many2many — "this book has many tags"
tag_ids = fields.Many2many('library.tag')

Domain Cheat Sheet

[('field', '=', value)]                          # Equals
[('field', '!=', False)]                         # Is set (not empty)
[('field', 'ilike', 'text')]                     # Contains (case-insensitive)
[('field', 'in', ['a', 'b'])]                   # In list
[('related.field', '=', value)]                  # Through relation
['|', ('field1', '=', 'x'), ('field2', '=', 'y')]  # OR

What's Next?

In Lesson 5: The ORM — Part 3: CRUD, Recordsets & Business Logic, we'll learn how to:

  • Use create(), read(), write(), unlink() for database operations
  • Work with Recordsets — filtering, mapping, sorting
  • Write computed fields with @api.depends
  • Add @api.onchange methods for dynamic UI behavior
  • Add constraints with @api.constrains and _sql_constraints
  • Use self.env, sudo(), and with_context()

This is where your models start coming alive with business logic!


Exercises: 1. Create 3 more books in the Odoo Shell, each with different authors and tags. Practice using the (4, id, 0) and (6, 0, [ids]) command syntax. 2. Write domains to find: - All books without an author: [('author_id', '=', False)] - Books published before the year 2000: try it yourself - Books that are either "Fiction" OR "Classic": try it yourself - Books by authors born after 1960 with more than 300 pages: try it yourself (hint: use dotted paths and implicit AND) 3. Test the ondelete behavior: - Try to delete an author who has books (with ondelete='restrict'). What error do you get? - Change ondelete to 'set null', upgrade, and try again. What happens to the book? 4. Create a new model library.category with a parent_id field (Many2one pointing to itself — a self-referential relationship). This creates a tree structure. Can you create categories like "Fiction → Fantasy → Epic Fantasy"? ``python class LibraryCategory(models.Model): _name = 'library.category' _description = 'Library Category' name = fields.Char(required=True) parent_id = fields.Many2one('library.category', string='Parent Category') child_ids = fields.One2many('library.category', 'parent_id', string='Subcategories') ``


Previous lesson: Lesson 3 — The ORM: Models & Fields Next lesson: Lesson 5 — The ORM: CRUD, Recordsets & Business Logic

Leave a Reply

Your email address will not be published. Required fields are marked *