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.bookmodel with relational fields
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 Operations: create(), read(), write(), unlink()
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 forwrite({'name': 'New Title'}) - Writing to multiple records is a single SQL
UPDATEstatement — 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'})
unlink() — Delete Records
# 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:
compute='_compute_price_per_page'— Tells Odoo which method to call to get the value@api.depends('price', 'pages')— Tells Odoo: “Recalculate this field wheneverpriceorpageschanges”- Inside the method, we loop over
self(because it can contain multiple records) - 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
related Fields — The Shortcut
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:
- Reading: Odoo calls
_compute_full_titleto calculate the display value - Writing: When the user edits
full_titlein the UI, Odoo calls_inverse_full_titleto 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:
- User types
3000in the “Pages” field - As soon as the user moves to another field (on blur), the onchange triggers
- A warning popup appears with the message
- 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_constraintsis 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:
@api.constrains('isbn')— This method is called aftercreate()orwrite()modifies theisbnfield- The method must check the data and raise
ValidationErrorif something is wrong - If a
ValidationErroris raised, the entire transaction is rolled back — nothing is saved - 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
- Recordsets are ordered collections of records — not lists. Use
ensure_one(),filtered(),mapped(),sorted(). - CRUD operations:
create(),read(),write(),unlink(). Prefer direct field access overread(). Preferwrite()on multiple records over looping. - Search methods:
search()for recordsets,search_count()for counts only,search_read()for raw dicts. self.envis your gateway: access other models, the current user, company, and context.sudo()bypasses security — use sparingly.with_context()passes extra data.- Computed fields calculate values automatically. Use
@api.dependsto declare dependencies. Usestore=Truefor searchable fields. Userelatedfor simple relation shortcuts. - Onchange methods provide live UI feedback — warnings and auto-filling. They only run in the UI, not in Python code.
- Constraints prevent invalid data:
_sql_constraintsfor simple database rules,@api.constrainsfor complex Python validation. Always raiseValidationError.
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_addedtolibrary.bookthat calculates how many days ago the book was added to the library. (Hint: subtractdate_addedfromfields.Datetime.now()and use.days) 2. Add a Python constraint that prevents creating two books with the same title by the same author. (Hint: useself.search_count()inside the constraint) 3. Practice recordset operations in the Odoo Shell: – Get all books, then usefiltered()to find books with price > 15 – Usemapped()to get a list of all author names – Usesorted()to sort books by pages descending – Chain all three: filter → sort → map 4. Write a methodaction_toggle_activethat toggles theactivefield on selected books (if active, make inactive; if inactive, make active). 5. Add arelatedfield onlibrary.bookthat 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
