Defining a Model
A model is a Python class that inherits from models.Model. The class attributes starting with an underscore are class-level configuration; the rest are field definitions:
from odoo import models, fields
class LibraryBook(models.Model):
_name = 'library.book' # Technical name → table: library_book
_description = 'Library Book' # Human-readable, shown in UI
_order = 'name asc' # Default sort order for list views
_rec_name = 'name' # Field used as display name (default: 'name')
name = fields.Char(string='Title', required=True)
author = fields.Char(string='Author')
isbn = fields.Char(string='ISBN')
price = fields.Float(string='Price')
active = fields.Boolean(default=True) # active=False archives the record
Key class attributes:
_name— dot-notation technical name; maps to a PostgreSQL table (library.book→library_book)_description— human-readable name shown in the UI and technical menus_order— default ORDER BY clause for searches ('name asc','create_date desc')_rec_name— which field is used as the record's display name in Many2one dropdowns (default:name)_inherit— inheritance; covered in the Model Inheritance lesson
Automatic Fields
Odoo adds these fields to every model automatically — you never declare them:
| Field | Type | Value |
|---|---|---|
id | Integer | Auto-increment primary key |
create_date | Datetime | When the record was created |
create_uid | Many2one (res.users) | Who created it |
write_date | Datetime | Last modification time |
write_uid | Many2one (res.users) | Who last modified it |
display_name | Char (computed) | Result of name_get() — shown in dropdowns |
Scalar Field Types
Scalar fields map to a single database column:
# String fields
name = fields.Char(string='Name', size=100, required=True)
description = fields.Text(string='Description') # multi-line
body_html = fields.Html(string='Content') # rich text
code = fields.Char(string='Reference', default='New')
# Numeric fields
quantity = fields.Integer(string='Qty', default=0)
price = fields.Float(string='Price', digits=(16, 2)) # (total digits, decimal places)
amount = fields.Monetary(string='Amount',
currency_field='currency_id') # uses currency_id for symbol
# Date/time fields
start_date = fields.Date(string='Start Date')
created_at = fields.Datetime(string='Created At')
# Boolean
active = fields.Boolean(default=True) # used for archive/unarchive
is_premium = fields.Boolean(string='Premium Member', default=False)
# Binary (file storage)
cover_image = fields.Binary(string='Cover Image', attachment=True)
document = fields.Binary(string='Document', attachment=True)
# Selection (dropdown with fixed choices)
state = fields.Selection([
('draft', 'Draft'),
('confirmed', 'Confirmed'),
('borrowed', 'Borrowed'),
('returned', 'Returned'),
('lost', 'Lost'),
], string='State', default='draft', required=True)
Relational Field Types
# Many2one: foreign key — "this book has one author (a res.partner record)"
author_id = fields.Many2one(
'res.partner',
string='Author',
ondelete='restrict', # prevent deleting the partner if books reference them
domain=[('is_author', '=', True)],
)
# One2many: reverse FK — "this book has many copies"
copy_ids = fields.One2many(
'library.book.copy', # the target model
'book_id', # the Many2one field on the target model
string='Copies',
)
# Many2many: junction table — "a book can have many tags; a tag can belong to many books"
tag_ids = fields.Many2many(
'library.tag',
'library_book_tag_rel', # junction table name (optional, auto-generated if omitted)
'book_id', # column in junction table for this model
'tag_id', # column in junction table for the related model
string='Tags',
)
| Field | DB column | Use case |
|---|---|---|
Many2one | Integer FK column | N records → 1 record (book → author) |
One2many | No column (reverse of Many2one) | 1 record → N records (book → copies) |
Many2many | Junction table | N records → N records (books ↔ tags) |
ondelete options for Many2one:
'cascade'— delete the child record when the parent is deleted'restrict'— block deletion if children exist (default; safest)'set null'— set the FK column to NULL when the parent is deleted
Common Field Attributes
| Attribute | Effect |
|---|---|
string='Label' | Field label shown in UI (default: field name formatted) |
required=True | Cannot save with empty value |
readonly=True | Display only; user cannot edit |
store=True | Computed field: save result to DB column (searchable/sortable) |
index=True | Create a DB index for faster searches |
default=value | Default value on new record creation |
default=lambda self: self.env.user | Dynamic default using a lambda |
copy=False | Don't copy this field when duplicating a record |
tracking=True | Log changes in chatter (requires mail.thread) |
groups='base.group_user' | Restrict visibility to specific user groups |
help='tooltip text' | Tooltip shown on hover in the form view |
domain=[...] | Filter options for relational fields |
context={} | Pass context to the related model's view |
Introduction to Computed Fields
Computed fields derive their value from other fields instead of storing user input. Full coverage is in the Backend section — here is a brief introduction to the pattern:
total_value = fields.Float(
string='Total Value',
compute='_compute_total_value',
store=True, # True = saved to DB (recalculated when depends change)
# False = calculated on-the-fly, not stored
)
@api.depends('price', 'quantity')
def _compute_total_value(self):
for record in self:
record.total_value = record.price * record.quantity
self
In Odoo, methods like _compute_* always receive a recordset — self can be one record or many. Always write a for record in self: loop; never assume self is a single record. This ensures your code works correctly in list operations and batch processing.
Complete Model Example
Here is a full working model incorporating all common patterns covered on this page:
from odoo import models, fields, api
from odoo.exceptions import ValidationError
class LibraryBook(models.Model):
_name = 'library.book'
_description = 'Library Book'
_order = 'name asc'
_inherit = ['mail.thread', 'mail.activity.mixin'] # adds chatter
# ── Basic fields ───────────────────────────────────────────────────────────
name = fields.Char(string='Title', required=True, tracking=True)
isbn = fields.Char(string='ISBN', size=13, index=True, copy=False)
author_id = fields.Many2one('res.partner', string='Author',
domain=[('is_company', '=', False)])
publisher_id = fields.Many2one('res.partner', string='Publisher',
domain=[('is_company', '=', True)])
publish_date = fields.Date(string='Publish Date')
price = fields.Float(string='Price', digits=(16, 2))
currency_id = fields.Many2one('res.currency', default=lambda self: self.env.company.currency_id)
amount = fields.Monetary(string='Amount', currency_field='currency_id')
# ── Categorisation ─────────────────────────────────────────────────────────
category_id = fields.Many2one('library.category', string='Category')
tag_ids = fields.Many2many('library.tag', string='Tags')
# ── State ──────────────────────────────────────────────────────────────────
state = fields.Selection([
('available', 'Available'),
('borrowed', 'Borrowed'),
('lost', 'Lost'),
], default='available', required=True, tracking=True)
active = fields.Boolean(default=True)
# ── Computed ───────────────────────────────────────────────────────────────
copy_count = fields.Integer(string='Copies', compute='_compute_copy_count', store=True)
copy_ids = fields.One2many('library.book.copy', 'book_id', string='Copies')
@api.depends('copy_ids')
def _compute_copy_count(self):
for book in self:
book.copy_count = len(book.copy_ids)
# ── Description ────────────────────────────────────────────────────────────
description = fields.Html(string='Description')
internal_note = fields.Text(string='Internal Notes')
📋 Key Points
- A model is a Python class extending
models.Model;_namedetermines the PostgreSQL table name (dots become underscores). - Odoo adds
id,create_date,create_uid,write_date,write_uidautomatically — never declare them. - Scalar fields:
Char,Text,Html,Integer,Float,Monetary,Date,Datetime,Boolean,Binary,Selection. - Relational fields:
Many2one(FK column),One2many(reverse FK, no column),Many2many(junction table). - Common attributes:
required,readonly,store,index,default,tracking,domain,copy. - Computed fields use
@api.depends+ a method that loopsfor record in self:and setsrecord.field = value.
FAQ
fields.Char and fields.Text? +Char is for short strings (max ~255 chars, single-line input in the UI). Text is for longer, multi-line content (textarea in the UI, no length limit). Both store as VARCHAR/TEXT in PostgreSQL.
python manage.py migrate like Django? +No. Odoo handles all database schema changes automatically when you install or upgrade a module. Adding a new field to a model and upgrading the module (-u module_name) automatically adds the column to the PostgreSQL table.
active = fields.Boolean(default=True) do specially? +The active field is magic in Odoo — any model with an active field automatically filters out records where active=False from all searches and views. Setting active=False archives the record. Users can see archived records by adding the "Archived" filter in the search bar.
store=True and store=False on a computed field? +store=True saves the computed value to the database column — making it searchable, sortable, and usable in domain filters. It recalculates when the @api.depends fields change. store=False calculates on-the-fly every time the field is read — not stored, not searchable, but always fresh. Use store=True for values used in filters or reports.
sale.order? +Yes — use model inheritance with _inherit = 'sale.order' to add your custom fields to an existing model. Your fields are added as new columns to the existing table. This is covered in detail in the Model Inheritance lesson.