The ORM — Part 3: CRUD, Recordsets & Business Logic

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


Table of Contents

Recordsets: The Core Data Structure

Before learning CRUD operations, you need to understand recordsets — the fundamental data structure you’ll work with in every line of Odoo Python code.

What is a Recordset?

A recordset is an ordered collection of records from the same model. It can contain zero, one, or many records.

# Empty recordset (0 records)
empty = env['library.book'].browse([])
print(len(empty))         # 0
print(bool(empty))        # False

# Single record (1 record)
one_book = env['library.book'].browse(1)
print(len(one_book))      # 1
print(bool(one_book))     # True

# Multiple records
many_books = env['library.book'].search([])
print(len(many_books))    # e.g., 5

A Recordset is NOT a List

This is a common source of confusion. A recordset looks like a list but behaves differently:

books = env['library.book'].search([])

# ✅ You CAN iterate over it (like a list):
for book in books:
    print(book.name)

# ✅ You CAN check length:
print(len(books))

# ✅ You CAN check if it's empty:
if books:
    print("Found books!")

# ❌ You CANNOT index it like a list:
# books[0]  → This returns a recordset with one record, NOT a record!
first = books[0]
print(type(first))   # Still a recordset! library.book(1,)

# ✅ You CAN slice it:
first_three = books[:3]   # Returns a recordset with up to 3 records

# ✅ You CAN use set-like operations:
books_a = env['library.book'].browse([1, 2, 3])
books_b = env['library.book'].browse([2, 3, 4])

union = books_a | books_b          # Records in A or B: {1, 2, 3, 4}
intersection = books_a & books_b   # Records in both A and B: {2, 3}
difference = books_a - books_b     # Records in A but not B: {1}

The self in Model Methods is a Recordset

When you write methods on your model, self is always a recordset:

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

    def do_something(self):
        # 'self' is a recordset — it might contain 1 or many records!
        # Always be aware of this.

        for book in self:
            # Now 'book' is a single-record recordset
            print(book.name)

This is important because a user might select multiple records in the UI and click an action button. Your method receives all selected records as self.

ensure_one() — When You Need Exactly One Record

def get_book_details(self):
    self.ensure_one()
    # Raises ValueError if self contains 0 or 2+ records
    # After this line, you KNOW self has exactly 1 record

    return f"{self.name} by {self.author_id.name}"

Use ensure_one() when your method logic only makes sense for a single record (like opening a specific form or computing a result for one record).


CRUD stands for Create, Read, Update, Delete — the four basic database operations.

create() — Create New Records

# Create a single record
book = env['library.book'].create({
    'name': 'Dune',
    'isbn': '978-0441013593',
    'pages': 412,
    'price': 16.99,
    'state': 'available',
    'author_id': 3,                                        # Set Many2one by ID
    'tag_ids': [(6, 0, [1, 2])],                           # Set Many2many
})
print(f"Created: {book.name} (ID: {book.id})")

# Create multiple records at once (more efficient)
books = env['library.book'].create([
    {'name': 'Dune Messiah', 'pages': 256, 'author_id': 3},
    {'name': 'Children of Dune', 'pages': 408, 'author_id': 3},
    {'name': 'God Emperor of Dune', 'pages': 423, 'author_id': 3},
])
print(f"Created {len(books)} books")
# books is a recordset containing all 3 new records

Key points about create():

  • Returns a recordset of the created record(s)
  • If you pass a list of dicts, it creates multiple records in one batch (faster)
  • Fields not included in the dict get their default values
  • Required fields must be included (or have defaults), otherwise an error is raised

read() — Read Field Values (Raw)

# Read specific fields (returns a list of dicts)
data = env['library.book'].browse(1).read(['name', 'pages', 'author_id'])
print(data)
# [{'id': 1, 'name': 'The Hobbit', 'pages': 310, 'author_id': (3, 'J.R.R. Tolkien')}]
#                                                    ↑ Many2one returns (id, display_name)

# Read multiple records
data = env['library.book'].browse([1, 2, 3]).read(['name', 'pages'])
# Returns a list of 3 dicts

In practice, you rarely use read() directly. It’s a low-level method mostly used by the web client internally. Instead, you access fields directly on the recordset:

# ✅ Preferred way (direct field access):
book = env['library.book'].browse(1)
print(book.name)      # 'The Hobbit'
print(book.pages)     # 310

# ❌ Unnecessary (using read):
data = book.read(['name', 'pages'])
print(data[0]['name'])  # Same result, but more complex

write() — Update Existing Records

# Update a single record
book = env['library.book'].browse(1)
book.write({
    'pages': 320,
    'state': 'borrowed',
})

