Ad – 728×90
⚙️ Module Development

Odoo Models and Fields – Building Your Data Layer

In Odoo, a model is a Python class that maps to a PostgreSQL table. Every field you define on the class becomes a column in that table. The ORM handles all SQL — you never write CREATE TABLE, INSERT, or SELECT directly. This page covers how to define models, all field types, field attributes, and how to create your first working model.

⏱️ 30 min 🎯 Beginner 📅 Updated 2026

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:

Python
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.booklibrary_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:

FieldTypeValue
idIntegerAuto-increment primary key
create_dateDatetimeWhen the record was created
create_uidMany2one (res.users)Who created it
write_dateDatetimeLast modification time
write_uidMany2one (res.users)Who last modified it
display_nameChar (computed)Result of name_get() — shown in dropdowns
Ad – 336×280

Scalar Field Types

Scalar fields map to a single database column:

Python
# 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

Python
# 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',
)
FieldDB columnUse case
Many2oneInteger FK columnN records → 1 record (book → author)
One2manyNo column (reverse of Many2one)1 record → N records (book → copies)
Many2manyJunction tableN 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

AttributeEffect
string='Label'Field label shown in UI (default: field name formatted)
required=TrueCannot save with empty value
readonly=TrueDisplay only; user cannot edit
store=TrueComputed field: save result to DB column (searchable/sortable)
index=TrueCreate a DB index for faster searches
default=valueDefault value on new record creation
default=lambda self: self.env.userDynamic default using a lambda
copy=FalseDon't copy this field when duplicating a record
tracking=TrueLog 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:

Python
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
💡
Always loop over 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:

Python
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; _name determines the PostgreSQL table name (dots become underscores).
  • Odoo adds id, create_date, create_uid, write_date, write_uid automatically — 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 loops for record in self: and sets record.field = value.

FAQ

What is the difference between 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.

Do I need to run 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.

What does 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.

What is the difference between 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.

Can I add fields to an existing Odoo model like 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.