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 & 2 completed,
library_appmodule installed
What is the Odoo ORM? (vs SQLAlchemy, Django ORM)
ORM in 30 Seconds
ORM stands for Object-Relational Mapping. It’s a technique that lets you interact with a database using Python objects instead of writing raw SQL.
Without ORM (raw SQL):
INSERT INTO library_book (name, pages, isbn) VALUES ('The Hobbit', 310, '978-0547928227');
SELECT * FROM library_book WHERE pages > 200;
With ORM (Python):
# Create a book
env['library.book'].create({'name': 'The Hobbit', 'pages': 310, 'isbn': '978-0547928227'})
# Search for books with more than 200 pages
books = env['library.book'].search([('pages', '>', 200)])
Both do the same thing. The ORM translates your Python calls into SQL behind the scenes.
How Odoo’s ORM Compares
If you’ve used other Python ORMs, here’s how Odoo’s ORM compares:
| Feature | Django ORM | SQLAlchemy | Odoo ORM |
|---|---|---|---|
| Model definition | models.Model class |
Declarative base class | models.Model class |
| Migration | makemigrations / migrate |
Alembic (separate tool) | Automatic (on module install/upgrade) |
| Query syntax | Chaining methods | Chaining methods | Domains (Polish notation) |
| Admin UI | Django Admin (optional) | None built-in | Built-in (it IS the UI) |
| Inheritance | Python class inheritance | Python class inheritance | 3 types of Odoo-specific inheritance |
| Security | Manual (decorators, middleware) | Manual | Built-in (ACL + record rules) |
| Caching | Manual (Redis, Memcached) | Manual | Built-in (prefetching, caching) |
The Biggest Difference: No Manual Migrations
In Django, when you add a field, you run makemigrations and migrate to update the database. In SQLAlchemy, you use Alembic.
In Odoo, there are no migration files. When you:
- Add a new field → Upgrade the module → Odoo adds the column automatically
- Remove a field → Upgrade the module → Odoo removes the column (after a safety warning)
- Change a field type → Upgrade → Odoo tries to convert the column
This makes development faster, but it also means you need to be careful about field changes in production (data loss is possible if you change a field type carelessly).
How the ORM Creates Tables
When you write this Python class:
class LibraryBook(models.Model):
_name = 'library.book'
name = fields.Char(string='Title', required=True)
pages = fields.Integer(string='Pages')
Odoo automatically creates this SQL table:
CREATE TABLE library_book (
id SERIAL PRIMARY KEY, -- Auto-created by Odoo
name VARCHAR, -- From fields.Char
pages INTEGER, -- From fields.Integer
create_date TIMESTAMP, -- Auto-created: when the record was created
write_date TIMESTAMP, -- Auto-created: when the record was last modified
create_uid INTEGER REFERENCES res_users, -- Auto-created: who created it
write_uid INTEGER REFERENCES res_users -- Auto-created: who last modified it
);
You never write this SQL. The ORM handles everything. Your job is to define the Python class correctly.
models.Model vs models.TransientModel vs models.AbstractModel
Odoo has three types of model base classes. Each has a specific purpose.
models.Model — The Standard Model
from odoo import models, fields
class LibraryBook(models.Model):
_name = 'library.book'
_description = 'Library Book'
name = fields.Char(string='Title', required=True)
What it does:
- Creates a permanent table in the database (
library_book) - Records persist forever (until explicitly deleted)
- This is what you use 90% of the time
Use for: Books, customers, orders, invoices — any data that should be stored permanently.
models.TransientModel — The Temporary Model
class BookBorrowWizard(models.TransientModel):
_name = 'library.book.borrow.wizard'
_description = 'Book Borrow Wizard'
borrower_name = fields.Char(string='Borrower Name', required=True)
borrow_date = fields.Date(string='Borrow Date', default=fields.Date.today)
What it does:
- Creates a table, but records are automatically deleted after a period (usually a few hours)
- Used for wizards — popup dialogs that collect user input and then perform an action
- The data doesn’t need to persist because it’s only used during the user interaction
Use for: Import wizards, bulk action dialogs, confirmation prompts, multi-step forms.
Real-world analogy: Think of a TransientModel like a post-it note. You write something on it, use it, then throw it away. A regular Model is like a page in a notebook — you keep it.
models.AbstractModel — The Mixin
class LibraryMixin(models.AbstractModel):
_name = 'library.mixin'
_description = 'Library Common Fields'
notes = fields.Text(string='Internal Notes')
active = fields.Boolean(string='Active', default=True)
What it does:
- Does NOT create a database table
- Exists only to be inherited by other models
- Provides reusable fields and methods
Use for: Sharing common fields/methods across multiple models, like Odoo’s built-in mail.thread (adds chatter to any model).
Real-world analogy: An AbstractModel is like a recipe ingredient list that you mix into multiple dishes. It doesn’t exist on its own — it becomes part of whatever dish uses it.
Comparison Table
| Feature | models.Model |
models.TransientModel |
models.AbstractModel |
|---|---|---|---|
| Creates database table | ✅ Yes | ✅ Yes (temporary) | ❌ No |
| Data persists | ✅ Forever | ❌ Auto-cleaned | N/A |
| Can be used standalone | ✅ Yes | ✅ Yes (as wizards) | ❌ No (must be inherited) |
| Has views (UI) | ✅ Yes | ✅ Yes (popups) | ❌ No |
| Usage frequency | ~85% | ~10% | ~5% |
| Typical use | Core business data | User input dialogs | Shared field/method mixins |
Which One to Choose?
Ask yourself:
- Does the data need to be stored permanently? →
models.Model - Is this a popup/wizard that collects input and runs an action? →
models.TransientModel - Do I just want to share fields/methods with other models? →
models.AbstractModel
For our library.book, the answer is clearly models.Model — books are permanent data.
The _name and _description Attributes
Every Odoo model has special class attributes that start with an underscore. The two most fundamental ones are _name and _description.
_name — The Model’s Identity
class LibraryBook(models.Model):
_name = 'library.book'
What it does:
- Uniquely identifies the model in the Odoo system
- Determines the database table name (dots become underscores):
library.book→library_book - Used to reference the model everywhere: in Python (
env['library.book']), in XML (), and in security fileslibrary.book
Naming conventions:
# Convention: module_name.model_name (using dots)
_name = 'library.book' # ✅ Good — clear prefix, descriptive name
_name = 'library.book.author' # ✅ Good — three-part name for sub-models
_name = 'book' # ❌ Bad — no prefix, might conflict with other modules
_name = 'library_book' # ❌ Bad — use dots, not underscores
_name = 'LibraryBook' # ❌ Bad — use lowercase with dots
Why use dots? The dot convention creates a namespace for your module. Since many modules can be installed together, library.book won’t conflict with another module’s store.book or school.book.
_description — The Human-Readable Name
class LibraryBook(models.Model):
_name = 'library.book'
_description = 'Library Book'
What it does:
- Provides a human-readable name for the model
- Shown in error messages, logs, and the technical menu
- If omitted, Odoo uses
_namewhich is less readable
Always set _description. It costs one line and makes debugging much easier. Compare:
# Without _description (error message):
Error in model library.book: field 'isbn' is required
# With _description (error message):
Error in model Library Book: field 'isbn' is required
Python Class Name vs _name
Notice that the Python class name (LibraryBook) and the _name (library.book) are different:
class LibraryBook(models.Model): # Python class name — CamelCase
_name = 'library.book' # Odoo model name — dotted.lowercase
The Python class name doesn’t matter to Odoo. Odoo only cares about _name. You could name the class Xyz and it would still work (but please don’t). The convention is to use CamelCase that matches the model name: library.book → LibraryBook.
Field Types Overview
Odoo provides a rich set of field types. Let’s start with the big picture before diving into each one.
The Field Type Family Tree
Fields
├── Simple (store a single value)
│ ├── Char — Short text (VARCHAR)
│ ├── Text — Long text (TEXT)
│ ├── Html — Rich text with formatting (TEXT + HTML)
│ ├── Integer — Whole numbers
│ ├── Float — Decimal numbers
│ ├── Monetary — Currency amounts (with currency field)
│ ├── Boolean — True/False
│ ├── Date — Date only (no time)
│ ├── Datetime — Date + time
│ ├── Selection — Dropdown choices
│ ├── Binary — File storage (BYTEA)
│ └── Image — Image storage (inherits from Binary)
│
└── Relational (link to other models) — covered in Lesson 4
├── Many2one — FK: this record → one record in another model
├── One2many — Reverse: one record ← many records
└── Many2many — Junction: many records ↔ many records
In this lesson, we’ll cover all the Simple field types. Relational fields get their own lesson (Lesson 4) because they’re complex and essential.
Basic Field Types in Detail
fields.Char — Short Text
name = fields.Char(
string='Title', # Label in the UI
required=True, # Cannot be empty
size=256, # Maximum length (optional, rarely used)
trim=True, # Strip whitespace (default: True)
translate=True, # Can be translated to other languages
)
Database: VARCHAR column Python type: str (or False if empty — Odoo uses False instead of None) Use for: Names, titles, short descriptions, codes, phone numbers, emails
Important: In Odoo, empty fields are False, not None or "":
book = env['library.book'].browse(1)
print(book.name) # 'The Hobbit' (if set)
print(book.isbn) # False (if empty — NOT None, NOT "")
# Always check with:
if book.isbn: # ✅ Correct
print(book.isbn)
# Not with:
if book.isbn is not None: # ❌ Wrong — it's False, not None
print(book.isbn)
fields.Text — Long Text
synopsis = fields.Text(
string='Synopsis',
help='A brief summary of the book',
translate=True,
)
Database: TEXT column (unlimited length) Python type: str or False Use for: Descriptions, notes, comments, any long-form text without formatting
Char vs Text — When to use which:
| Scenario | Use |
|---|---|
| Book title, person name, email | Char |
| Book synopsis, internal notes | Text |
| Short reference codes (ISBN, SKU) | Char |
| Address, multi-line content | Text |
Rule of thumb: If the content might be more than one line, use Text. Otherwise, use Char.
fields.Html — Rich Text
description = fields.Html(
string='Full Description',
sanitize=True, # Remove potentially dangerous HTML tags (default: True)
sanitize_tags=True, # Strip disallowed tags
sanitize_attributes=True, # Strip disallowed attributes
strip_style=False, # Keep inline styles (default: False)
)
Database: TEXT column (stores HTML as a string) Python type: str (containing HTML) or False Use for: Rich content with bold, italic, lists, images — like a mini word processor
In the UI: Odoo renders an Html field as a WYSIWYG editor (What You See Is What You Get) — the user sees formatting buttons like Bold, Italic, Insert Image, etc.
Security note: sanitize=True (the default) is important. It strips dangerous HTML like tags to prevent XSS attacks. Never set sanitize=False unless you trust the content completely.
fields.Integer — Whole Numbers
pages = fields.Integer(
string='Number of Pages',
default=0, # Default value if not set
)
Database: INTEGER column Python type: int or 0 (Odoo converts False to 0 for Integer fields) Use for: Counts, quantities, ages, years — any whole number
Note on defaults: Unlike Char and Text (which default to False), numeric fields default to 0 when displayed. But in the database, they can still be NULL. So:
book = env['library.book'].browse(1)
print(book.pages) # 0 (if not set)
print(type(book.pages)) # <class 'int'>
fields.Float — Decimal Numbers
price = fields.Float(
string='Price',
digits=(10, 2), # (total digits, decimal digits)
# This means: up to 10 digits total, 2 after the decimal point
# Example: 12345678.99
)
rating = fields.Float(
string='Average Rating',
digits='Rating', # Use a named decimal precision (configured in Settings)
)
Database: NUMERIC column (or DOUBLE PRECISION without digits) Python type: float Use for: Prices, ratings, measurements, percentages
The digits parameter:
digits=(10, 2)— Explicit precision: 10 total digits, 2 decimal placesdigits='Rating'— Named precision defined in Settings → Technical → Decimal Accuracy- No
digits— UsesDOUBLE PRECISION(floating point, less precise)
For money, don't use Float — use Monetary instead (see below).
fields.Monetary — Currency Amounts
price = fields.Monetary(
string='Price',
currency_field='currency_id', # Name of the Many2one field pointing to res.currency
)
currency_id = fields.Many2one(
'res.currency',
string='Currency',
default=lambda self: self.env.company.currency_id,
# This sets the default currency to the current company's currency
)
Database: NUMERIC column Python type: float Use for: Any amount of money (prices, costs, totals, salaries)
Why not just use Float? Monetary adds:
- Currency symbol in the UI (€, $, ¥, etc.)
- Proper rounding based on currency precision
- Automatic currency conversion support
Important: A Monetary field requires a companion Many2one field pointing to res.currency. The currency_field parameter tells Odoo which field holds the currency. If your model doesn't have a currency field, you need to add one.
We'll cover Many2one in detail in Lesson 4. For now, just know that Monetary always needs a currency companion.
fields.Boolean — True/False
is_available = fields.Boolean(
string='Available?',
default=True, # Default to True when creating a new record
)
Database: BOOLEAN column Python type: bool (True or False) Use for: Flags, toggles, yes/no states
The special active field:
active = fields.Boolean(
string='Active',
default=True,
)
If you name a Boolean field active, Odoo applies special behavior: records with active=False are hidden from search results by default. This is Odoo's "soft delete" mechanism. Instead of deleting a record, you set active=False to archive it.
# With 'active' field on the model:
books = env['library.book'].search([]) # Only returns active books
books = env['library.book'].with_context(active_test=False).search([]) # Returns ALL books
This is very useful in practice. We'll use it in our library.book model.
fields.Date — Date Only
date_published = fields.Date(
string='Published Date',
default=fields.Date.today, # Default to today's date
# Note: fields.Date.today (no parentheses!) — it's a callable, not a value
)
Database: DATE column Python type: datetime.date or False Use for: Birthdays, publication dates, due dates — any date without time
Useful date methods:
from odoo import fields
# Get today's date
today = fields.Date.today() # Returns datetime.date object
# Convert string to date
d = fields.Date.to_date('2024-03-15') # Returns datetime.date(2024, 3, 15)
# Convert date to string
s = fields.Date.to_string(d) # Returns '2024-03-15'
# Date arithmetic
from datetime import timedelta
tomorrow = fields.Date.today() + timedelta(days=1)
next_week = fields.Date.today() + timedelta(weeks=1)
fields.Datetime — Date + Time
last_borrow_date = fields.Datetime(
string='Last Borrowed On',
default=fields.Datetime.now, # Default to current date and time
)
Database: TIMESTAMP column (stored in UTC) Python type: datetime.datetime or False Use for: Timestamps, deadlines with specific times, event start/end times
Time zone handling: Odoo stores all Datetime values in UTC in the database. When displayed in the UI, they're converted to the user's timezone. This means:
# In the database: 2024-03-15 10:00:00 (UTC)
# User in Vietnam (UTC+7) sees: 2024-03-15 17:00:00
# User in New York (UTC-4) sees: 2024-03-15 06:00:00
You usually don't need to worry about this — Odoo handles the conversion. But be aware of it when writing business logic that involves time comparisons.
fields.Selection — Dropdown Choices
state = fields.Selection(
selection=[
('draft', 'Draft'), # ('database_value', 'Display Label')
('available', 'Available'),
('borrowed', 'Borrowed'),
('lost', 'Lost'),
],
string='Status',
default='draft', # Default value (must match a database_value)
required=True, # User must select a value
)
Database: VARCHAR column (stores the first element of the tuple, e.g., 'draft') Python type: str or False Use for: Status fields, types, categories — any field with a fixed set of choices
How the tuples work:
('draft', 'Draft')
# ↑ ↑
# │ └── What the USER sees in the dropdown
# └── What's stored in the DATABASE
In Python code, you always work with the database value:
book = env['library.book'].browse(1)
print(book.state) # 'draft' (the database value)
# Check the state:
if book.state == 'draft': # ✅ Compare with database value
book.state = 'available' # ✅ Set using database value
if book.state == 'Draft': # ❌ Wrong — 'Draft' is the display label, not the stored value
pass
Important: Once records exist in the database, be careful about removing or renaming selection options. If you remove 'lost' from the list but some books have state='lost' in the database, Odoo will show an empty dropdown for those records.
fields.Binary — File Storage
book_cover = fields.Binary(
string='Cover Image',
attachment=True, # Store as an attachment (in filesystem, not in database)
)
book_cover_filename = fields.Char(
string='Cover Filename',
# This companion field stores the original filename
# Odoo uses it to know the file extension
)
Database: BYTEA column (if attachment=False) or filesystem (if attachment=True) Python type: bytes (base64-encoded) or False Use for: File uploads, images, documents, any binary data
attachment=True vs attachment=False:
| Setting | Storage | Performance |
|---|---|---|
attachment=True |
Filesystem (filestore) | ✅ Better for large files |
attachment=False |
Database (BYTEA column) | ❌ Bloats the database |
Best practice: Always use attachment=True for large files (images, PDFs, etc.).
fields.Image — Image Storage
book_cover = fields.Image(
string='Cover Image',
max_width=1024, # Resize if wider than this
max_height=1024, # Resize if taller than this
)
Database: Same as Binary (stored as attachment) Python type: bytes (base64-encoded) or False Use for: Images specifically (profile photos, product images, covers)
Image vs Binary: Image is a subclass of Binary that adds:
- Automatic resizing (based on
max_widthandmax_height) - Validation (rejects non-image files)
- Thumbnail generation
Use Image for images, Binary for other files (PDFs, documents, etc.).
Field Attributes: The Full Toolkit
Every field type accepts a common set of attributes. Here's the complete reference:
Most Commonly Used Attributes
name = fields.Char(
string='Title', # UI label — what the user sees
required=True, # Cannot be empty (adds NOT NULL in DB)
default='Untitled', # Default value for new records
help='Enter the book title', # Tooltip shown on hover in the UI
index=True, # Create a database index (faster searches)
readonly=False, # Can the user edit this in the UI?
copy=True, # Include when duplicating a record?
groups='base.group_system', # Only visible to users in this group
tracking=True, # Log changes in chatter (requires mail.thread)
)
Let's explain each one:
string — The UI Label
# If you set string:
name = fields.Char(string='Book Title') # UI shows: "Book Title"
# If you omit string:
book_title = fields.Char() # UI shows: "Book Title" (auto-generated from field name)
# Odoo converts 'book_title' → 'Book Title' (underscores → spaces, title case)
Tip: If your field name is already descriptive (like name, email, phone), you can often omit string.
required — Cannot Be Empty
name = fields.Char(required=True) # User must fill this in
isbn = fields.Char(required=False) # Optional (this is the default)
In the database, required=True adds a NOT NULL constraint. In the UI, the field is marked with a visual indicator and the form cannot be saved without it.
default — Default Value
# Static default:
state = fields.Selection([...], default='draft')
pages = fields.Integer(default=0)
is_available = fields.Boolean(default=True)
# Dynamic default using a lambda:
date_published = fields.Date(default=fields.Date.today)
# Note: fields.Date.today without () — it's a reference to the function, not a call
# Dynamic default using a method:
currency_id = fields.Many2one(
'res.currency',
default=lambda self: self.env.company.currency_id,
)
# 'self.env.company' gives the current user's company
# '.currency_id' gives that company's default currency
Why lambdas for dynamic defaults? Because the default is evaluated when a new record is created, not when the module is loaded. If you wrote default=fields.Date.today() (with parentheses), the date would be "frozen" to whenever the server started.
# ❌ WRONG — date is frozen when the server starts
date_published = fields.Date(default=fields.Date.today())
# ✅ CORRECT — date is evaluated fresh each time a new record is created
date_published = fields.Date(default=fields.Date.today)
help — Tooltip
isbn = fields.Char(
string='ISBN',
help='International Standard Book Number (13 digits)',
)
In the UI, when the user hovers over the field label, a tooltip appears with this text. It's a great way to provide guidance without cluttering the interface.
index — Database Index
isbn = fields.Char(index=True)
Creates a PostgreSQL index on this column. This makes search() and search_count() faster for queries that filter on this field.
When to use:
- ✅ Fields you frequently search or filter by (ISBN, email, reference codes)
- ✅ Fields used in domain filters or record rules
- ❌ Fields rarely used in searches (description, notes)
- ❌ Boolean fields (indexes on booleans are usually not helpful)
Don't over-index. Each index slows down write() and create() operations slightly.
readonly — Not Editable in UI
# Always readonly:
state = fields.Selection([...], readonly=True)
# In Odoo 18, you can also make fields conditionally readonly in XML views:
# <field name="isbn" readonly="state != 'draft'"/>
# This makes isbn readonly when the state is not 'draft'
Important: readonly=True on the field definition only affects the UI. In Python code, you can still write to a readonly field:
book.isbn = '978-123456789' # This works in Python, even if readonly=True
If you need to prevent writes in code too, use @api.constrains (covered in Lesson 5).
copy — Include in Duplication
name = fields.Char(copy=True) # ✅ Duplicated (default for most fields)
state = fields.Selection(copy=False) # ❌ NOT duplicated — reset to default
When a user clicks "Duplicate" on a record, Odoo copies all fields where copy=True. Fields with copy=False get their default value instead.
Common use: Set copy=False on status fields, sequence numbers, and dates that should be fresh on the new record.
groups — Field-Level Security
internal_notes = fields.Text(
groups='base.group_system', # Only system administrators can see this field
)
cost_price = fields.Float(
groups='base.group_system,library_app.group_library_manager',
# Visible to system admins OR library managers
)
If a user is not in the specified group, the field is completely invisible — it doesn't appear in views, and the ORM filters it out of read operations.
tracking — Change Tracking in Chatter
state = fields.Selection([...], tracking=True)
# When 'state' changes, a message is posted in the chatter:
# "State: Draft → Available"
This requires the model to inherit from mail.thread (covered in Lesson 11). For now, just know the attribute exists.
Complete Attributes Reference Table
| Attribute | Type | Default | Description |
|---|---|---|---|
string |
str |
Field name (title-cased) | UI label |
required |
bool |
False |
Cannot be empty |
default |
value or callable | False |
Default value for new records |
help |
str |
'' |
Tooltip text |
index |
bool |
False |
Create database index |
readonly |
bool |
False |
Not editable in UI |
copy |
bool |
True (most fields) |
Include in duplication |
groups |
str |
'' |
Comma-separated group XML IDs |
tracking |
bool |
False |
Log changes in chatter |
store |
bool |
True |
Store in database (False for computed) |
translate |
bool |
False |
Translatable to other languages |
company_dependent |
bool |
False |
Different value per company |
Automatic Fields: What Odoo Creates for You
Every models.Model and models.TransientModel automatically gets these fields — you don't need to define them:
# You do NOT write these — Odoo creates them automatically:
id = fields.Id()
# The primary key. Auto-incrementing integer.
# Every record has a unique id.
# In Python: book.id returns 1, 2, 3, etc.
create_date = fields.Datetime(string='Created on')
# When the record was first created.
# In Python: book.create_date returns a datetime object.
write_date = fields.Datetime(string='Last Modified on')
# When the record was last modified (any field changed).
# In Python: book.write_date returns a datetime object.
create_uid = fields.Many2one('res.users', string='Created by')
# The user who created the record.
# In Python: book.create_uid.name returns the user's name.
write_uid = fields.Many2one('res.users', string='Last Updated by')
# The user who last modified the record.
# In Python: book.write_uid.name returns the user's name.
These are often called "magic fields" or "log fields." They exist on every model and are maintained automatically by the ORM. You never need to set them manually.
Using Automatic Fields
book = env['library.book'].browse(1)
# Who created this book and when?
print(f"Created by {book.create_uid.name} on {book.create_date}")
# Output: "Created by Administrator on 2024-03-15 10:30:00"
# Who last modified it?
print(f"Last updated by {book.write_uid.name} on {book.write_date}")
The display_name Field
There's one more automatic field worth mentioning:
display_name = fields.Char(compute='_compute_display_name')
display_name is a computed field (we'll learn about these in Lesson 5) that returns the human-readable name of a record. By default, it uses the name field. So:
book = env['library.book'].browse(1)
print(book.display_name) # Same as book.name, e.g., "The Hobbit"
This field is used everywhere in the UI — in dropdown menus, breadcrumbs, and anywhere a record needs to be displayed as text.
The _rec_name and _order Attributes
Two more model attributes that control how records are displayed and sorted.
_rec_name — What Represents a Record
class LibraryBook(models.Model):
_name = 'library.book'
_rec_name = 'name' # Use the 'name' field as the record's display name
What it does: Tells Odoo which field to use for display_name. By default, it's 'name'. If your model doesn't have a name field, you need to set _rec_name explicitly.
# This model has no 'name' field, so we set _rec_name
class LibraryBorrowRecord(models.Model):
_name = 'library.borrow.record'
_rec_name = 'reference' # Use 'reference' as the display name
reference = fields.Char(string='Reference', required=True)
borrow_date = fields.Date(string='Borrow Date')
Without _rec_name (and without a name field), Odoo would show records as "library.borrow.record,1" — which is not user-friendly.
_order — Default Sort Order
class LibraryBook(models.Model):
_name = 'library.book'
_order = 'name ASC' # Sort alphabetically by title (ascending)
What it does: Defines the default order when records are fetched with search() or displayed in list views.
# Default order (by id):
_order = 'id' # Oldest first (lowest id)
# Single field:
_order = 'name ASC' # Alphabetical A→Z
_order = 'name DESC' # Alphabetical Z→A
_order = 'date_published DESC' # Newest first
# Multiple fields (comma-separated):
_order = 'state ASC, name ASC' # First by status, then alphabetically within each status
_order = 'date_published DESC, name ASC' # Newest first, then alphabetical for same date
Note: ASC (ascending) is the default if you don't specify. So _order = 'name' is the same as _order = 'name ASC'.
Hands-on: Define the library.book Model with Full Fields
Let's upgrade our minimal library.book model from Lesson 2 into a fully featured model with all the field types we've learned.
Replace the Contents of models/book.py
# library_app/models/book.py
from odoo import models, fields
class LibraryBook(models.Model):
"""A model representing a book in the library catalog."""
# ---------------------------
# Model Attributes
# ---------------------------
_name = 'library.book'
_description = 'Library Book'
_order = 'name ASC'
# _rec_name = 'name' # Not needed — 'name' is the default
# ---------------------------
# Basic Information
# ---------------------------
name = fields.Char(
string='Title',
required=True,
index=True,
help='The title of the book as it appears on the cover',
)
isbn = fields.Char(
string='ISBN',
help='International Standard Book Number (10 or 13 digits)',
index=True,
copy=False,
# Each book has a unique ISBN, so don't copy it when duplicating
)
active = fields.Boolean(
string='Active',
default=True,
help='Uncheck to archive the book (hide from default searches)',
# Remember: 'active' is a magic name — archived records are hidden by default
)
# ---------------------------
# Descriptive Fields
# ---------------------------
synopsis = fields.Text(
string='Synopsis',
help='A brief summary of the book',
translate=True,
# translate=True allows different synopses in different languages
)
description = fields.Html(
string='Full Description',
sanitize=True,
help='A detailed description with rich formatting (bold, lists, images, etc.)',
)
# ---------------------------
# Numeric Fields
# ---------------------------
pages = fields.Integer(
string='Number of Pages',
default=0,
)
price = fields.Float(
string='Price',
digits=(10, 2),
# (10, 2) means up to 10 total digits, 2 after the decimal
# Examples: 29.99, 1234.50, 99999999.99
help='The retail price of the book',
)
rating = fields.Float(
string='Average Rating',
digits=(3, 2),
# (3, 2) means up to 3 total digits, 2 after the decimal
# Range: 0.00 to 9.99 (perfect for 0-5 star ratings)
help='Average reader rating from 0 to 5',
)
# ---------------------------
# Date Fields
# ---------------------------
date_published = fields.Date(
string='Published Date',
help='The date when the book was first published',
)
date_added = fields.Datetime(
string='Date Added to Library',
default=fields.Datetime.now,
# Default to the current date and time when a book is added
readonly=True,
# Readonly because this should be set automatically, not edited by users
copy=False,
# Don't copy this date when duplicating — the new record should get a fresh timestamp
)
# ---------------------------
# Selection Fields
# ---------------------------
state = fields.Selection(
selection=[
('draft', 'Draft'),
('available', 'Available'),
('borrowed', 'Borrowed'),
('lost', 'Lost'),
],
string='Status',
default='draft',
required=True,
copy=False,
# When duplicating a book, the new copy starts as 'draft'
help='Current status of the book in the library',
)
cover_type = fields.Selection(
selection=[
('hardcover', 'Hardcover'),
('paperback', 'Paperback'),
('ebook', 'E-Book'),
('audiobook', 'Audiobook'),
],
string='Cover Type',
default='paperback',
)
# ---------------------------
# Binary / Image Fields
# ---------------------------
cover_image = fields.Image(
string='Cover Image',
max_width=512,
max_height=512,
help='Upload the book cover image (will be resized to 512x512 max)',
)
# ---------------------------
# Additional Info
# ---------------------------
notes = fields.Text(
string='Internal Notes',
help='Internal notes, not visible to library members',
)
What We've Done
Let's count: our model now has 13 fields (plus 5 automatic fields from Odoo). Here's a summary:
| Field Name | Type | Purpose |
|---|---|---|
name |
Char | Book title |
isbn |
Char | Unique identifier |
active |
Boolean | Soft delete (archive) |
synopsis |
Text | Short summary |
description |
Html | Rich text description |
pages |
Integer | Page count |
price |
Float | Retail price |
rating |
Float | Reader rating |
date_published |
Date | Publication date |
date_added |
Datetime | When added to library |
state |
Selection | Status (Draft/Available/Borrowed/Lost) |
cover_type |
Selection | Physical format |
cover_image |
Image | Cover photo |
notes |
Text | Internal notes |
Upgrade the Module
After making these changes, upgrade the module so Odoo adds the new columns:
# Docker method:
docker compose exec odoo odoo -d odoo18dev -u library_app --stop-after-init
docker compose restart odoo
# Source method:
# Restart the server (Ctrl+C, then):
python odoo/odoo-bin -c odoo.conf -d odoo18dev -u library_app
Verify in the Odoo Shell
# Open the shell
# Docker: docker compose exec odoo odoo shell -d odoo18dev
# Check all fields on the model
model = env['library.book']
for field_name, field_obj in sorted(model._fields.items()):
print(f"{field_name:25s} {field_obj.type:15s} {field_obj.string}")
# Expected output (abbreviated):
# active boolean Active
# cover_image image Cover Image
# cover_type selection Cover Type
# create_date datetime Created on
# create_uid many2one Created by
# date_added datetime Date Added to Library
# date_published date Published Date
# description html Full Description
# display_name char Display Name
# id integer ID
# isbn char ISBN
# name char Title
# notes text Internal Notes
# pages integer Number of Pages
# price float Price
# rating float Average Rating
# state selection Status
# synopsis text Synopsis
# write_date datetime Last Modified on
# write_uid many2one Last Updated by
# Create a test record
book = env['library.book'].create({
'name': 'The Hobbit',
'isbn': '978-0547928227',
'pages': 310,
'price': 14.99,
'rating': 4.7,
'date_published': '1937-09-21',
'state': 'available',
'cover_type': 'paperback',
'synopsis': 'A hobbit goes on an unexpected adventure...',
})
print(f"Created: {book.name} (ID: {book.id})")
# Output: Created: The Hobbit (ID: 1)
# Read it back
print(f"Pages: {book.pages}") # 310
print(f"Price: {book.price}") # 14.99
print(f"Status: {book.state}") # available
print(f"Added: {book.date_added}") # Current datetime
print(f"Active: {book.active}") # True
# Don't forget to commit!
env.cr.commit()
Summary & What's Next
Key Takeaways
- The Odoo ORM translates Python classes into database tables. No SQL needed, no manual migrations.
- Three model types:
- models.Model — permanent data (90% of your models) - models.TransientModel — temporary wizard data - models.AbstractModel — reusable mixins (no table)
_nameis the model's identity (determines table name)._descriptionis the human-readable label.- Field types for every data type:
- Text: Char, Text, Html - Numbers: Integer, Float, Monetary - Logic: Boolean - Time: Date, Datetime - Choices: Selection - Files: Binary, Image
- Field attributes control behavior:
required,default,index,readonly,copy,help,groups,tracking. - Automatic fields are created for free:
id,create_date,write_date,create_uid,write_uid,display_name. activeis a magic field name — records withactive=Falseare hidden from default searches._ordersets the default sort,_rec_namesets the display name field.
What's Next?
In Lesson 4: The ORM — Part 2: Relational Fields & Domains, we'll add relationships between models. You'll learn:
Many2one— linking a book to an authorOne2many— viewing all books by an authorMany2many— tagging books with multiple categories- Domains — Odoo's powerful filtering syntax
Our library.book model will grow to include an author, a publisher, and tags — making it a realistic data model.
Exercises: 1. Open the Odoo Shell and create 3-5 books with different field values. Practice setting different
state,cover_type, anddate_publishedvalues. 2. Try theactivefield: create a book, then setactive=False. Search for all books — does the archived book appear? Now search withactive_test=Falsein the context — does it appear now? ``python # Create and archive a book book = env['library.book'].create({'name': 'Old Book', 'state': 'draft'}) book.active = False env.cr.commit() # Search normally (archived book should be hidden) visible = env['library.book'].search([]) print([b.name for b in visible]) # Search including archived records all_books = env['library.book'].with_context(active_test=False).search([]) print([b.name for b in all_books])`3. Experiment with field defaults: create a book without settingstateordate_added. What values do they get? 4. Try setting a Float field with the wrong precision. What happens if you setprice = 123456789.99whendigits=(10, 2)only allows 10 total digits? 5. Look at a built-in Odoo model's source code. Find theres.partnermodel: - Docker:docker compose exec odoo cat /usr/lib/python3/dist-packages/odoo/addons/base/models/res_partner.py | head -100- Source:head -100 odoo/addons/base/models/res_partner.py- Can you identify the field types we learned? Look forChar,Selection,Boolean,Date`, etc.
Previous lesson: Lesson 2 — Anatomy of an Odoo Module Next lesson: Lesson 4 — The ORM: Relational Fields & Domains