# Shortcut: direct field assignment (same as write for single fields)
book.pages = 320
book.state = 'borrowed'
# Under the hood, this calls write() too

# Update multiple records at once
books = env['library.book'].search([('state', '=', 'draft')])
books.write({'state': 'available'})
# Sets ALL draft books to 'available' in one SQL query — very efficient!

Key points about write():

  • Can update one or many records at once
  • Updates only the fields you specify — other fields are untouched
  • Direct assignment (book.name = 'New Title') is a shortcut for write({'name': 'New Title'})
  • Writing to multiple records is a single SQL UPDATE statement — much faster than looping

Common mistake — writing in a loop:

# ❌ SLOW: one SQL UPDATE per record
for book in books:
    book.write({'state': 'available'})

# ✅ FAST: one SQL UPDATE for all records
books.write({'state': 'available'})
# Delete a single record
book = env['library.book'].browse(1)
book.unlink()

# Delete multiple records
old_books = env['library.book'].search([('state', '=', 'lost')])
old_books.unlink()

# Check before deleting
if old_books:
    old_books.unlink()
    print(f"Deleted {len(old_books)} lost books")

Warning: unlink() permanently deletes records from the database. There is no trash can or undo. In most cases, archiving (setting active=False) is preferred over deleting.

# ✅ Better approach: archive instead of delete
old_books.write({'active': False})
# Records are hidden from searches but still in the database
# Can be restored later by setting active=True

CRUD Summary

Operation Method SQL Equivalent Returns
Create create({values}) INSERT INTO New recordset
Read read(['field1', 'field2']) SELECT List of dicts
Read (preferred) record.field_name SELECT Field value
Update write({values}) UPDATE True
Update (shortcut) record.field = value UPDATE N/A
Delete unlink() DELETE FROM True

Searching: search(), search_count(), search_read()

search() — Find Records by Domain

# Find all available books
books = env['library.book'].search([('state', '=', 'available')])

# With additional parameters:
books = env['library.book'].search(
    [('state', '=', 'available')],   # domain — filter conditions
    order='name ASC',                # sort order (overrides _order)
    limit=10,                        # max number of records to return
    offset=20,                       # skip the first 20 records (for pagination)
)

Parameters:

Parameter Default Description
domain [] (required) Filter conditions (see Lesson 4)
order Model’s _order Sort order, e.g., 'name ASC, id DESC'
limit None (all) Maximum records to return
offset 0 Records to skip (for pagination)

Returns: A recordset of matching records.

search_count() — Count Without Loading

# Count how many books are available
count = env['library.book'].search_count([('state', '=', 'available')])
print(f"Available books: {count}")

# This is MUCH more efficient than:
# len(env['library.book'].search([('state', '=', 'available')]))
# Because search_count() uses SQL COUNT(*) — it doesn't load any records into memory

Always use search_count() when you only need the count. Loading thousands of records just to count them wastes memory and time.

search_read() — Search + Read in One Step

# Search and read in one step (returns list of dicts)
data = env['library.book'].search_read(
    domain=[('state', '=', 'available')],
    fields=['name', 'pages', 'author_id'],
    limit=5,
    order='name ASC',
)
print(data)
# [
#   {'id': 1, 'name': 'The Hobbit', 'pages': 310, 'author_id': (3, 'J.R.R. Tolkien')},
#   {'id': 2, 'name': 'Dune', 'pages': 412, 'author_id': (5, 'Frank Herbert')},
#   ...
# ]

search_read() combines search() + read() into a single call. It’s slightly more efficient because it avoids an extra round trip. The web client uses search_read() extensively.

When to use each:

Need Method
Work with records in Python (access fields, call methods) search()
Just count records search_count()
Get raw data as dicts (for API responses, exports) search_read()

Browsing: browse() and When to Use It

browse() — Get Records by ID

# Get a single record by ID
book = env['library.book'].browse(1)
print(book.name)    # 'The Hobbit'

# Get multiple records by IDs
books = env['library.book'].browse([1, 2, 3])
for book in books:
    print(book.name)

browse() vs search():

Method Input Output Use When
search(domain) Filter conditions Matching recordset You don’t know the IDs
browse(ids) Known IDs Recordset of those IDs You already have the IDs

Important: browse() does not check if the IDs exist. It creates a recordset that will lazily load data when you access fields:

# This does NOT hit the database yet:
book = env['library.book'].browse(999)

# This DOES hit the database (and might fail if ID 999 doesn't exist):
print(book.name)    # MissingError if record doesn't exist!

# Safe way to check:
book = env['library.book'].browse(999)
if book.exists():
    print(book.name)
else:
    print("Book not found")

exists() — Check if Records Still Exist

# Check if a record still exists in the database
book = env['library.book'].browse(1)
if book.exists():
    print(f"{book.name} exists!")

# Useful after potential deletions
books = env['library.book'].browse([1, 2, 3, 999])
existing = books.exists()    # Returns recordset of only existing records
print(len(existing))         # 3 (record 999 doesn't exist)

Recordset Operations: Filtering, Mapping, Sorted

Odoo recordsets have built-in methods for common collection operations — similar to Python’s filter(), map(), and sorted().

filtered() — Filter Records

books = env['library.book'].search([])

# Filter to only available books
available = books.filtered(lambda b: b.state == 'available')
print(f"Available: {len(available)}")

# Filter by a Boolean field (shortcut — pass field name as string)
active_books = books.filtered('active')
# Equivalent to: books.filtered(lambda b: b.active)

# Complex filter
expensive_fantasy = books.filtered(
    lambda b: b.price > 20 and 'Fantasy' in b.tag_ids.mapped('name')
)

filtered() vs search():

Method Runs on Performance Use When
search() Database (SQL) ✅ Fast for large datasets Fetching records from DB
filtered() Python (memory) ❌ Slower for large sets Filtering already-loaded records

Rule of thumb: Use search() to get records from the database. Use filtered() to further filter records you already have in memory.

mapped() — Extract or Transform Values

books = env['library.book'].search([])

# Get a list of book names
names = books.mapped('name')
print(names)    # ['The Hobbit', 'Dune', 'Children of Dune', ...]

# Get a list of page counts
page_counts = books.mapped('pages')
print(page_counts)    # [310, 412, 408, ...]

# Navigate through relations
author_names = books.mapped('author_id.name')
print(author_names)   # ['J.R.R. Tolkien', 'Frank Herbert', ...]

# Get all unique tags across all books (returns a recordset for relational fields)
all_tags = books.mapped('tag_ids')
print(all_tags)       # library.tag(1, 2, 3, 4) — deduplicated!

# Use a lambda for custom transformation
titles_upper = books.mapped(lambda b: b.name.upper())
print(titles_upper)   # ['THE HOBBIT', 'DUNE', ...]

Key behavior: When mapped() is used with a relational field, it returns a recordset (deduplicated). When used with a non-relational field, it returns a Python list.

sorted() — Sort Records

books = env['library.book'].search([])

# Sort by name (ascending)
by_name = books.sorted('name')

# Sort by name (descending)
by_name_desc = books.sorted('name', reverse=True)

# Sort by a custom key
by_pages = books.sorted(key=lambda b: b.pages, reverse=True)
# Longest books first

# Sort by multiple criteria using a tuple key
by_state_then_name = books.sorted(key=lambda b: (b.state, b.name))

Chaining Operations

You can chain these operations together — similar to how you’d chain filter() and map() in Python:

# Get names of all available books with more than 300 pages, sorted by name
result = (
    env['library.book']
    .search([])                                        # Get all books
    .filtered(lambda b: b.state == 'available')        # Only available
    .filtered(lambda b: b.pages > 300)                 # Over 300 pages
    .sorted('name')                                    # Sort alphabetically
    .mapped('name')                                    # Extract names
)
print(result)   # ['Children of Dune', 'Dune', 'The Hobbit']

But for this specific case, using search() with a domain is more efficient:

# ✅ Better: let the database do the filtering
result = env['library.book'].search(
    [('state', '=', 'available'), ('pages', '>', 300)],
    order='name ASC',
).mapped('name')

The Environment: self.env

The environment (self.env) is the gateway to everything in Odoo. It holds the current user, database cursor, context, and registry.

What’s Inside self.env?

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

    def print_env_info(self):
        # The current user
        print(f"User: {self.env.user.name}")          # e.g., 'Administrator'
        print(f"User ID: {self.env.uid}")              # e.g., 2

        # The current company
        print(f"Company: {self.env.company.name}")     # e.g., 'My Company'

        # The context (a dict of contextual values)
        print(f"Context: {self.env.context}")           # e.g., {'lang': 'en_US', 'tz': 'UTC'}
        print(f"Language: {self.env.context.get('lang')}")

        # The database cursor (for raw SQL — rarely needed)
        self.env.cr  # The cursor object

        # Access any model
        authors = self.env['library.author'].search([])
        partners = self.env['res.partner'].search([], limit=5)

Accessing Other Models via self.env

The most common use of self.env is to access other models:

def some_business_logic(self):
    self.ensure_one()

    # Access the author model
    AuthorModel = self.env['library.author']

    # Create a new author
    new_author = AuthorModel.create({'name': 'New Author'})

    # Search for authors
    british_authors = AuthorModel.search([('nationality', '=', 'British')])

    # Access a specific XML record by its External ID
    default_tag = self.env.ref('library_app.tag_fiction')
    # 'library_app.tag_fiction' is the XML ID defined in your data files
    # We'll create these in Lesson 10

self.env.ref() — Get Records by XML ID

# Get a record by its XML ID (External ID)
admin_user = self.env.ref('base.user_admin')
print(admin_user.name)     # 'Administrator'

# Safe version (returns False instead of raising an error)
record = self.env.ref('library_app.nonexistent', raise_if_not_found=False)
if record:
    print(record.name)

XML IDs are used in data files and are very useful for referencing specific records. We’ll use them extensively starting from Lesson 10.


sudo(), with_user(), with_context(), with_company()

These methods create a new environment with modified properties. They don’t change the current environment — they return a new one.

sudo() — Bypass Security Rules

def some_admin_action(self):
    # Normal: respects the current user's security rules
    books = self.env['library.book'].search([])
    # If the user has limited access, they see fewer books

    # sudo(): bypasses ALL security rules (acts as superuser)
    all_books = self.env['library.book'].sudo().search([])
    # Returns ALL books, regardless of the user's permissions

    # Common use: create records in models the current user can't normally access
    self.env['ir.config_parameter'].sudo().set_param(
        'library.max_borrow_days', '14'
    )

When to use sudo():

  • ✅ When business logic needs to access data across security boundaries
  • ✅ When creating system records (like log entries or config values)
  • ❌ Never use it as a lazy workaround for broken security rules
  • ❌ Never use it to display data to the user (they’d see data they shouldn’t)

Security warning: sudo() is powerful. Every time you use it, ask yourself: “Is this necessary? Am I exposing data the user shouldn’t see?”

with_user() — Switch User

# Execute as a specific user
other_user = self.env['res.users'].browse(5)
books_as_other = self.env['library.book'].with_user(other_user).search([])
# Returns books visible to user ID 5, applying THEIR security rules

Useful for testing permissions or running actions “as” another user.

with_context() — Pass Extra Context

# Add values to the context
books = self.env['library.book'].with_context(active_test=False).search([])
# 'active_test=False' tells search() to include archived records

# Set default values for new records
AuthorModel = self.env['library.author'].with_context(
    default_nationality='British',
)
new_author = AuthorModel.create({'name': 'New Author'})
# new_author.nationality is 'British' (from context)

# Pass custom values for your business logic
self.with_context(skip_validation=True).some_method()
# In some_method:
#   if self.env.context.get('skip_validation'):
#       ...

with_company() — Switch Company

# Execute in the context of a different company
other_company = self.env['res.company'].browse(2)
result = self.with_company(other_company).some_method()

Used in multi-company setups where logic depends on the current company.

Method Chaining

You can chain these methods:

# Switch to superuser AND change context
records = (
    self.env['library.book']
    .sudo()
    .with_context(active_test=False)
    .search([])
)

Computed Fields and @api.depends

Computed fields are one of the most powerful features in Odoo. Instead of storing a value directly, a computed field calculates its value from other fields.

Basic Computed Field

from odoo import models, fields, api

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

    pages = fields.Integer(string='Pages')
    price = fields.Float(string='Price')

    # Computed field: price per page
    price_per_page = fields.Float(
        string='Price per Page',
        compute='_compute_price_per_page',   # Name of the compute method
        digits=(10, 4),
    )

    @api.depends('price', 'pages')
    def _compute_price_per_page(self):
        """Calculate the price per page."""
        for book in self:
            # IMPORTANT: always loop over self — it can contain multiple records
            if book.pages > 0:
                book.price_per_page = book.price / book.pages
            else:
                book.price_per_page = 0.0

What’s happening step by step:

  1. compute='_compute_price_per_page' — Tells Odoo which method to call to get the value
  2. @api.depends('price', 'pages') — Tells Odoo: “Recalculate this field whenever price or pages changes”
  3. Inside the method, we loop over self (because it can contain multiple records)
  4. We assign the computed value directly to the field: book.price_per_page = ...

@api.depends — The Dependency Declaration

@api.depends('price', 'pages')
def _compute_price_per_page(self):
    ...

The @api.depends decorator is required for computed fields. It tells Odoo:

  • Which fields trigger recalculation
  • Which fields to watch for changes in the UI (real-time updates)
  • Which fields to track for stored computed fields (database triggers)
# Depend on fields in the same model
@api.depends('price', 'pages')

# Depend on fields in related models (via relations)
@api.depends('author_id.nationality')

# Depend on One2many/Many2many fields (ANY change in related records)
@api.depends('tag_ids')
@api.depends('tag_ids.name')  # Specifically when a tag's name changes

Stored vs Non-Stored Computed Fields

By default, computed fields are not stored — they’re calculated on the fly every time they’re read. You can change this with store=True:

# Non-stored (default): calculated every time it's read
price_per_page = fields.Float(
    compute='_compute_price_per_page',
    store=False,    # This is the default
)

# Stored: calculated and saved to the database
price_per_page = fields.Float(
    compute='_compute_price_per_page',
    store=True,     # Value is written to the database
)

Comparison:

Feature store=False (default) store=True
Database column ❌ No ✅ Yes
Calculated when Every time it’s read When dependencies change
Can be searched/filtered ❌ No ✅ Yes
Can be sorted by ❌ No ✅ Yes
Can be used in domains ❌ No ✅ Yes
Performance (read) Slower (calculates each time) Faster (reads from DB)
Performance (write) No overhead Recalculates on dependency change

When to use store=True:

  • When you need to search or filter by the computed value
  • When the field is displayed in list views (calculated for every row otherwise)
  • When the computation is expensive

When to keep store=False:

  • When the value depends on the current time or context (changes every request)
  • When dependencies change very frequently (constant recalculation)
  • When the value is only shown in form views (calculated once per record)

Common Computed Field Patterns

# Count related records
book_count = fields.Integer(
    string='Number of Books',
    compute='_compute_book_count',
)

@api.depends('book_ids')
def _compute_book_count(self):
    for author in self:
        author.book_count = len(author.book_ids)

# --------------------------------------------------------

# Display name from related record
author_name = fields.Char(
    string='Author Name',
    related='author_id.name',    # Shortcut for simple computed fields!
    readonly=True,
)
# 'related' fields are a special type of computed field that simply
# fetches a value from a related record. No compute method needed!

# --------------------------------------------------------

# Boolean based on a condition
is_long_book = fields.Boolean(
    string='Long Book?',
    compute='_compute_is_long_book',
    store=True,
)

@api.depends('pages')
def _compute_is_long_book(self):
    for book in self:
        book.is_long_book = book.pages > 500

For the common case of “just show a field from a related record,” Odoo provides the related shortcut:

# Instead of writing a full compute method:
author_nationality = fields.Char(
    compute='_compute_author_nationality',
)

@api.depends('author_id.nationality')
def _compute_author_nationality(self):
    for book in self:
        book.author_nationality = book.author_id.nationality

# You can simply write:
author_nationality = fields.Char(
    related='author_id.nationality',
    string='Author Nationality',
    store=True,        # Optional: store in the database
    readonly=True,     # Usually True for related fields
)
# Odoo automatically handles the depends and compute!

Inverse Methods for Writable Computed Fields

By default, computed fields are readonly — the user can see the value but can’t edit it. But sometimes you want a computed field to be editable. That’s where inverse methods come in.

The Problem

# This field shows the full name, but the user can't edit it:
full_title = fields.Char(compute='_compute_full_title')

@api.depends('name', 'isbn')
def _compute_full_title(self):
    for book in self:
        if book.isbn:
            book.full_title = f"{book.name} (ISBN: {book.isbn})"
        else:
            book.full_title = book.name

The Solution: inverse

full_title = fields.Char(
    compute='_compute_full_title',
    inverse='_inverse_full_title',     # Method to call when the user EDITS this field
    store=True,
)

@api.depends('name', 'isbn')
def _compute_full_title(self):
    for book in self:
        if book.isbn:
            book.full_title = f"{book.name} (ISBN: {book.isbn})"
        else:
            book.full_title = book.name

def _inverse_full_title(self):
    """When the user edits full_title, extract the name from it."""
    for book in self:
        if ' (ISBN:' in book.full_title:
            book.name = book.full_title.split(' (ISBN:')[0]
        else:
            book.name = book.full_title

How it works:

  1. Reading: Odoo calls _compute_full_title to calculate the display value
  2. Writing: When the user edits full_title in the UI, Odoo calls _inverse_full_title to update the underlying fields

Inverse methods are rare. Most computed fields are read-only. Only add an inverse when it makes clear logical sense to “reverse” the computation.


Onchange Methods with @api.onchange

Onchange methods provide live feedback in the UI. When a user changes a field value, the onchange method runs immediately (before saving) and can update other fields or show warnings.

Basic Onchange

@api.onchange('pages')
def _onchange_pages(self):
    """Warn the user if the book has too many pages."""
    if self.pages > 2000:
        return {
            'warning': {
                'title': 'Large Book!',
                'message': f'This book has {self.pages} pages. Are you sure this is correct?',
            }
        }

What happens in the UI:

  1. User types 3000 in the “Pages” field
  2. As soon as the user moves to another field (on blur), the onchange triggers
  3. A warning popup appears with the message
  4. The user can dismiss it and keep the value, or go back and fix it

Onchange That Updates Other Fields

@api.onchange('author_id')
def _onchange_author_id(self):
    """When the author changes, auto-fill some fields."""
    if self.author_id:
        # Auto-fill based on the author's information
        if not self.notes:
            self.notes = f"Written by {self.author_id.name}"

        # You can also set domain filters dynamically
        # (return a domain for another field)
        return {
            'domain': {
                'publisher_id': [('country', '=', self.author_id.nationality)],
                # Only show publishers from the author's country
            }
        }

Onchange vs Computed: When to Use Which?

Feature @api.onchange @api.depends (computed)
Runs when User edits field in UI Field value changes (UI or code)
Runs in Python code ❌ No ✅ Yes
Can show warnings ✅ Yes ❌ No
Can change other fields ✅ Yes (in-memory) Only the computed field
Persists changes Only if user saves Automatic
Use for UI convenience, warnings Derived values, calculations

General guidance:

  • If the value is always calculated from other fields → use computed
  • If you want to suggest a default or show a warning when the user changes something → use onchange
  • If in doubt → prefer computed fields (they work in code too, not just the UI)

Important: Onchange Only Runs in the UI

# This onchange ONLY triggers when a user changes 'pages' in the browser.
# It does NOT trigger in Python code:
book.write({'pages': 3000})   # ← _onchange_pages is NOT called!

# If you need logic that runs both in UI and code, use computed fields
# or override write() (covered in Lesson 9: Inheritance)

Constraints: @api.constrains and _sql_constraints

Constraints prevent invalid data from being saved. Odoo has two types:

_sql_constraints — Database-Level Constraints

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

    _sql_constraints = [
        (
            'unique_isbn',                            # Constraint name (must be unique)
            'UNIQUE(isbn)',                            # SQL constraint
            'The ISBN must be unique! Another book already has this ISBN.',  # Error message
        ),
        (
            'positive_pages',                          # Constraint name
            'CHECK(pages >= 0)',                       # SQL constraint
            'The number of pages cannot be negative!', # Error message
        ),
        (
            'positive_price',
            'CHECK(price >= 0)',
            'The price cannot be negative!',
        ),
    ]

What’s happening:

  • _sql_constraints is a class attribute — a list of tuples
  • Each tuple: (name, sql_definition, error_message)
  • These create actual PostgreSQL constraints on the table
  • They’re enforced by the database itself — impossible to bypass, even with sudo()

Common SQL constraints:

_sql_constraints = [
    # Unique value
    ('unique_isbn', 'UNIQUE(isbn)', 'ISBN must be unique'),

    # Unique combination of fields
    ('unique_name_author', 'UNIQUE(name, author_id)', 'This author already has a book with this title'),

    # Check condition
    ('positive_pages', 'CHECK(pages >= 0)', 'Pages must be positive'),

    # Check range
    ('rating_range', 'CHECK(rating >= 0 AND rating <= 5)', 'Rating must be between 0 and 5'),
]

Limitation: SQL constraints can only check values in the same record. They can’t check across records or relational fields. For that, use Python constraints.

@api.constrains — Python-Level Constraints

from odoo import models, fields, api
from odoo.exceptions import ValidationError

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

    @api.constrains('isbn')
    def _check_isbn(self):
        """Validate that ISBN has the correct format."""
        for book in self:
            if book.isbn and len(book.isbn.replace('-', '')) not in (10, 13):
                raise ValidationError(
                    f"ISBN '{book.isbn}' is invalid. "
                    "An ISBN must be 10 or 13 digits long."
                )

    @api.constrains('date_published')
    def _check_date_published(self):
        """Ensure published date is not in the future."""
        for book in self:
            if book.date_published and book.date_published > fields.Date.today():
                raise ValidationError(
                    "The published date cannot be in the future!"
                )

    @api.constrains('rating')
    def _check_rating(self):
        """Ensure rating is between 0 and 5."""
        for book in self:
            if book.rating < 0 or book.rating > 5:
                raise ValidationError(
                    f"Rating must be between 0 and 5. Got: {book.rating}"
                )

How Python constraints work:

  1. @api.constrains('isbn') — This method is called after create() or write() modifies the isbn field
  2. The method must check the data and raise ValidationError if something is wrong
  3. If a ValidationError is raised, the entire transaction is rolled back — nothing is saved
  4. Always loop over self (multiple records may be created/written at once)

SQL vs Python Constraints: When to Use Which

Feature _sql_constraints @api.constrains
Enforced by Database (PostgreSQL) Python (Odoo ORM)
Can be bypassed ❌ Never (even raw SQL) ⚠️ Only if constraint decorator is wrong
Can check relations ❌ No (same record only) ✅ Yes (any Python logic)
Custom error messages Basic (static string) ✅ Rich (dynamic, with details)
Performance ✅ Very fast Slightly slower
Best for Unique, positive, range checks Complex business rules

Best practice: Use SQL constraints for simple rules (unique, positive, range). Use Python constraints for complex validation logic.


Hands-on: Add Computed Fields, Constraints, and Business Methods

Let’s upgrade our library.book model with everything we’ve learned in this lesson.

Update models/book.py

Add these sections to the existing LibraryBook class:

# library_app/models/book.py
# ADD these to the existing class — don't replace the entire file!

from odoo import models, fields, api
from odoo.exceptions import ValidationError


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

    # ... (keep all existing fields from Lessons 3 and 4) ...

    # ---------------------------
    # SQL Constraints
    # ---------------------------
    _sql_constraints = [
        ('unique_isbn',
         'UNIQUE(isbn)',
         'The ISBN must be unique! Another book already has this ISBN.'),
        ('positive_pages',
         'CHECK(pages >= 0)',
         'The number of pages cannot be negative!'),
        ('positive_price',
         'CHECK(price >= 0)',
         'The price cannot be negative!'),
    ]

    # ---------------------------
    # Computed Fields
    # ---------------------------
    author_nationality = fields.Char(
        related='author_id.nationality',
        string='Author Nationality',
        store=True,
        readonly=True,
    )

    tag_count = fields.Integer(
        string='Number of Tags',
        compute='_compute_tag_count',
    )

    is_long_book = fields.Boolean(
        string='Long Book (500+ pages)',
        compute='_compute_is_long_book',
        store=True,
    )

    price_per_page = fields.Float(
        string='Price per Page',
        compute='_compute_price_per_page',
        digits=(10, 4),
        store=True,
    )

    @api.depends('tag_ids')
    def _compute_tag_count(self):
        for book in self:
            book.tag_count = len(book.tag_ids)

    @api.depends('pages')
    def _compute_is_long_book(self):
        for book in self:
            book.is_long_book = book.pages > 500

    @api.depends('price', 'pages')
    def _compute_price_per_page(self):
        for book in self:
            if book.pages > 0:
                book.price_per_page = book.price / book.pages
            else:
                book.price_per_page = 0.0

    # ---------------------------
    # Python Constraints
    # ---------------------------
    @api.constrains('isbn')
    def _check_isbn(self):
        for book in self:
            if book.isbn:
                digits = book.isbn.replace('-', '').replace(' ', '')
                if len(digits) not in (10, 13):
                    raise ValidationError(
                        f"ISBN '{book.isbn}' is invalid. "
                        "An ISBN must be 10 or 13 digits long (ignoring hyphens)."
                    )

    @api.constrains('date_published')
    def _check_date_published(self):
        for book in self:
            if book.date_published and book.date_published > fields.Date.today():
                raise ValidationError(
                    "The published date cannot be in the future!"
                )

    @api.constrains('rating')
    def _check_rating(self):
        for book in self:
            if book.rating and (book.rating < 0 or book.rating > 5):
                raise ValidationError(
                    f"Rating must be between 0 and 5. Got: {book.rating}"
                )

    # ---------------------------
    # Onchange Methods
    # ---------------------------
    @api.onchange('pages')
    def _onchange_pages(self):
        if self.pages and self.pages > 2000:
            return {
                'warning': {
                    'title': 'Very Large Book',
                    'message': (
                        f'This book has {self.pages} pages. '
                        'Please double-check this is correct.'
                    ),
                }
            }

    @api.onchange('author_id')
    def _onchange_author_id(self):
        if self.author_id and not self.notes:
            self.notes = f"Book by {self.author_id.name}"

    # ---------------------------
    # Business Logic Methods
    # ---------------------------
    def action_mark_available(self):
        """Mark selected books as available."""
        for book in self:
            if book.state == 'draft':
                book.state = 'available'

    def action_mark_borrowed(self):
        """Mark selected books as borrowed."""
        for book in self:
            if book.state == 'available':
                book.state = 'borrowed'

    def action_mark_returned(self):
        """Mark selected books as returned (available again)."""
        for book in self:
            if book.state == 'borrowed':
                book.state = 'available'

    def action_mark_lost(self):
        """Mark selected books as lost."""
        for book in self:
            if book.state in ('available', 'borrowed'):
                book.state = 'lost'

Update models/author.py — Add Book Count

# In library_app/models/author.py — add this computed field and method:

    book_count = fields.Integer(
        string='Number of Books',
        compute='_compute_book_count',
    )

    @api.depends('book_ids')
    def _compute_book_count(self):
        for author in self:
            author.book_count = len(author.book_ids)

Upgrade and Test

# Upgrade the module
docker compose exec odoo odoo -d odoo18dev -u library_app --stop-after-init
docker compose restart odoo

Test in the Odoo Shell:

# Test computed fields
book = env['library.book'].search([('name', '=', 'The Hobbit')], limit=1)
if book:
    print(f"Title: {book.name}")
    print(f"Price per page: {book.price_per_page:.4f}")
    print(f"Is long book: {book.is_long_book}")
    print(f"Tag count: {book.tag_count}")
    print(f"Author nationality: {book.author_nationality}")

# Test constraints
try:
    env['library.book'].create({
        'name': 'Bad Book',
        'isbn': '123',        # Too short — should raise ValidationError
        'state': 'draft',
    })
except Exception as e:
    print(f"Caught error: {e}")

# Test business methods
book = env['library.book'].search([('state', '=', 'draft')], limit=1)
if book:
    print(f"Before: {book.state}")    # 'draft'
    book.action_mark_available()
    print(f"After: {book.state}")     # 'available'
    book.action_mark_borrowed()
    print(f"After: {book.state}")     # 'borrowed'
    book.action_mark_returned()
    print(f"After: {book.state}")     # 'available'

env.cr.commit()

Summary & What’s Next

Key Takeaways

  1. Recordsets are ordered collections of records — not lists. Use ensure_one(), filtered(), mapped(), sorted().
  2. CRUD operations: create(), read(), write(), unlink(). Prefer direct field access over read(). Prefer write() on multiple records over looping.
  3. Search methods: search() for recordsets, search_count() for counts only, search_read() for raw dicts.
  4. self.env is your gateway: access other models, the current user, company, and context.
  5. sudo() bypasses security — use sparingly. with_context() passes extra data.
  6. Computed fields calculate values automatically. Use @api.depends to declare dependencies. Use store=True for searchable fields. Use related for simple relation shortcuts.
  7. Onchange methods provide live UI feedback — warnings and auto-filling. They only run in the UI, not in Python code.
  8. Constraints prevent invalid data: _sql_constraints for simple database rules, @api.constrains for complex Python validation. Always raise ValidationError.

ORM Cheat Sheet

# CRUD
record = Model.create({values})
record.write({values})
record.unlink()

# Search
records = Model.search([domain], order='...', limit=N)
count = Model.search_count([domain])

# Recordset operations
records.filtered(lambda r: r.field > 10)
records.mapped('field_name')
records.sorted('field_name')

# Environment
self.env['model.name']
self.env.user / self.env.uid / self.env.company
self.env.ref('module.xml_id')

# Context switches
records.sudo()
records.with_context(key=value)

# Computed field pattern
field = fields.Type(compute='_compute_field', store=True)

@api.depends('dependency')
def _compute_field(self):
    for rec in self:
        rec.field = ...

What’s Next?

In Lesson 6: Views — XML-Based UI (Part 1: Basic Views), we’ll finally give our module a user interface! You’ll learn:

  • Form views for editing books
  • List views for browsing books
  • Search views with filters
  • Window actions and menu items

After five lessons of backend Python, it’s time to make our module visible to users.


Exercises: 1. Add a computed field days_since_added to library.book that calculates how many days ago the book was added to the library. (Hint: subtract date_added from fields.Datetime.now() and use .days) 2. Add a Python constraint that prevents creating two books with the same title by the same author. (Hint: use self.search_count() inside the constraint) 3. Practice recordset operations in the Odoo Shell: – Get all books, then use filtered() to find books with price > 15 – Use mapped() to get a list of all author names – Use sorted() to sort books by pages descending – Chain all three: filter → sort → map 4. Write a method action_toggle_active that toggles the active field on selected books (if active, make inactive; if inactive, make active). 5. Add a related field on library.book that shows the author’s email. Verify it updates when you change the author.


Previous lesson: Lesson 4 — Relational Fields & Domains Next lesson: Lesson 6 — Views: XML-Based UI

Leave a Reply

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