
Odoo 19
- 1.5k installs
- 118 repo stars
- Updated July 14, 2026
- unclecatvn/agent-skills
odoo-19 is an agent skill that apply odoo-19 agent skill workflows from documented skill.md guidance.
About
odoo-19 is an agent skill from unclecatvn/agent-skills that apply odoo-19 agent skill workflows from documented skill.md guidance. # Odoo 19 Skill - Master Index Master index for all Odoo 19 development guides. Read the appropriate guide from `references/` based on your task. ## Quick Reference | Topic | File | When to Use | | -------------- | ----------------------------------------- Developers invoke odoo-19 during ship/testing work for testing & qa tasks. The skill documents triggers, prerequisites, and step-by-step workflows grounded in SKILL.md. Compatible with Claude Code, Cursor, and Codex agent runtimes that load marketplace skills. Review the Security Audits panel on this listing before installing in production environments. Category Testing & QA with development vertical focus supports repeatable agent-guided delivery.
- Odoo 19 Skill - Master Index
- Master index for all Odoo 19 development guides. Read the appropriate guide from `references/` based on your task.
- | Topic | File | When to Use |
- | -------------- | ----------------------------------------- | ------------------------------------------------------- |
- | Actions | `references/odoo-19-actions-guide.md` | Creating actions, menus, scheduled jobs, server actions |
Odoo 19 by the numbers
- 1,486 all-time installs (skills.sh)
- +57 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #477 of 2,155 Testing & QA skills by installs in the Skillselion catalog
- Security screen: MEDIUM risk (skills.sh audit)
- Data as of Aug 2, 2026 (Skillselion catalog sync)
odoo-19 capabilities & compatibility
- Capabilities
- odoo 19 skill master index · master index for all odoo 19 development guides. · | topic | file | when to use | · | | · | actions | `references/odoo 19 actions guide.md
- Use cases
- orchestration
What odoo-19 says it does
Master index for all Odoo 19 development guides. Read the appropriate guide from `references/` based on your task.
├── SKILL.md # This file - master index
└── references/ # Development guides
npx skills add https://github.com/unclecatvn/agent-skills --skill odoo-19Add your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1.5k |
|---|---|
| repo stars | ★ 118 |
| Security audit | 2 / 3 scanners passed |
| Last updated | July 14, 2026 |
| Repository | unclecatvn/agent-skills ↗ |
What it does
Apply odoo-19 agent skill workflows from documented SKILL.md guidance.
Who is it for?
Developers working on testing & qa during ship tasks.
Skip if: Tasks outside Testing & QA scope described in SKILL.md.
When should I use this skill?
Apply odoo-19 agent skill workflows from documented SKILL.md guidance.
What you get
Completed testing & qa workflow aligned with SKILL.md steps.
- Odoo model and field definitions
- Migration scripts
- OWL component code
Files
Odoo 19 Skill - Master Index
Master index for all Odoo 19 development guides. Read the appropriate guide from references/ based on your task.
Quick Reference
| Topic | File | When to Use |
|---|---|---|
| Actions | references/odoo-19-actions-guide.md | Creating actions, menus, scheduled jobs, server actions |
| API Decorators | references/odoo-19-decorator-guide.md | Using @api decorators, compute fields, validation |
| Controllers | references/odoo-19-controller-guide.md | Writing HTTP endpoints, routes, web controllers |
| Data Files | references/odoo-19-data-guide.md | XML/CSV data files, records, shortcuts |
| Development | references/odoo-19-development-guide.md | Creating modules, manifest, reports, security, wizards |
| Field Types | references/odoo-19-field-guide.md | Defining model fields, choosing field types |
| Manifest | references/odoo-19-manifest-guide.md | manifest.py configuration, dependencies, hooks |
| Migration | references/odoo-19-migration-guide.md | Upgrading modules, data migration, version changes |
| Mixins | references/odoo-19-mixins-guide.md | mail.thread, activities, email aliases, tracking |
| Model Methods | references/odoo-19-model-guide.md | Writing ORM queries, CRUD operations, domain filters |
| OWL Components | references/odoo-19-owl-guide.md | Building OWL UI components, hooks, services |
| Performance | references/odoo-19-performance-guide.md | Optimizing queries, fixing slow code, preventing N+1 |
| Reports | references/odoo-19-reports-guide.md | QWeb reports, PDF/HTML, templates, paper formats |
| Security | references/odoo-19-security-guide.md | Access rights, record rules, field permissions |
| Testing | references/odoo-19-testing-guide.md | Writing tests, mocking, assertions, browser testing |
| Transactions | references/odoo-19-transaction-guide.md | Handling database errors, savepoints, UniqueViolation |
| Translation | references/odoo-19-translation-guide.md | Adding translations, localization, i18n |
| Views & XML | references/odoo-19-view-guide.md | Writing XML views, actions, menus, QWeb templates |
File Structure
skills/odoo-19.0/
├── SKILL.md # This file - master index
└── references/ # Development guides
├── odoo-19-actions-guide.md
├── odoo-19-controller-guide.md
├── odoo-19-data-guide.md
├── odoo-19-decorator-guide.md
├── odoo-19-development-guide.md
├── odoo-19-field-guide.md
├── odoo-19-manifest-guide.md
├── odoo-19-migration-guide.md
├── odoo-19-mixins-guide.md
├── odoo-19-model-guide.md
├── odoo-19-owl-guide.md
├── odoo-19-performance-guide.md
├── odoo-19-reports-guide.md
├── odoo-19-security-guide.md
├── odoo-19-testing-guide.md
├── odoo-19-transaction-guide.md
├── odoo-19-translation-guide.md
└── odoo-19-view-guide.mdBase Code Reference (Odoo 19)
All guides are based on analysis of Odoo 19 source code:
odoo/models.py- ORM implementationodoo/fields.py- Field typesodoo/api.py- Decoratorsodoo/http.py- HTTP layerodoo/exceptions.py- Exception typesodoo/tools/translate.py- Translation systemodoo/addons/base/models/res_lang.py- Language modeladdons/web/static/src/core/l10n/translation.js- JS translations
External Documentation
Odoo 19 Documentation - AI Agents Setup
Setup guide for using Odoo 19 documentation with AI coding assistants (Cursor, Claude Code, Windsurf, Aider, etc.).
Quick Start
Install via skills.sh (Recommended)
# Add Odoo 19 skill to your project
npx skills add unclecatvn/agent-skillsVisit https://skills.sh/ for more installation options.
Cursor IDE - Remote Rule
Configure once in Cursor settings:
Settings→Rules→Add Remote Rule- Source:
Git Repository - URL:
git@github.com:unclecatvn/agent-skills.git - Branch:
19.0 - Subfolder:
skills/odoo-19.0/
---
Documentation Structure
skills/odoo-19.0/
├── SKILL.md # Master index (all agents)
├── references/ # Development guides (18 files)
│ ├── odoo-19-actions-guide.md # ir.actions.*, cron, bindings
│ ├── odoo-19-controller-guide.md # HTTP, routing, controllers
│ ├── odoo-19-data-guide.md # XML/CSV data files, records
│ ├── odoo-19-decorator-guide.md # @api decorators
│ ├── odoo-19-development-guide.md # Manifest, wizards (overview)
│ ├── odoo-19-field-guide.md # Field types, parameters
│ ├── odoo-19-manifest-guide.md # __manifest__.py reference
│ ├── odoo-19-mixins-guide.md # mail.thread, activities, etc.
│ ├── odoo-19-model-guide.md # ORM, CRUD, search, domain
│ ├── odoo-19-migration-guide.md # Migration scripts, hooks
│ ├── odoo-19-owl-guide.md # OWL components, services
│ ├── odoo-19-performance-guide.md # N+1 prevention, optimization
│ ├── odoo-19-reports-guide.md # QWeb reports, PDF/HTML
│ ├── odoo-19-security-guide.md # ACL, record rules, security
│ ├── odoo-19-testing-guide.md # Test classes, decorators
│ ├── odoo-19-transaction-guide.md # Savepoints, errors
│ ├── odoo-19-translation-guide.md # Translations, i18n
│ └── odoo-19-view-guide.md # XML views, QWeb
├── CLAUDE.md # Claude Code specific
└── AGENTS.md # THIS FILE - setup guide---
Guide Reference
| File | Purpose | When to Use |
|---|---|---|
SKILL.md | Master index for all guides | Find the right guide for your task |
references/odoo-19-actions-guide.md | Actions (window, URL, server, cron) | Creating actions, menus, scheduled jobs |
references/odoo-19-controller-guide.md | HTTP controllers, routing | Writing endpoints |
references/odoo-19-data-guide.md | XML/CSV data files, records | Creating data files |
references/odoo-19-decorator-guide.md | @api decorators usage | Using @api decorators |
references/odoo-19-development-guide.md | Module structure, wizards | Creating new modules |
references/odoo-19-field-guide.md | Field types, parameters | Defining model fields |
references/odoo-19-manifest-guide.md | __manifest__.py reference | Configuring module manifest |
references/odoo-19-mixins-guide.md | mail.thread, activities, mixins | Adding messaging, activities |
references/odoo-19-model-guide.md | ORM methods, CRUD, domains | Writing model methods |
references/odoo-19-migration-guide.md | Migration scripts, hooks | Upgrading modules |
references/odoo-19-owl-guide.md | OWL components, hooks, services | Building OWL UI |
references/odoo-19-performance-guide.md | Performance optimization | Fixing slow code |
references/odoo-19-reports-guide.md | QWeb reports, templates | Creating reports |
references/odoo-19-security-guide.md | ACL, record rules, security | Configuring security |
references/odoo-19-testing-guide.md | Test classes, decorators, mocking | Writing tests |
references/odoo-19-transaction-guide.md | Database transactions, error handling | Savepoints, UniqueViolation |
references/odoo-19-translation-guide.md | Translations, localization, i18n | Adding translations |
references/odoo-19-view-guide.md | XML views, actions, menus | Writing view XML |
---
AI Agent Configuration
Cursor IDE
| Setting | Value |
|---|---|
| Source | Git Repository |
| URL | git@github.com:unclecatvn/agent-skills.git |
| Branch | 19.0 |
| Subfolder | skills/odoo-19.0/ |
Globs patterns used by Cursor:
| File | globs Pattern |
|---|---|
SKILL.md | **/*.{py,xml} |
references/odoo-19-actions-guide.md | **/*.{py,xml} |
references/odoo-19-controller-guide.md | **/controllers/**/*.py |
references/odoo-19-data-guide.md | **/*.{xml,csv} |
references/odoo-19-decorator-guide.md | **/models/**/*.py |
references/odoo-19-development-guide.md | **/*.{py,xml,csv} |
references/odoo-19-field-guide.md | **/models/**/*.py |
references/odoo-19-manifest-guide.md | **/__manifest__.py |
references/odoo-19-mixins-guide.md | **/models/**/*.py |
references/odoo-19-model-guide.md | **/models/**/*.py |
references/odoo-19-migration-guide.md | **/migrations/**/*.py |
references/odoo-19-owl-guide.md | static/src/**/*.{js,xml} |
references/odoo-19-performance-guide.md | **/*.{py,xml} |
references/odoo-19-reports-guide.md | **/report/**/*.xml |
references/odoo-19-security-guide.md | **/security/**/*.{csv,xml} |
references/odoo-19-testing-guide.md | **/tests/**/*.py |
references/odoo-19-transaction-guide.md | **/models/**/*.py |
references/odoo-19-translation-guide.md | **/*.{py,js,xml} |
references/odoo-19-view-guide.md | **/views/**/*.xml |
Claude Code
# Install via skills.sh
npx skills add unclecatvn/agent-skillsClaude Code reads:
CLAUDE.md- Project overview and quick referenceSKILL.md- Master index for all guides- Individual guides in
references/- Detailed information
Other Agents
| Agent | Setup |
|---|---|
| Windsurf | Same as Cursor (uses .mdc files) |
| Continue | Place CLAUDE.md or SKILL.md in root |
| Aider | Place CLAUDE.md or add to prompt |
| OpenCode | Copy skill folder to project - no additional config needed |
---
Cursor / Claude Skills Folder
After installing via npx skills add unclecatvn/agent-skills, the skill is placed at:
.cursor/skills/
└── odoo-19/
└── SKILL.md
.claude/skills/
└── odoo-19/
└── SKILL.md---
Key Odoo 19 Changes
| Change | Old | New |
|---|---|---|
| List view tag | <tree> | <list> |
| Dynamic attributes | attrs="{'invisible': [...]}" | invisible="..." |
| Delete validation | Override unlink() | @api.ondelete(at_uninstall=False) |
| Field aggregation | group_operator= | aggregator= |
| SQL queries | cr.execute() | SQL class with execute_query_dict() |
---
Repository
URL: git@github.com:unclecatvn/agent-skills.git Branch: 19.0 License: MIT
Odoo 19 Development Guide
This file provides guidance to AI agents when working with Odoo 19 code in this repository.
For setup instructions with different AI IDEs, see [AGENTS.md](./AGENTS.md)
Documentation Structure
The skills/odoo-19.0/references/ directory contains modular guides for Odoo 19 development:
skills/odoo-19.0/
├── SKILL.md # Master index
├── references/ # Development guides (18 files)
│ ├── odoo-19-actions-guide.md # ir.actions.*, cron, bindings
│ ├── odoo-19-controller-guide.md # HTTP, routing, controllers
│ ├── odoo-19-data-guide.md # XML/CSV data files, records
│ ├── odoo-19-decorator-guide.md # @api decorators
│ ├── odoo-19-development-guide.md # Manifest, wizards (overview)
│ ├── odoo-19-field-guide.md # Field types, parameters
│ ├── odoo-19-manifest-guide.md # __manifest__.py reference
│ ├── odoo-19-mixins-guide.md # mail.thread, activities, etc.
│ ├── odoo-19-model-guide.md # ORM, CRUD, search, domain
│ ├── odoo-19-migration-guide.md # Migration scripts, hooks
│ ├── odoo-19-owl-guide.md # OWL components, services
│ ├── odoo-19-performance-guide.md # N+1 prevention, optimization
│ ├── odoo-19-reports-guide.md # QWeb reports, PDF/HTML
│ ├── odoo-19-security-guide.md # ACL, record rules, security
│ ├── odoo-19-testing-guide.md # Test classes, decorators
│ ├── odoo-19-transaction-guide.md # Savepoints, errors
│ ├── odoo-19-translation-guide.md # Translations, i18n
│ └── odoo-19-view-guide.md # XML views, QWeb
├── CLAUDE.md # This file
└── AGENTS.md # AI agents setupWhich Guide to Use
| Task | Guide |
|---|---|
| Creating actions, menus, cron jobs | references/odoo-19-actions-guide.md |
| Creating a new module | references/odoo-19-development-guide.md |
| Configuring manifest.py | references/odoo-19-manifest-guide.md |
| Creating XML/CSV data files | references/odoo-19-data-guide.md |
| Writing ORM queries/search | references/odoo-19-model-guide.md |
| Defining model fields | references/odoo-19-field-guide.md |
| Using @api decorators | references/odoo-19-decorator-guide.md |
| Writing XML views | references/odoo-19-view-guide.md |
| Fixing slow code/N+1 queries | references/odoo-19-performance-guide.md |
| Handling database errors | references/odoo-19-transaction-guide.md |
| Creating HTTP endpoints | references/odoo-19-controller-guide.md |
| Building OWL components | references/odoo-19-owl-guide.md |
| Upgrading modules/migrating data | references/odoo-19-migration-guide.md |
| Using mail.thread, activities, mixins | references/odoo-19-mixins-guide.md |
| Creating QWeb reports | references/odoo-19-reports-guide.md |
| Configuring security (ACL, rules) | references/odoo-19-security-guide.md |
| Writing tests | references/odoo-19-testing-guide.md |
| Adding translations/localization | references/odoo-19-translation-guide.md |
Key Odoo 19 Changes
| Change | Old (Odoo 17-) | New (Odoo 19) |
|---|---|---|
| List view tag | <tree> | <list> |
| Dynamic attributes | attrs="{'invisible': [...]}" | invisible="..." (direct) |
| Delete validation | Override unlink() | @api.ondelete(at_uninstall=False) |
| Field aggregation | group_operator= | aggregator= |
| SQL queries | cr.execute() | SQL class with execute_query_dict() |
| Batch create | Single dict | List of dicts (create([{...}, {...}])) |
| SQL constraints | _sql_constraints = [...] | models.Constraint(...) |
| DB indexes | index=True only | models.Index(...) declarative |
| Kanban template | t-name="kanban-box" | t-name="card" |
| QWeb output | t-esc | t-out (t-esc deprecated) |
| Security groups | category_id on res.groups | privilege_id + res.groups.privilege |
| Private methods | _ prefix convention | @api.private decorator (enforced) |
| Model naming | _name = 'res.users' required | CamelCase class → auto-derive _name |
| read_group | read_group() | _read_group() / formatted_read_group() |
Critical Anti-Patterns
| Anti-Pattern | Why Bad | Correct Approach |
|---|---|---|
attrs="{'invisible': [...]}" | Deprecated in Odoo 18 | Use invisible="..." direct attribute |
@api.depends('partner_id') then accessing partner_id.email | N queries per record | Add @api.depends('partner_id.email') |
search() inside loop | N+1 queries | Use search() with IN domain or _read_group() |
create() in loop | N INSERT statements | Batch: create([{...}, {...}]) |
Overriding unlink() for validation | Breaks module uninstall | Use @api.ondelete(at_uninstall=False) |
Using <tree> in Odoo 19 | Deprecated tag | Use <list> instead |
Using _sql_constraints | Not supported in Odoo 19 | Use models.Constraint(...) |
Using t-esc in templates | Deprecated directive | Use t-out instead |
Using category_id in res.groups | Removed in Odoo 19 | Use privilege_id + res.groups.privilege |
Using read_group() | Deprecated | Use _read_group() or formatted_read_group() |
@api Decorator Decision Tree
Need to define field behavior?
├── Field computed from other fields → @api.depends
│ └── CAN use dotted paths: `@api.depends('partner_id.email')`
├── Validate data → @api.constrains
│ └── CANNOT use dotted paths: only simple field names
├── Prevent record deletion → @api.ondelete (Odoo 18+)
└── Update form UI → @api.onchange
└── NO CRUD operations allowed
Need to define method behavior?
├── Method-level, doesn't depend on self → @api.model
├── Mark method as non-RPC callable → @api.private
└── Normal record method → no decorator neededCommon Patterns Reference
N+1 Query Prevention
# BAD: search in loop
for order in orders:
payments = self.env['payment'].search([('order_id', '=', order.id)])
# GOOD: single query
payments = self.env['payment'].search_read([('order_id', 'in', orders.ids)])List View (Odoo 19)
<list string="Records" editable="bottom" multi_edit="1">
<field name="state" decoration-success="state == 'done'"/>
<field name="phone" optional="show"/>
</list>Delete Validation (Odoo 19)
@api.ondelete(at_uninstall=False)
def _unlink_if_not_draft(self):
if any(rec.state != 'draft' for rec in self):
raise UserError("Cannot delete non-draft records")Module Structure
my_module/
├── __init__.py
├── __manifest__.py
├── models/
│ ├── __init__.py
│ └── my_model.py
├── views/
│ └── my_model_views.xml
├── security/
│ ├── ir.model.access.csv
│ └── my_module_security.xml
├── data/
│ └── my_module_data.xml
├── migrations/
│ └── 19.0.1.0/
│ └── post-migration.py
├── tests/
│ ├── __init__.py
│ └── test_my_model.py
├── wizard/
│ ├── __init__.py
│ └── my_wizard.py
├── controllers/
│ ├── __init__.py
│ └── my_controller.py
└── static/
└── src/
├── js/
│ └── my_component.js
├── xml/
│ └── my_component.xml
└── scss/
└── my_style.scssBase Code Reference
The guides are based on Odoo 19 source code. Reference these files in your Odoo installation:
odoo/models.py- ORM implementationodoo/fields.py- Field typesodoo/api.py- Decoratorsodoo/http.py- HTTP layerodoo/exceptions.py- Exception typesodoo/tools/translate.py- Translation systemodoo/addons/base/models/res_lang.py- Language modeladdons/web/static/src/core/l10n/translation.js- JS translations
Odoo 19 API Highlights
Use this file as the version-specific ruleset when the resolved Odoo version is 19.0. It supplements — not replaces — the general review checklist. Everything from 18 applies unless noted below.
Models
- `_name` is optional — Odoo 19 derives it automatically from the CamelCase class name (each capital letter →
.separator): ResPartner→res.partnerSaleOrder→sale.orderMyModel→my.model- `_sql_constraints` — the constraint name can be omitted; Odoo auto-generates a unique name based on model + attribute.
- Reference:
references/odoo-19-model-guide.md.
Views
- Same as 18:
<list>tag, direct-expression attrs. Reference:references/odoo-19-view-guide.md.
Fields
- Same as 18:
aggregator=for aggregation. Reference:references/odoo-19-field-guide.md.
Decorators
- Same as 18:
@api.ondelete,@api.model_create_multi.@api.returnsusage patterns are expanded in the v19 guide. Reference:references/odoo-19-decorator-guide.md.
Quick review checks (v19-specific)
- ✅
_namemay be omitted when the CamelCase class name maps correctly — don't flag as missing. - ✅ Unnamed
_sql_constraintsare valid — don't flag as missing name. - All 18 rules still apply (
<list>, direct-expression attrs,aggregator=,@api.ondelete,@api.model_create_multi).
Odoo 19 Actions Guide
Guide for working with Odoo 19 actions (ir.actions.*), scheduled jobs (cron), and action bindings.
Table of Contents
- Action Types
- Window Actions
- Server Actions
- Report Actions
- Client Actions
- URL Actions
- Scheduled Actions (Cron)
- Action Bindings
---
Action Types
Actions define the behavior of the system in response to user actions: login, action button, selection of records, etc.
Actions can be stored in the database or returned directly as dictionaries. All actions share two mandatory attributes:
| Attribute | Type | Description |
|---|---|---|
type | string | The category of the current action |
name | string | Short user-readable description |
A client can get actions in 4 forms:
False- closes any open action dialog- A string - client action tag or number
- A number - database identifier or external ID
- A dictionary - client action descriptor
---
Window Actions
ir.actions.act_window - The most common action type, used to present visualizations of a model through views.
Key Attributes
| Attribute | Type | Description |
|---|---|---|
res_model | string | Model to present views for |
views | list | List of (view_id, view_type) pairs |
res_id | int | If default view is form, specifies the record to load |
search_view_id | tuple | (id, name) pair for specific search view |
target | string | current, fullscreen, new, or main |
context | dict | Additional context data |
domain | list | Filtering domain |
limit | int | Number of records to display (default: 80) |
Example: Opening customers
{
"type": "ir.actions.act_window",
"res_model": "res.partner",
"views": [[False, "list"], [False, "form"]],
"domain": [["customer", "=", True]],
}Example: Opening specific product in dialog
{
"type": "ir.actions.act_window",
"res_model": "product.product",
"views": [[False, "form"]],
"res_id": a_product_id,
"target": "new",
}In-Database Fields
When defining actions from XML data files:
| Attribute | Description |
|---|---|
view_mode | Comma-separated list of view types (e.g., list,form) |
view_ids | Many2many to view objects |
view_id | Specific view to add to views list |
Using ir.actions.act_window.view
<record model="ir.actions.act_window.view" id="test_action_tree">
<field name="sequence" eval="1"/>
<field name="view_mode">list</field>
<field name="view_id" ref="view_test_tree"/>
<field name="act_window_id" ref="test_action"/>
</record>---
Server Actions
ir.actions.server - Allow triggering complex server code from any valid action location.
Key Attributes
| Attribute | Type | Description |
|---|---|---|
id | int | In-database identifier |
model_id | ref | Odoo model linked to the action |
state | string | Type of action: code, object_create, object_write, multi |
code | string | Python code to execute |
State: code
<record model="ir.actions.server" id="print_instance">
<field name="name">Res Partner Server Action</field>
<field name="model_id" ref="model_res_partner"/>
<field name="state">code</field>
<field name="code">
raise Warning(record.name)
</field>
</record>Returning next action
<record model="ir.actions.server" id="open_form">
<field name="name">Open Form Action</field>
<field name="model_id" ref="model_res_partner"/>
<field name="state">code</field>
<field name="code">
if record.some_condition():
action = {
"type": "ir.actions.act_window",
"view_mode": "form",
"res_model": record._name,
"res_id": record.id,
}
</field>
</record>State: object_create
| Attribute | Description |
|---|---|
crud_model_id | Model in which to create a new record |
link_field_id | Many2one field on which to set newly created record |
fields_lines | Fields to override when creating |
State: object_write
Updates the current record(s) following fields_lines specifications.
State: multi
Executes several actions given through child_ids.
Evaluation Context
Available variables in server actions:
model- Model object linked to the actionrecord/records- Record/recordset on which the action is triggeredenv- Odoo Environmentdatetime,dateutil,time,timezone- Python moduleslog(message, level='info')- Logging functionWarning- Constructor for Warning exception
---
Report Actions
ir.actions.report - Triggers the printing of a report.
Key Attributes
| Attribute | Type | Description |
|---|---|---|
name | string | Used as file name if print_report_name not specified |
model | string | Model your report will be about |
report_type | string | qweb-pdf or qweb-html |
report_name | string | External ID of the qweb template |
print_report_name | string | Python expression for report name |
groups_id | Many2many | Groups allowed to view/use the report |
multi | boolean | If True, not displayed on form view |
paperformat_id | Many2one | Paper format to use |
attachment_use | boolean | Generate once, then reprint from stored report |
attachment | string | Python expression for attachment name |
Print Menu Integration
If you define your report through a <record> and want it in the Print menu:
<record id="my_report" model="ir.actions.report">
<field name="name">My Report</field>
<field name="model">my.model</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">my_module.my_template</field>
<field name="binding_model_id" ref="model_my_model"/>
</record>---
Client Actions
ir.actions.client - Triggers an action implemented entirely in the client.
Key Attributes
| Attribute | Type | Description |
|---|---|---|
tag | string | Client-side identifier of the action |
params | dict | Additional data to send to the client |
target | string | current, fullscreen, or new |
{
"type": "ir.actions.client",
"tag": "pos.ui"
}Tells the client to start the Point of Sale interface.
---
URL Actions
ir.actions.act_url - Allow opening a URL (website/web page).
Key Attributes
| Attribute | Type | Description |
|---|---|---|
url | string | The address to open |
target | string | new, self, or download |
{
"type": "ir.actions.act_url",
"url": "https://odoo.com",
"target": "self",
}---
Scheduled Actions (Cron)
ir.cron - Actions triggered automatically on a predefined frequency.
Key Attributes
| Attribute | Type | Description |
|---|---|---|
name | string | Name of the scheduled action |
interval_number | int | Number of interval_type units between executions |
interval_type | string | minutes, hours, days, weeks, months |
model_id | ref | Model on which this action will be called |
code | string | Code content of the action |
nextcall | datetime | Next planned execution date |
priority | int | Priority when executing multiple actions |
Writing cron functions
When writing cron functions, batch the progress to avoid blocking workers:
def _cron_do_something(self, *, limit=300):
domain = [('state', '=', 'ready')]
records = self.search(domain, limit=limit)
records.do_something()
# notify progression
remaining = 0 if len(records) == limit else self.search_count(domain)
self.env['ir.cron']._commit_progress(len(records), remaining=remaining)Managing resources between batches
def _cron_do_something(self):
assert self.env.context.get('cron_id'), "Run only inside cron jobs"
domain = [('state', '=', 'ready')]
records = self.search(domain)
self.env['ir.cron']._commit_progress(remaining=len(records))
with open_some_connection() as conn:
for record in records:
record = record.try_lock_for_update().filtered_domain(domain)
if not record:
continue
try:
record.do_something(conn)
if not self.env['ir.cron']._commit_progress(1):
break
except Exception:
self.env.cr.rollback()Running cron functions
Do not call cron functions directly. Use:
IrCron.method_direct_trigger()- for testingIrCron._trigger()- for scheduled execution
Security Measures
- If a scheduled action encounters an error or timeout 3 consecutive times, it skips current execution
- If it fails 5 consecutive times over 7 days, it is deactivated and notifies the DB admin
- A hard-limit exists for cron execution at the database level
---
Action Bindings
Actions can be bound to models to appear in contextual menus.
Binding Attributes
| Attribute | Type | Description |
|---|---|---|
binding_model_id | Many2one | Model the action is bound to (use model_id for Server Actions) |
binding_type | string | action (default) or report |
binding_view_types | string | Comma-separated list: list, form, or list,form (default) |
Binding Type: action
Action appears in the Action contextual menu.
Binding Type: report
Action appears in the Print contextual menu.
---
References
- Source: Odoo 19 documentation
/doc/developer/reference/backend/actions.rst
Odoo 19 Controller Guide
Guide for creating HTTP controllers and routes in Odoo 19.
Table of Contents
---
Controllers
Controllers provide extensibility similar to models, but with a separate mechanism (since database may not be available).
Controllers are created by inheriting from odoo.http.Controller:
from odoo import http
class MyController(http.Controller):
@http.route('/some_url', auth='public')
def handler(self):
return stuff()Controller Inheritance
To override a controller, inherit from its class and override methods:
class Extension(MyController):
@http.route()
def handler(self):
do_before()
return super(Extension, self).handler()Important:
- Always re-apply
@http.route()decorator to keep route visible - Without decorator, method is "unpublished"
- Decorator arguments override previous ones
Change Authentication
class Restrict(MyController):
@http.route(auth='user')
def handler(self):
return super(Restrict, self).handler()This changes /some_url from public to user (requires login).
---
Routes
Route Decorator
@http.route() defines routing for controller methods.
@http.route('/hello', auth='public', website=True)
def hello(self):
return "Hello World!"Route Parameters
| Parameter | Description |
|---|---|
route | Route path(s) (string or list) |
auth | Authentication type (public, user, none) |
methods | Allowed HTTP methods (GET, POST, etc.) |
type | Response type (http, json) |
website | Boolean: bind to current website |
csrf | Boolean: CSRF protection (default: True) |
sitemap | Boolean or sitemap config |
Multiple Routes
@http.route(['/hello', '/bonjour'], auth='public')
def hello_bonjour(self):
return "Hello or Bonjour!"HTTP Methods
@http.route('/api/data', methods=['GET'], auth='user', type='json')
def get_data(self):
return {'data': 'value'}
@http.route('/api/data', methods=['POST'], auth='user', type='json')
def post_data(self, **kwargs):
return {'result': 'created'}---
Authentication
Authentication Types
| Type | Description |
|---|---|
public | No authentication required |
user | Requires active user session |
none | No authentication, no session |
website | Public with website support |
Examples
# Public route
@http.route('/page', auth='public')
def public_page(self):
return "Everyone can see this"
# User-only route
@http.route('/my-account', auth='user')
def user_page(self):
return "Only logged users can see this"
# No authentication
@http.route('/api/status', auth='none', type='json')
def status(self):
return {'status': 'ok'}Current User
@http.route('/profile', auth='user')
def profile(self):
# Access current user
user = http.request.env.user
return f"Hello, {user.name}"---
Request
The request object is automatically set on odoo.http.request at the start of each request.
Request Properties
| Property | Description |
|---|---|
httprequest | Original Werkzeug request |
env | Odoo environment for current request |
db | Current database |
uid | Current user id |
context | Request context |
session | Session |
cr | Database cursor |
lang | Current language |
registry | Model registry |
Example
@http.route('/info', auth='user')
def info(self):
request = http.request
user = request.env.user
company = request.env.company
return f"{user.name} @ {company.name}"Session
@http.route('/set-value', auth='public', methods=['POST'])
def set_value(self, key, value):
http.request.session[key] = value
return "OK"
@http.route('/get-value', auth='public')
def get_value(self, key):
return http.request.session.get(key, 'not set')---
Response
HTTP Response
Return string for HTML, dict for JSON:
# HTML response
@http.route('/html', auth='public', type='http')
def html_response(self):
return "<h1>Hello</h1>"
# JSON response
@http.route('/json', auth='public', type='json')
def json_response(self):
return {'key': 'value'}Redirect
from odoo.http import redirect
@http.route('/old-url', auth='public')
def old_url(self):
return redirect('/new-url')File Response
@http.route('/download', auth='user')
def download_file(self):
file_content = b'file data'
headers = [
('Content-Type', 'application/pdf'),
('Content-Disposition', 'attachment; filename="file.pdf"'),
]
return request.make_response(
file_content,
headers
)---
JSON-RPC
JSON Controller
@http.route('/api/search', auth='user', type='json', methods=['POST'])
def json_search(self, model, domain, fields=None):
Model = http.request.env[model]
records = Model.search(domain)
if fields:
records = records.read(fields)
else:
records = records.read()
return {'result': records}Call from JavaScript
rpc("/api/search", {
model: "res.partner",
domain: [["is_company", "=", true]],
fields: ["name", "email"],
}).then(function (result) {
console.log(result);
});---
Website Routes
Website Page
class WebsiteController(http.Controller):
@http.route('/my-page', auth='public', website=True)
def my_page(self):
return http.request.render('my_module.my_page_template', {
'title': 'My Page',
})Template
<template id="my_page_template" name="My Page">
<t t-call="website.layout">
<div id="wrap">
<div class="oe_structure"/>
<h1 t-out="title"/>
<div class="oe_structure"/>
</div>
</t>
</template>---
Controllers Best Practices
Always Return a Value
# BAD: no return
@http.route('/bad', auth='public')
def bad(self):
pass
# GOOD: return something
@http.route('/good', auth='public')
def good(self):
return "Response"Use Proper Authentication
# BAD: public for sensitive data
@http.route('/sensitive', auth='public')
def sensitive(self):
return secret_data()
# GOOD: user authentication
@http.route('/sensitive', auth='user')
def sensitive(self):
return secret_data()CSRF Protection
CSRF is enabled by default for POST. Disable with caution:
@http.route('/webhook', auth='public', methods=['POST'], csrf=False)
def webhook(self):
# External webhook, no CSRF token
return "OK"---
References
- Source: Odoo 19 documentation
/doc/developer/reference/backend/http.rst
Odoo 19 Data Files Guide
Guide for working with Odoo 19 data files (XML and CSV), records, and shortcuts.
Table of Contents
---
XML Data Files Structure
The main way to define data in Odoo is via XML data files:
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<operation/>
...
</odoo>noupdate Flag
If content should only be applied once:
<odoo>
<data noupdate="1">
<!-- Only loaded when installing the module -->
<operation/>
</data>
<!-- (Re)Loaded at install and update -->
<operation/>
</odoo>---
Record Tag
record - Defines or updates a database record.
Attributes
| Attribute | Type | Required | Description |
|---|---|---|---|
model | string | Yes | Name of the model to create/update |
id | string | No\* | External identifier for this record (strongly recommended) |
context | dict | No | Context to use when creating |
forcecreate | bool | No | In update mode, create if doesn't exist (default: True) |
\*Required for record updates; recommended for creation
Example
<record id="partner_1" model="res.partner">
<field name="name">Odoo</field>
<field name="is_company" eval="True"/>
<field name="customer_rank" eval="1"/>
</record>---
Field Tag
Each record can have field tags defining values.
Attributes
| Attribute | Type | Description |
|---|---|---|
name | string | Required. Name of the field to set |
ref | string | External ID to look up and set |
search | domain | Search domain, result set as field value |
eval | string | Python expression to evaluate |
type | string | Interpret field content (see types below) |
Value Methods
Nothing (False)
<field name="description"/>search
For relational fields, evaluates a domain and sets the result:
<field name="partner_id" search="[('name', '=', 'Odoo')]"/>Only first result used for Many2one fields.
ref
Look up an external ID:
<field name="country_id" ref="base.vn"/>
<field name="user_id" ref="base.user_admin"/>type
Available types:
| Type | Description |
|---|---|
xml, html | Extract children as document, evaluate external IDs |
file | Ensure content is valid file path, saves module,path |
char | Set content directly without alterations |
base64 | Base64-encode content (use with file attribute) |
int, float | Convert to number |
list, tuple | Contains value elements |
<field name="description" type="xml">
<p>Hello <t t-out="user.name"/></p>
</field>
<field name="image" type="base64" file="static/img/logo.png"/>eval
Evaluate a Python expression:
<field name="active" eval="True"/>
<field name="date_today" eval="datetime.date.today()"/>
<field name="partner_id" eval="ref('base.main_partner')"/>Evaluation context:
time,datetime,timedelta,relativedeltamodulesref()function to resolve external IDsobjfor current field's model
---
Delete Tag
delete - Removes records.
Attributes
| Attribute | Type | Required | Description |
|---|---|---|---|
model | string | Yes | Model in which to delete |
id | string | No\* | External ID of record to remove |
search | domain | No\* | Domain to find records to remove |
\*Exclusive: use either id or search
Examples
<!-- Delete by external ID -->
<delete model="ir.ui.view" id="my_module.unwanted_view"/>
<!-- Delete by search -->
<delete model="ir.ui.menu" search="[('name', '=', 'Old Menu')]"/>---
Function Tag
function - Calls a method on a model.
Attributes
| Attribute | Type | Required | Description |
|---|---|---|---|
model | string | Yes | Model to call method on |
name | string | Yes | Name of method to call |
Parameters
Via eval (should evaluate to sequence):
<function model="res.partner" name="send_inscription_notice"
eval="[[ref('partner_1'), ref('partner_2')]]"/>Via value elements:
<function model="res.users" name="send_vip_inscription_notice">
<function eval="[[('vip','=',True)]]" model="res.partner" name="search"/>
</function>---
Shortcuts
Because some structural models are complex, data files provide shorter alternatives.
menuitem
Defines an ir.ui.menu record with defaults:
| Attribute | Description |
|---|---|
parent | External ID of parent menu, or interpret name as /-separated sequence |
name | Menu name (or get from linked action) |
groups | Comma-separated external IDs for res.groups (prefix - removes group) |
action | External ID of action to execute |
id | External identifier |
<menuitem id="my_module_menu_root" name="My Module" web_icon="my_module,static/description/icon.png"/>
<menuitem id="my_module_menu" name="My Model" parent="my_module_menu_root" action="my_module_action"/>template
Creates a QWeb view requiring only the arch section:
| Attribute | Description |
|---|---|
id | External identifier |
name | View name |
inherit_id | External ID of parent view |
priority | View priority |
primary | If True with inherit_id, defines as primary |
groups | Comma-separated group external IDs |
active | Whether view is active (for inheritance views) |
<template id="my_template" name="My Template">
<t t-call="website.layout">
<div class="oe_structure">
<h1>My Content</h1>
</div>
</t>
</template>asset
Creates an ir.asset record:
<asset id="website_something.some_style_asset" name="Some style asset" active="False">
<bundle>web.assets_frontend</bundle>
<path>website_something/static/src/some_style.scss</path>
</asset>---
CSV Data Files
XML is verbose for bulk creation. CSV files are simpler for same-model records.
Structure
- File name:
{model_name}.csv - First row: fields to write, special field
idfor external IDs - Each row: creates a new record
Example: res.country.state.csv
id,country_id,name,code
state_1_us,country_us,Alabama,AL
state_2_us,country_us,Alaska,AK
state_3_us,country_us,Arizona,AZNotes
- First column: external ID for creation/update
- Second column: external ID of country object to link to
- Third column:
namefield value - Fourth column:
codefield value
---
References
- Source: Odoo 19 documentation
/doc/developer/reference/backend/data.rst
Odoo 19 Decorator Guide
Guide for using @api decorators in Odoo 19: computed fields, validation, onchange, and more.
Table of Contents
- Method Decorators
- @api.depends
- @api.constrains
- @api.ondelete
- @api.onchange
- @api.model
- @api.model_create_multi
- @api.autovacuum
- @api.private
- @api.returns
---
Method Decorators
Decorators in Odoo 19 are in odoo.api module:
from odoo import api, models
class MyModel(models.Model):
_name = 'my.model'
@api.depends('field')
def _compute_method(self):
pass---
@api.depends
For computed fields. Specifies dependencies that trigger recomputation.
Basic Usage
total = fields.Float(compute='_compute_total', store=True)
@api.depends('value', 'tax')
def _compute_total(self):
for record in self:
record.total = record.value + record.value * record.taxDotted Paths
Can use dotted paths for relational fields:
@api.depends('partner_id.email')
def _compute_email(self):
for record in self:
record.email = record.partner_id.emailMultiple Fields
Compute multiple fields:
discount_value = fields.Float(compute='_apply_discount')
total = fields.Float(compute='_apply_discount')
@api.depends('value', 'discount')
def _apply_discount(self):
for record in self:
discount = record.value * record.discount
record.discount_value = discount
record.total = record.value - discountSearch on Computed Field
upper_name = fields.Char(compute='_compute_upper', search='_search_upper')
@api.depends('name')
def _compute_upper(self):
for record in self:
record.upper_name = record.name.upper() if record.name else False
def _search_upper(self, operator, value):
if operator == 'like':
operator = 'ilike'
return [('name', operator, value)]Inverse Method
Allow setting computed field:
document = fields.Char(compute='_get_document', inverse='_set_document')
@api.depends('document_path')
def _get_document(self):
for record in self:
with open(record.document_path) as f:
record.document = f.read()
def _set_document(self):
for record in self:
if not record.document:
continue
with open(record.document_path) as f:
f.write(record.document)---
@api.constrains
For validation. Called on create and write.
Basic Usage
@api.constrains('email')
def _check_email(self):
for record in self:
if not tools.email_validation(record.email):
raise ValidationError("Invalid email")Multiple Fields
@api.constrains('date_start', 'date_end')
def _check_dates(self):
for record in self:
if record.date_end < record.date_start:
raise ValidationError("End date must be after start date")No Dotted Paths
Unlike @api.depends, cannot use dotted paths:
# BAD: dotted path not supported
@api.constrains('partner_id.email')
def _check_email(self):
pass
# GOOD: use simple field name
@api.constrains('partner_id')
def _check_email(self):
for record in self:
if not tools.email_validation(record.partner_id.email):
raise ValidationError("Invalid email")---
@api.ondelete
For delete validation (Odoo 18+).
@api.ondelete(at_uninstall=False)
def _unlink_if_not_draft(self):
if any(rec.state != 'draft' for rec in self):
raise UserError("Cannot delete non-draft records")Parameters
| Parameter | Description |
|---|---|
at_uninstall | If False, allows deletion when module is uninstalled |
Why Use @api.ondelete?
- Better than overriding `unlink()`: Doesn't break module uninstall
- Clear intent: Explicitly for delete validation
- Automatic: Called before deletion
Unlink Override (Anti-pattern)
# BAD: breaks module uninstall
def unlink(self):
if any(rec.state != 'draft' for rec in self):
raise UserError("Cannot delete non-draft records")
return super().unlink()---
@api.onchange
For form UI updates when field values change.
Basic Usage
@api.onchange('partner_id')
def _onchange_partner_id(self):
if self.partner_id:
self.email = self.partner_id.email
self.phone = self.partner_id.phoneMultiple Fields
@api.onchange('country_id', 'state_id')
def _onchange_location(self):
if self.country_id:
# Update zip format
passNo CRUD Operations
Important: onchange methods should not perform CRUD operations.
# BAD: create in onchange
@api.onchange('field1')
def _onchange_field1(self):
self.env['another.model'].create({'name': 'test'})
# GOOD: only modify current record
@api.onchange('field1')
def _onchange_field1(self):
self.field2 = 'computed value'Return Warning
@api.onchange('amount')
def _onchange_amount(self):
if self.amount < 0:
return {
'warning': {
'title': "Warning",
'message': "Amount cannot be negative",
}
}---
@api.model
For model-level methods that don't depend on self.
Usage
@api.model
def get_default_values(self):
return {
'field1': 'value1',
'field2': 'value2',
}Can be called on any recordset
# Can call on any recordset (self may be empty)
record = self.env['my.model'].browse([1, 2, 3])
defaults = record.get_default_values()---
@api.model_create_multi
For handling batch create operations.
@api.model_create_multi
def create(self, vals_list):
# Add default values
for vals in vals_list:
vals.setdefault('field', 'default')
return super().create(vals_list)Why Use It?
Odoo 17+ creates records in batches by default. This decorator ensures proper handling.
---
@api.autovacuum
For methods to run by cron vacuuem.
@api.autovacuum
def _gc_entries(self):
# Clean old records
domain = [('create_date', '<', date.today() - timedelta(days=90)])
self.search(domain).unlink()---
Decorator Decision Tree
Need to define field behavior?
├── Field computed from other fields → @api.depends
│ └── CAN use dotted paths
├── Validate data → @api.constrains
│ └── CANNOT use dotted paths
├── Prevent record deletion → @api.ondelete
└── Update form UI → @api.onchange
└── NO CRUD operations allowed
Need to define method behavior?
├── Method-level, doesn't depend on self → @api.model
├── Mark method as non-RPC callable → @api.private
└── Normal record method → no decorator needed---
@api.private
New in Odoo 19. Marks a method as not callable via RPC (external API).
Usage
from odoo import api, models
class MyModel(models.Model):
_name = 'my.model'
@api.private
def _internal_computation(self):
"""This method cannot be called via XML-RPC/JSON-RPC."""
return self._do_heavy_work()
def public_action(self):
"""This method CAN be called via RPC."""
return self._internal_computation()When to Use
- Methods that should only be called internally (not via API/button)
- Replaces the convention of prefixing with
_for security-critical methods - ORM override methods that you don't want exposed
@api.private vs Underscore Convention
# Convention: underscore prefix = private (but NOT enforced by ORM)
def _do_stuff(self): # Cannot be called from action buttons, but still convention-based
pass
# Odoo 19: @api.private = explicitly enforced by framework
@api.private
def compute_sensitive_data(self): # Name doesn't need underscore
pass---
---
@api.returns
Purpose: Specify the return model of a method for API compatibility.
from odoo import api, models
class SaleOrder(models.Model):
_name = 'sale.order'
@api.returns('res.partner')
def get_partner(self):
"""Returns partner record(s)"""
return self.mapped('partner_id')
@api.returns('self')
def copy(self, default=None):
"""Returns new record(s) of same model"""
return super().copy(default)Common usage in Odoo base:
# Many methods use @api.returns
@api.returns('mail.message', lambda value: value.id)
def message_post(self, ...):
# Post a message, return the message
return message---
Common Patterns
Computed Field with Inverse
total = fields.Float(compute='_compute_total', inverse='_inverse_total', store=True)
@api.depends('subtotal', 'tax')
def _compute_total(self):
for record in self:
record.total = record.subtotal + record.tax
def _inverse_total(self):
for record in self:
record.subtotal = record.total - record.taxValidation with Constraints
@api.constrains('age')
def _check_age(self):
for record in self:
if record.age < 18:
raise ValidationError("Must be 18 or older")Delete Validation
@api.ondelete(at_uninstall=False)
def _unlink_if_not_cancelled(self):
if any(rec.state != 'cancel' for rec in self):
raise UserError("Only cancelled records can be deleted")Onchange for Defaults
@api.onchange('partner_id')
def _onchange_partner_id(self):
if self.partner_id:
self.lang = self.partner_id.lang
self.user_id = self.partner_id.user_id---
References
- Source: Odoo 19 documentation
/doc/developer/reference/backend/orm.rst
Odoo 19 Development Guide
Guide for developing Odoo 19 modules: creating modules, manifest, structure, and common patterns.
Table of Contents
---
Module Structure
Standard Structure
my_module/
├── __init__.py
├── __manifest__.py
├── models/
│ ├── __init__.py
│ └── my_model.py
├── views/
│ └── my_model_views.xml
├── security/
│ ├── ir.model.access.csv
│ └── my_module_security.xml
├── data/
│ └── my_module_data.xml
├── demo/
│ └── demo_data.xml
├── migrations/
│ └── 19.0.1.0/
│ └── post-migration.py
├── tests/
│ ├── __init__.py
│ └── test_my_model.py
├── wizard/
│ ├── __init__.py
│ └── my_wizard.py
├── controllers/
│ ├── __init__.py
│ └── my_controller.py
├── static/
│ ├── src/
│ │ ├── js/
│ │ ├── xml/
│ │ └── scss/
│ └── description/
│ └── icon.png
└── report/
└── my_report.xml---
Creating a Module
Step 1: Create Directory
mkdir -p my_module/models
mkdir -p my_module/views
mkdir -p my_module/security
mkdir -p my_module/static/src/jsStep 2: Create __init__.py
# __init__.py
from . import models
from . import controllers# models/__init__.py
from . import my_modelStep 3: Create Model
# models/my_model.py
from odoo import models, fields
class MyModel(models.Model):
_name = 'my.model'
_description = 'My Model'
name = fields.Char(string="Name", required=True)
description = fields.Text(string="Description")
active = fields.Boolean(string="Active", default=True)Step 4: Create Manifest
# __manifest__.py
{
'name': 'My Module',
'version': '1.0.0',
'category': 'Tools',
'summary': 'My awesome module',
'description': """
My Module
==========
This module does something useful.
""",
'author': 'Your Name',
'website': 'https://github.com/yourname/my_module',
'license': 'LGPL-3',
'depends': ['base'],
'data': [
'security/my_module_security.xml',
'views/my_model_views.xml',
],
'demo': [
'demo/demo_data.xml',
],
'assets': {
'web.assets_backend': [
'my_module/static/src/js/my_script.js',
],
},
'installable': True,
'application': False,
}---
Manifest File
Required Fields
{
'name': 'My Module', # Required
'version': '1.0', # Optional
'depends': ['base'], # Optional but recommended
}Common Fields
{
# Information
'name': 'My Module',
'version': '1.0.0',
'category': 'Tools',
'summary': 'Short description',
'description': 'Long description',
'author': 'Author Name',
'website': 'https://example.com',
'license': 'LGPL-3',
# Dependencies
'depends': ['base', 'web'],
'data': ['views/views.xml'],
'demo': ['demo/demo.xml'],
# Assets
'assets': {
'web.assets_backend': [
'my_module/static/src/js/file.js',
],
},
# Other
'application': False,
'installable': True,
'auto_install': False,
}---
Models
Basic Model
from odoo import models, fields
class MyModel(models.Model):
_name = 'my.model'
_description = 'My Model'
_order = 'name'
name = fields.Char(string="Name", required=True)
code = fields.Char(string="Code")
description = fields.Text(string="Description")
active = fields.Boolean(string="Active", default=True)Model Inheritance (Extension)
class Partner(models.Model):
_inherit = 'res.partner'
my_field = fields.Char(string="My Field")Model Inheritance (Prototype)
class NewModel(models.Model):
_name = 'new.model'
_inherit = 'base.model'
# Inherits all fields and methods from base.model
my_field = fields.Char(string="My Field")---
Views
Create Views
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<!-- List View -->
<record id="view_my_model_tree" model="ir.ui.view">
<field name="name">my.model.tree</field>
<field name="model">my.model</field>
<field name="arch" type="xml">
<list string="My Models">
<field name="name"/>
<field name="code"/>
<field name="active"/>
</list>
</field>
</record>
<!-- Form View -->
<record id="view_my_model_form" model="ir.ui.view">
<field name="name">my.model.form</field>
<field name="model">my.model</field>
<field name="arch" type="xml">
<form string="My Model">
<sheet>
<group>
<group>
<field name="name"/>
<field name="code"/>
</group>
<group>
<field name="active"/>
</group>
</group>
<notebook>
<page string="Description">
<field name="description"/>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<!-- Search View -->
<record id="view_my_model_search" model="ir.ui.view">
<field name="name">my.model.search</field>
<field name="model">my.model</field>
<field name="arch" type="xml">
<search string="Search My Models">
<field name="name"/>
<field name="code"/>
<filter string="Active" name="active" domain="[('active','=',True)]"/>
</search>
</field>
</record>
<!-- Action -->
<record id="action_my_model" model="ir.actions.act_window">
<field name="name">My Models</field>
<field name="res_model">my.model</field>
<field name="view_mode">list,form</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Create your first my.model!
</p>
</field>
</record>
<!-- Menu -->
<menuitem id="menu_my_model_root" name="My Module" web_icon="my_module,static/description/icon.png"/>
<menuitem id="menu_my_model" name="My Models" parent="menu_my_model_root" action="action_my_model"/>
</odoo>---
Security
Access Rights (CSV)
File: security/ir.model.access.csv
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_my_model_user,my.model.user,model_my_model,base.group_user,1,1,1,0
access_my_model_manager,my.model.manager,model_my_model,group_my_module_manager,1,1,1,1Record Rules (XML)
<odoo>
<data noupdate="1">
<!-- User can only see their own records -->
<record id="my_model_user_rule" model="ir.rule">
<field name="name">My Model: user can see own records</field>
<field name="model_id" ref="model_my_model"/>
<field name="domain_force">[('create_uid', '=', user.id)]</field>
<field name="groups" eval="[(4, ref('base.group_user'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="True"/>
<field name="perm_unlink" eval="True"/>
</record>
<!-- Manager can see all -->
<record id="my_model_manager_rule" model="ir.rule">
<field name="name">My Model: manager sees all</field>
<field name="model_id" ref="model_my_model"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('group_my_module_manager'))]"/>
</record>
</data>
</odoo>---
Data Files
Create Records
<odoo>
<record id="my_model_1" model="my.model">
<field name="name">Record 1</field>
<field name="code">R001</field>
</record>
<record id="my_model_2" model="my.model">
<field name="name">Record 2</field>
<field name="code">R002</field>
</record>
</odoo>noupdate Flag
<odoo>
<data noupdate="1">
<!-- Only loaded on install -->
<record id="demo_data" model="my.model">
<field name="name">Demo</field>
</record>
</data>
<!-- Loaded on install and update -->
<record id="core_data" model="my.model">
<field name="name">Core</field>
</record>
</odoo>---
Assets
JavaScript
'assets': {
'web.assets_backend': [
'my_module/static/src/js/my_widget.js',
'my_module/static/src/js/my_view.js',
],
'web.assets_frontend': [
'my_module/static/src/js/frontend.js',
],
},CSS/SCSS
'assets': {
'web.assets_backend': [
'my_module/static/src/scss/my_style.scss',
],
'web.assets_frontend': [
'my_module/static/src/scss/frontend.scss',
],
},---
Wizards
TransientModel
from odoo import models, fields
class MyWizard(models.TransientModel):
_name = 'my.wizard'
_description = 'My Wizard'
date = fields.Date(string="Date", required=True, default=fields.Date.context_today)
note = fields.Text(string="Note")
def action_confirm(self):
# Do something
return {'type': 'ir.actions.act_window_close'}Wizard View
<record id="view_my_wizard_form" model="ir.ui.view">
<field name="name">my.wizard.form</field>
<field name="model">my.wizard</field>
<field name="arch" type="xml">
<form string="My Wizard">
<group>
<field name="date"/>
<field name="note"/>
</group>
<footer>
<button name="action_confirm" string="Confirm" type="object" class="btn-primary"/>
<button string="Cancel" class="btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>Action to Open Wizard
def action_open_wizard(self):
return {
'type': 'ir.actions.act_window',
'name': 'My Wizard',
'res_model': 'my.wizard',
'view_mode': 'form',
'target': 'new',
'context': {
'default_date': fields.Date.context_today(self),
}
}---
Common Patterns
State Field
state = fields.Selection([
('draft', 'Draft'),
'confirmed', 'Confirmed'),
('done', 'Done'),
], string='State', default='draft', tracking=True)Create Default from Context
def default_get(self, fields_list):
defaults = super().default_get(fields_list)
if 'field' in fields_list:
defaults['field'] = self.env.context.get('default_field', 'default')
return defaultsName Search
def name_search(self, name='', args=None, operator='ilike', limit=100):
args = args or []
if name:
args = [('name', operator, name)] + args
return super().name_search(name, args, operator, limit)---
References
- Based on Odoo 19 best practices
Odoo 19 Field Guide
Guide for defining fields in Odoo 19: field types, parameters, computed fields, and relational fields.
Table of Contents
- Field Types
- Basic Fields
- Advanced Fields
- Date Fields
- Relational Fields
- Computed Fields
- Related Fields
- Field Parameters
---
Field Types
Type Summary
| Category | Types |
|---|---|
| Basic | Boolean, Char, Float, Integer |
| Advanced | Binary, Html, Image, Monetary, Selection, Text |
| Date | Date, Datetime |
| Relational | Many2one, One2many, Many2many |
| Pseudo | Reference, Many2oneReference |
---
Basic Fields
Boolean
True/False value.
active = fields.Boolean(string="Active", default=True)
is_company = fields.Boolean("Is Company")Char
String with limited length.
name = fields.Char(string="Name", required=True)
code = fields.Char("Code", size=10)Float
Floating-point number.
price = fields.Float(string="Price")
weight = fields.Float(digits="Stock Weight")Digits
# Using named precision
price = fields.Float(digits="Product Price")
# Custom precision (12 digits, 2 decimal)
amount = fields.Float(digits=(12, 2))Integer
Whole number.
count = fields.Integer(string="Count")
priority = fields.Integer(default=10)---
Advanced Fields
Binary
Binary data (files).
file = fields.Binary(string="File")
attachment = fields.Binary("Attachment")Html
HTML content (rich text).
description = fields.Html(string="Description")
notes = fields.Html("Notes", sanitize=False)Image
Enhanced Binary for images with thumbnails.
image = fields.Image(string="Image")
logo = fields.Image("Logo", max_width=1024, max_height=1024)Monetary
Monetary amount with currency.
amount = fields.Monetary(string="Amount", currency_field="currency_id")Requires a currency field (default: currency_id).
Selection
Selection from predefined list.
state = fields.Selection([
('draft', 'Draft'),
('confirmed', 'Confirmed'),
('done', 'Done'),
], string="State", default='draft')
# Or using model reference
type = fields.Selection([
('a', 'A'),
('b', 'B'),
], string="Type")Dynamic selection (from another model):
type_id = fields.Many2one('my.type', string="Type")Text
Long text (unlimited).
description = fields.Text(string="Description")
notes = fields.Text("Notes")---
Date Fields
Date
Date without time.
date = fields.Date(string="Date")
deadline = fields.Date(default=fields.Date.context_today)Date Methods
from odoo.fields import Date
# Today
today = Date.context_today(self)
# Add/subtract
next_week = Date.add(Date.today(), weeks=1)
last_month = Date.subtract(Date.today(), months=1)
# Start/end of period
start_of_month = Date.start_of(Date.today(), 'month')
end_of_month = Date.end_of(Date.today(), 'month')
# To string
date_str = Date.to_string(Date.today())
# From string
date_obj = Date.to_date('2023-01-01')Datetime
Date and time.
datetime = fields.Datetime(string="DateTime")
create_date = fields.Datetime(default=fields.Datetime.now)Datetime Methods
from odoo.fields import Datetime
# Now
now = Datetime.now()
# Context timestamp (user timezone)
timestamp = Datetime.context_timestamp(self, datetime)
# Add/subtract
next_hour = Datetime.add(Datetime.now(), hours=1)
# Start/end of period
start_of_day = Datetime.start_of(Datetime.now(), 'day')
end_of_day = Datetime.end_of(Datetime.now(), 'day')
# Convert
date_obj = Datetime.to_datetime('2023-01-01 12:00:00')
date_str = Datetime.to_string(Datetime.now())Timezone
Datetime fields are stored as UTC. Conversion is client-side.
---
Relational Fields
Many2one
Many-to-one relation (foreign key).
partner_id = fields.Many2one('res.partner', string="Partner")
user_id = fields.Many2one('res.users', 'User', default=lambda self: self.env.user)Parameters
| Parameter | Description |
|---|---|
comodel_name | Related model name |
string | Field label |
required | Whether required |
ondelete | What to do when related record is deleted (cascade, set null, restrict) |
domain | Domain filter |
context | Context for operations |
default | Default value |
index | Add database index |
partner_id = fields.Many2one(
'res.partner',
string="Customer",
required=True,
ondelete='cascade',
domain=[('customer_rank', '>', 0)],
default=lambda self: self.env.partner,
)One2many
One-to-many relation (inverse of Many2one).
line_ids = fields.One2many('sale.order.line', 'order_id', string="Order Lines")Parameters
| Parameter | Description |
|---|---|
comodel_name | Related model name |
inverse_name | Inverse Many2one field |
string | Field label |
order_id = fields.Many2one('sale.order', 'Order')
line_ids = fields.One2many(
'sale.order.line',
'order_id',
string="Order Lines",
)Many2many
Many-to-many relation.
tag_ids = fields.Many2many('crm.tag', string="Tags")
category_ids = fields.Many2many('product.category', 'product_category_rel', 'product_id', 'category_id')Parameters
| Parameter | Description |
|---|---|
comodel_name | Related model name |
relation | Relation table name (auto if not specified) |
column1 | Column for this model |
column2 | Column for related model |
string | Field label |
# Simple (auto relation table)
tag_ids = fields.Many2many('crm.tag', string="Tags")
# Custom relation table
product_ids = fields.Many2many(
'product.product',
'my_rel',
'my_id',
'product_id',
string="Products",
)Commands
Use Command class for One2many/Many2many operations:
from odoo.fields import Command
# Create
{
'line_ids': [
Command.create({'product_id': 1, 'qty': 10}),
Command.create({'product_id': 2, 'qty': 5}),
]
}
# Update
{
'line_ids': [
Command.update(line_id, {'qty': 20}),
]
}
# Delete
{
'line_ids': [
Command.delete(line_id),
]
}
# Clear all
{
'line_ids': [Command.clear()]
}
# Set (replace all)
{
'line_ids': [
Command.set([id1, id2, id3])
]
}
# Link (add without deleting existing)
{
'tag_ids': [
Command.link(tag_id),
]
}
# Unlink
{
'tag_ids': [
Command.unlink(tag_id),
]
}---
Computed Fields
Fields computed from other fields.
Basic Compute
total = fields.Float(compute='_compute_total')
@api.depends('price', 'qty')
def _compute_total(self):
for record in self:
record.total = record.price * record.qtyStore and Search
total = fields.Float(
compute='_compute_total',
store=True,
search='_search_total',
)Inverse
Allow setting computed field:
full_name = fields.Char(
compute='_compute_full_name',
inverse='_inverse_full_name',
)
@api.depends('first_name', 'last_name')
def _compute_full_name(self):
for record in self:
record.full_name = f"{record.first_name} {record.last_name}"
def _inverse_full_name(self):
for record in self:
parts = record.full_name.split(' ', 1)
record.first_name = parts[0]
record.last_name = parts[1] if len(parts) > 1 else ''---
Related Fields
Shortcut for computed fields that follow a relation.
partner_name = fields.Char(related='partner_id.name', string="Partner Name")
partner_email = fields.Char(related='partner_id.email', readonly=True)Store Related
partner_name = fields.Char(
related='partner_id.name',
string="Partner Name",
store=True,
)Dependencies
# Only recompute when partner_id changes
partner_name = fields.Char(
related='partner_id.name',
store=True,
depends=['partner_id'],
)---
Field Parameters
Common Parameters
| Parameter | Description |
|---|---|
string | Field label |
required | Whether required (create/write) |
readonly | Whether read-only |
index | Add database index |
default | Default value or callable |
help | Tooltip text |
groups | Comma-separated group IDs |
copy | Copy on duplicate (default: True) |
track_visibilty | Track changes in chatter (always, onchange, never) |
Examples
name = fields.Char(
string="Name",
required=True,
index=True,
default='Untitled',
help="Enter the name",
copy=True,
tracking=True, # Equivalent to track_visibility='always'
)---
Reserved Field Names
| Name | Purpose |
|---|---|
id | Record identifier |
display_name | Display name |
create_date, create_uid, write_date, write_uid | Access log fields |
name | Default rec_name |
active | Global visibility toggle |
state | Lifecycle stages |
parent_id | Tree structure parent |
parent_path | Tree structure path |
company_id | Multi-company field |
---
References
- Source: Odoo 19 documentation
/doc/developer/reference/backend/orm.rst
Odoo 19 Manifest Guide
Guide for configuring __manifest__.py in Odoo 19 modules.
Table of Contents
- Manifest File
- Required Fields
- Module Information
- Dependencies
- Data Files
- Assets
- Hooks
- External Dependencies
- Auto-Install
---
Manifest File
The manifest file declares a python package as an Odoo module and specifies module metadata.
File: __manifest__.py
{
'name': "A Module",
'version': '1.0',
'depends': ['base'],
'author': "Author Name",
'category': 'Category',
'description': """
Description text
""",
'data': [
'views/mymodule_view.xml',
],
'demo': [
'demo/demo_data.xml',
],
}---
Required Fields
name (str, required)
The human-readable name of the module.
'name': "My Module",---
Module Information
version (str)
Module version, should follow semantic versioning rules.
'version': '1.0.0',description (str)
Extended description in reStructuredText.
'description': """
This module does something useful.
""",author (str)
Name of the module author.
'author': "UncleCat",website (str)
Website URL for the module author.
'website': "https://github.com/unclecat",license (str, default: LGPL-3)
Distribution license.
Possible values:
GPL-2GPL-2 or any later versionGPL-3GPL-3 or any later versionAGPL-3LGPL-3Other OSI approved licenceOEEL-1(Odoo Enterprise Edition License v1.0)OPL-1(Odoo Proprietary License v1.0)Other proprietary
'license': 'LGPL-3',category (str, default: Uncategorized)
Classification category within Odoo.
Use existing categories or create hierarchies with /:
'category': 'Tools / My Category',application (bool, default: False)
Whether the module should be considered a fully-fledged application.
'application': True, # Appears in Apps menuinstallable (bool, default: True)
Whether a user can install the module from the Web UI.
'installable': False, # Hidden from Apps menumaintainer (str)
Person or entity in charge of maintenance.
'maintainer': "UncleCat",---
Dependencies
depends (list(str))
Odoo modules which must be loaded before this one.
'depends': ['base', 'web', 'sale'],Important: Module base is always installed, but you should still specify it as a dependency to ensure your module is updated when base is updated.
When a module is installed, all dependencies are installed first.
---
Data Files
data (list(str))
Data files always loaded at installation and update.
'data': [
'security/my_module_security.xml',
'views/my_model_views.xml',
'data/my_module_data.xml',
],demo (list(str))
Data files only loaded in demonstration mode.
'demo': [
'demo/demo_data.xml',
],---
Assets
assets (dict)
Definition of how static files are loaded in asset bundles.
'assets': {
'web.assets_backend': [
'my_module/static/src/js/my_script.js',
'my_module/static/src/scss/my_style.scss',
],
'web.assets_frontend': [
'my_module/static/src/js/frontend.js',
],
},---
Hooks
{pre_init, post_init, uninstall}_hook (str)
Hooks for module installation/uninstallation.
# In __init__.py
def pre_init_hook(env):
"""Executed prior to module installation"""
pass
def post_init_hook(env):
"""Executed right after module installation"""
pass
def uninstall_hook(env):
"""Executed after module uninstallation"""
pass# In __manifest__.py
'pre_init_hook': 'pre_init_hook',
'post_init_hook': 'post_init_hook',
'uninstall_hook': 'uninstall_hook',Usage: Only when setup/cleanup is extremely difficult or impossible through the API.
---
External Dependencies
external_dependencies (dict(key=list(str)))
Dictionary of Python and binary dependencies.
'external_dependencies': {
'python': ['requests', 'openpyxl'],
'bin': ['zip', 'unzip'],
},The module won't be installed if dependencies are not available.
---
Auto-Install
auto_install (bool or list(str), default: False)
If True, automatically installs if all dependencies are installed.
Used for "link modules" implementing integration between independent modules.
'auto_install': True, # Install when all dependencies are presentIf it is a list, must contain a subset of dependencies:
'auto_install': ['sale', 'crm'], # Install when both sale and crm are presentIf the list is empty, always auto-install regardless of dependencies.
'auto_install': [], # Always install---
Complete Example
{
'name': "My Awesome Module",
'version': '1.0.0',
'category': 'Tools',
'summary': 'Does something awesome',
'description': """
My Awesome Module
=================
This module adds awesome functionality to Odoo.
""",
'author': "UncleCat",
'website': "https://github.com/unclecat/my_module",
'license': 'LGPL-3',
'depends': ['base', 'web'],
'data': [
'security/my_module_security.xml',
'views/my_model_views.xml',
'data/ir_cron_data.xml',
],
'demo': [
'demo/demo_data.xml',
],
'assets': {
'web.assets_backend': [
'my_module/static/src/js/my_widget.js',
'my_module/static/src/scss/my_style.scss',
],
},
'external_dependencies': {
'python': ['requests'],
},
'application': True,
'installable': True,
'auto_install': False,
}---
References
- Source: Odoo 19 documentation
/doc/developer/reference/backend/module.rst
Odoo 19 Migration Guide
Guide for migrating modules from Odoo 17/18 to Odoo 19.
Table of Contents
---
Migration Overview
When to Migrate
- Major version upgrade: Odoo 17 → 18 → 19
- Module dependencies changed
- API breaking changes
Migration Strategy
1. Review changelog and breaking changes 2. Update __manifest__.py version 3. Run migration scripts 4. Test thoroughly 5. Update documentation
---
Key Changes
Odoo 19 Key Changes
| Area | Change |
|---|---|
| List view | Use <list> instead of <tree> |
| Dynamic attributes | Use direct attributes instead of attrs |
| Delete validation | Use @api.ondelete instead of overriding unlink() |
| Field aggregation | Use aggregator= instead of group_operator= |
| SQL queries | Use odoo.tools.SQL class |
| Batch create | Use list of dicts instead of single dict |
Odoo 18 Key Changes
| Area | Change |
|---|---|
| Views | Use <list> instead of <tree> |
| attrs | Deprecated, use direct attributes |
| ondelete | New @api.ondelete decorator |
---
Migration Scripts
Migration Script Location
my_module/
└── migrations/
└── 19.0.1.0/
├── pre-migration.py
├── end-migration.py
└── post-migration.pyMigration Script Naming
| Script | When it runs |
|---|---|
pre-migration.py | Before module update |
post-migration.py | After module update |
end-migration.py | After all migrations |
Migration Script Template
def migrate(cr, version):
"""
Migration script for Odoo 19
"""
# Your migration code here
pass---
Common Migrations
Tree to List View
Odoo 17 and earlier:
<tree string="Records">
<field name="name"/>
</tree>Odoo 18+:
<list string="Records">
<field name="name"/>
</list>attrs to Direct Attributes
Odoo 17 and earlier:
<field name="state" attrs="{'invisible': [('state', '!=', 'draft')]}"/>Odoo 18+:
<field name="state" invisible="state != 'draft'"/>Delete Validation
Odoo 17 and earlier:
def unlink(self):
for record in self:
if record.state != 'draft':
raise UserError("Cannot delete non-draft records")
return super().unlink()Odoo 18+:
@api.ondelete(at_uninstall=False)
def _unlink_if_not_draft(self):
if any(rec.state != 'draft' for rec in self):
raise UserError("Cannot delete non-draft records")Field Aggregation
Odoo 17 and earlier:
amount_total = fields.Monetary(group_operator="sum")Odoo 18+:
amount_total = fields.Monetary(aggregator="sum")---
Module Hooks
pre_init_hook
def pre_init_hook(env):
"""Called before module installation"""
# Create custom tables, etc.
passpost_init_hook
def post_init_hook(env):
"""Called after module installation"""
# Set default values, create records, etc.
passuninstall_hook
def uninstall_hook(env):
"""Called after module uninstallation"""
# Clean up custom tables, files, etc.
passRegister Hooks in Manifest
{
...
'pre_init_hook': 'my_module.pre_init_hook',
'post_init_hook': 'my_module.post_init_hook',
'uninstall_hook': 'my_module.uninstall_hook',
}---
Migration Checklist
- [ ] Review Odoo 19 changelog
- [ ] Update
__manifest__.pyversion - [ ] Update dependencies
- [ ] Rename
<tree>to<list> - [ ] Replace
attrswith direct attributes - [ ] Replace
unlink()override with@api.ondelete - [ ] Update field aggregators
- [ ] Update SQL queries to use
odoo.tools.SQL - [ ] Run migration scripts
- [ ] Test all functionality
- [ ] Update documentation
---
Testing Migrations
Test Migration Script
from odoo.tests import TransactionCase
class TestMigration(TransactionCase):
def test_migration(self):
# Test migration script
passManual Testing
1. Install previous version with sample data 2. Update to Odoo 19 3. Verify all data migrated correctly 4. Test all features
---
References
- Odoo 19 changelog
- Odoo 18 changelog
Odoo 19 Mixins Guide
Guide for using Odoo 19 mixins: mail.thread, activities, email aliases, and other useful mixins.
Table of Contents
---
Messaging Features
Basic Messaging Integration
Add mail.thread mixin to your model:
class BusinessTrip(models.Model):
_name = 'business.trip'
_inherit = ['mail.thread']
_description = 'Business Trip'
name = fields.Char()
partner_id = fields.Many2one('res.partner', 'Responsible')
guest_ids = fields.Many2many('res.partner', 'Participants')Add chatter to form view:
<form string="Business Trip">
<!-- Your form fields here -->
<chatter open_attachments="True"/>
</form>Chatter Options
| Option | Description |
|---|---|
open_attachments | Shows attachment section expanded |
reload_on_attachment | Reload form on attachment change |
reload_on_follower | Reload form on follower update |
reload_on_post | Reload form on message post |
---
Posting Messages
message_post
Post a new message in an existing thread:
record.message_post(
body='This is a message',
subject='Subject',
message_type='notification',
subtype_xmlid='mail.mt_comment',
)Parameters:
body(str | Markup): Message body (escaped if str, use Markup for HTML)subject(str): Message subjectmessage_type(str):notification,comment,emailsubtype(str/xmlid): Message subtypeparent_id(int): Reply to messageattachments(list): List of(name, content)tuples**kwargs: Extra mail.message field values
message_post_with_view
Send message using a QWeb template:
record.message_post_with_view(
'my_module.my_template',
additional_context={'val': value},
)message_post_with_template
Send message using an email template:
record.message_post_with_template(
template_id,
composition_mode='comment',
)---
Receiving Messages
message_new
Called when new email arrives for an alias:
def message_new(self, msg_dict, custom_values=None):
# Extract data from email
name = msg_dict.get('subject', 'New')
# Create record
return super().message_new(msg_dict, {
'name': name,
**(custom_values or {}),
})message_update
Called when email reply arrives:
def message_update(self, msg_dict, update_vals=None):
# Update record from email
return super().message_update(msg_dict, {
'description': msg_dict.get('body'),
**(update_vals or {}),
})---
Followers Management
message_subscribe
Add partners/channels as followers:
# Subscribe partners
record.message_subscribe(partner_ids=[pid1, pid2])
# Subscribe channels
record.message_subscribe(channel_ids=[cid1])
# With specific subtypes
record.message_subscribe(
partner_ids=[pid1],
subtype_ids=[subtype_id],
)message_unsubscribe
Remove followers:
# Unsubscribe partners
record.message_unsubscribe(partner_ids=[pid1, pid2])
# Unsubscribe current user
record.message_unsubscribe_users()---
Logging Changes (Tracking)
Enable field tracking in mail.thread:
class MyModel(models.Model):
_name = 'my.model'
_inherit = ['mail.thread']
name = fields.Char(tracking=True)
state = fields.Selection([
('draft', 'Draft'),
('done', 'Done'),
], tracking=True)
# Track changes in relational field
partner_id = fields.Many2one('res.partner', tracking=1)Track changes in specific subfields:
# Track all partner_id subfields
partner_id = fields.Many2one('res.partner', tracking=True)
# Track only name
partner_id = fields.Many2one('res.partner', tracking='name')---
Activities
mail.activity.mixin
Add activity support:
class MyModel(models.Model):
_name = 'my.model'
_inherit = ['mail.activity.mixin']
name = fields.Char()Activity Methods
# Schedule activity
record.activity_schedule(
'mail.mail_activity_data_todo',
user_id=user.id,
summary='Review this',
)
# Mark as done
activities = record.activity_ids
activities.action_done()
# Feedback
activities.action_feedback(feedback='Completed')---
Email Aliases
mail.alias.mixin
Add email alias support:
class MyModel(models.Model):
_name = 'my.model'
_inherit = ['mail.alias.mixin', 'mail.thread']
name = fields.Char()
alias_id = fields.Many2one(
'mail.alias',
string='Alias',
ondelete="cascade",
required=True,
)
def get_alias_model_name(self, vals):
return self._name
def get_alias_values(self):
values = super().get_alias_values()
values.update({
'alias_defaults': 'name',
})
return valuesCreate alias in data file:
<record id="my_alias" model="mail.alias">
<field name="alias_name">my-model</field>
<field name="alias_model_id" ref="model_my_model"/>
<field name="alias_user_id" ref="base.user_admin"/>
</record>---
UTM Mixin
utm.mixin
Add campaign tracking:
class MyModel(models.Model):
_name = 'my.model'
_inherit = ['utm.mixin']
name = fields.Char()
campaign_id = fields.Many2one('utm.campaign', 'Campaign')
source_id = fields.Many2one('utm.source', 'Source')
medium_id = fields.Many2one('utm.medium', 'Medium')This adds tracking for marketing campaigns.
---
Website Mixins
website.published.mixin
Add website publishing:
class MyModel(models.Model):
_name = 'my.model'
_inherit = ['website.published.mixin']
name = fields.Char()
website_published = fields.Boolean('Visible on Website')website.seo.metadata
Add SEO metadata:
class MyModel(models.Model):
_name = 'my.model'
_inherit = ['website.seo.metadata']
name = fields.Char()
website_meta_title = fields.Char('Meta Title')
website_meta_description = fields.Text('Meta Description')---
Rating Mixin
rating.mixin
Add customer rating:
class MyModel(models.Model):
_name = 'my.model'
_inherit = ['rating.mixin', 'mail.thread']
name = fields.Char()Rating Methods
# Send rating request
record.rating_send_request(
rating_template='mail.mail_template_data_rating',
)
# Get rating stats
avg_rating = record.rating_get_stats()---
Portal Access
portal.mixin
Add customer portal access:
class MyModel(models.Model):
_name = 'my.model'
_inherit = ['portal.mixin']
name = fields.Char()
partner_id = fields.Many2one('res.partner', 'Customer')Override access:
def _compute_access_url(self):
super()._compute_access_url()
for record in self:
record.access_url = '/my/model/%s' % record.id
def _get_portal_return_action(self):
return self.env.ref('my_module.my_model_action')---
References
- Source: Odoo 19 documentation
/doc/developer/reference/backend/mixins.rst
Odoo 19 Model Guide
Guide for working with Odoo 19 ORM, recordsets, CRUD operations, and domain filters.
Table of Contents
- Models
- Fields
- SQL Constraints
- Database Indexes
- Recordsets
- CRUD Operations
- Search Domains
- Environment
- SQL Execution
- Inheritance
---
Models
Defining a Model
Odoo 19 Change:_nameis now optional. Odoo derives it automatically from the CamelCase class name (each capital letter →.separator). E.g.ResPartner→res.partner,SaleOrder→sale.order.
from odoo import models, fields
# Odoo 19: _name auto-derived from class name
class MyModel(models.Model):
# _name = 'my.model' ← auto-derived, can be omitted
_description = 'My Model'
field1 = fields.Char()
field2 = fields.Integer(string="Field Label")# When _name differs from class name convention, specify explicitly:
class CustomNameModel(models.Model):
_name = 'custom.different.name'
_description = 'Custom Named Model'
name = fields.Char()Model Attributes
| Attribute | Description |
|---|---|
_name | Model name (optional in Odoo 19 — auto-derived from CamelCase class name) |
_description | Model description |
_order | Default sort order |
_rec_name | Field to use as name representation |
_inherit | Model(s) to inherit from |
_inherits | Delegation inheritance |
_table | Database table name |
_log_access | Enable create_date, write_date, create_uid, write_uid |
_auto | Auto-create database table |
_abstract | Abstract model |
_transient | Transient model |
_parent_store | Enable parent_path field |
_fold_name | Field for kanban fold |
Model Types
| Class | Description |
|---|---|
models.Model | Regular database model |
models.TransientModel | Temporary/wizard model |
models.AbstractModel | Abstract model (no database table) |
---
Fields
Field Definition
Fields are defined as class attributes on the model.
from odoo import models, fields
class MyModel(models.Model):
_name = 'my.model'
name = fields.Char(required=True)
description = fields.Text()
active = fields.Boolean(default=True)
count = fields.Integer()
price = fields.Float(digits='Product Price')Default Values
# Value
name = fields.Char(default="A value")
# Function
def _default_name(self):
return self.get_value()
name = fields.Char(default=lambda self: self._default_name())Field Types
| Type | Class | Description |
|---|---|---|
| Basic | ||
| Boolean | fields.Boolean() | True/False |
| Char | fields.Char() | String (limited length) |
| Float | fields.Float() | Floating-point number |
| Integer | fields.Integer() | Integer |
| Advanced | ||
| Binary | fields.Binary() | Binary data (files) |
| Html | fields.Html() | HTML content |
| Image | fields.Image() | Image (enhanced Binary) |
| Monetary | fields.Monetary() | Monetary amount |
| Selection | fields.Selection() | Selection from list |
| Text | fields.Text() | Long text |
| Date | ||
| Date | fields.Date() | Date (no time) |
| Datetime | fields.Datetime() | Date and time |
| Relational | ||
| Many2one | fields.Many2one() | Many-to-one |
| One2many | fields.One2many() | One-to-many |
| Many2many | fields.Many2many() | Many-to-many |
| Pseudo | ||
| Reference | fields.Reference() | Reference to any model |
| Many2oneReference | fields.Many2oneReference() | Many2one with dynamic model |
Computed Fields
from odoo import api
total = fields.Float(compute='_compute_total', store=True)
@api.depends('value', 'tax')
def _compute_total(self):
for record in self:
record.total = record.value + record.value * record.taxRelated Fields
nickname = fields.Char(related='partner_id.name', store=True)Automatic Fields
| Field | Type | Description |
|---|---|---|
id | int | Identifier |
display_name | char | Display name |
create_date | datetime | Creation timestamp |
create_uid | Many2one | Creator |
write_date | datetime | Last update timestamp |
write_uid | Many2one | Last modifier |
Reserved Field Names
| Name | Type | Purpose |
|---|---|---|
name | Char | Default rec_name |
active | Boolean | Toggles global visibility |
state | Selection | Lifecycle stages |
parent_id | Many2one | Tree structure parent |
parent_path | Char | Tree structure path |
company_id | Many2one | Multi-company field |
---
SQL Constraints
Odoo 19 Breaking Change:_sql_constraintsis no longer supported. Usemodels.Constraintinstead.
models.Constraint (Odoo 19)
SQL constraints are now defined as model attributes using models.Constraint:
from odoo import models, fields
class MyModel(models.Model):
_name = 'my.model'
_description = 'My Model'
name = fields.Char(required=True)
code = fields.Char()
quantity = fields.Integer()
# UNIQUE constraint
_unique_name = models.Constraint(
'UNIQUE(name)',
'Name must be unique!',
)
# UNIQUE on multiple fields
_unique_name_code = models.Constraint(
'UNIQUE(name, code)',
'The combination of name and code must be unique!',
)
# CHECK constraint
_check_quantity = models.Constraint(
'CHECK(quantity > 0)',
'Quantity must be positive!',
)Constraint Naming
If you omit the _name in the constraint, Odoo auto-generates a unique name based on model + attribute name.
Migration from _sql_constraints
# ❌ OLD (Odoo 18 and earlier) — NO LONGER WORKS in Odoo 19
class MyModel(models.Model):
_name = 'my.model'
_sql_constraints = [
('name_uniq', 'UNIQUE(name)', 'Name must be unique!'),
('check_qty', 'CHECK(quantity > 0)', 'Quantity must be positive!'),
]
# ✅ NEW (Odoo 19)
class MyModel(models.Model):
_name = 'my.model'
_name_uniq = models.Constraint(
'UNIQUE(name)',
'Name must be unique!',
)
_check_qty = models.Constraint(
'CHECK(quantity > 0)',
'Quantity must be positive!',
)Overriding Constraints in Inherited Models
Constraints can be overridden or removed in inherited models:
class ExtendedModel(models.Model):
_inherit = 'my.model'
# Override constraint with different check
_check_qty = models.Constraint(
'CHECK(quantity >= 0)',
'Quantity cannot be negative!',
)---
Database Indexes
Field-Level Index
name = fields.Char(index=True) # Simple btree indexDeclarative Index (Odoo 19)
For composite or custom indexes, use models.Index:
class MyModel(models.Model):
_name = 'my.model'
name = fields.Char()
code = fields.Char()
date = fields.Date()
# Composite index on multiple fields
_name_code_idx = models.Index('(name, code)')
# Index with specific method
_date_idx = models.Index('(date DESC)')Warning: Don't over-index — indexes consume space and impact INSERT/UPDATE/DELETE performance.
---
Recordsets
Active Record Interface
# Read field
record.name
record.company_id.name
# Write field
record.name = "Bob"
# Dynamic field access
field = "name"
record[field]Iteration
def do_operation(self):
for record in self:
# record is a single record
print(record.name)Record Cache and Prefetching
Odoo maintains a cache and prefetches records/fields following heuristics.
# Without prefetching: 2000 queries
for partner in partners:
print(partner.name)
print(partner.lang)
# With prefetching: 1 query
for partner in partners:
print(partner.name)
print(partner.lang)---
CRUD Operations
Create
# Single record
record = self.env['model.name'].create({'field': 'value'})
# Multiple records (batch)
records = self.env['model.name'].create([
{'field': 'value1'},
{'field': 'value2'},
])Read
# Browse
record = self.env['model.name'].browse(record_id)
records = self.env['model.name'].browse([id1, id2, id3])
# Read
data = records.read(['field1', 'field2'])Write
# Single record
record.write({'field': 'value'})
# Multiple records
records.write({'field': 'value'})Unlink (Delete)
# Single record
record.unlink()
# Multiple records
records.unlink()---
Search Domains
A search domain is a first-order logical predicate for filtering.
Domain Condition
# Simple condition
domain = [('name', '=', 'ABC')]
# Multiple conditions
domain = [('name', '=', 'ABC'), ('phone', 'like', '7620')]Operators
| Operator | Description |
|---|---|
= | equals |
!= | not equals |
>, >=, <, <= | comparison |
=? | unset or equals |
=like, like, ilike, =ilike | pattern matching |
in, not in | in list |
child_of, parent_of | tree traversal |
any, any!, not any, not any! | relation traversal |
Logical Operators
# AND (implicit)
domain = [('name', '=', 'ABC'), ('state', '=', 'draft')]
# OR
domain = '|', [('name', '=', 'ABC')], [('name', '=', 'XYZ')]
# NOT
domain = '!', [('state', '=', 'draft')]Domain Class
from odoo.fields import Domain
# Create domain
d1 = Domain('name', '=', 'abc')
d2 = Domain('phone', 'like', '7620')
# Combine
d3 = d1 & d2 # AND
d4 = d1 | d2 # OR
d5 = ~d1 # NOT
# Parse from list
domain = Domain([('name', '=', 'abc'), ('phone', 'like', '7620')])
# Serialize to list
domain_list = list(domain)Search Methods
# Search
records = self.env['model'].search(domain)
# Search with limit
records = self.env['model'].search(domain, limit=10)
# Search with offset
records = self.env['model'].search(domain, offset=20)
# Search with order
records = self.env['model'].search(domain, order='name ASC')
# Search count
count = self.env['model'].search_count(domain)
# Search and read
records = self.env['model'].search_read(domain, ['field1', 'field2'])
# Search fetch (Odoo 19+)
records = self.env['model'].search_fetch(domain, ['field1', 'field2'])
# Name search
records = self.env['model'].name_search('keyword', operator='ilike')---
Environment
The environment holds:
- Database cursor (
cr) - Current user (
user,uid) - Context (
context) - Record cache
Accessing Environment
# From recordset
env = record.env
# Create new recordset in another model
model = env['another.model']
# Access properties
env.uid # Current user id
env.user # Current user recordset
env.company # Current company
env.companies # Allowed companies
env.lang # Current languageAltering Environment
# Change context
records.with_context(lang='fr_FR')
# Change user
records.with_user(user_id)
# Change company
records.with_company(company_id)
# Change environment completely
records.with_env(new_env)
# Sudo (superuser mode)
records.sudo()---
SQL Execution
Raw SQL
# Execute query
self.env.cr.execute("SELECT id FROM table WHERE field = %s", (value,))
# Fetch results
results = self.env.cr.fetchall()
row = self.env.cr.fetchone()SQL Class (Recommended)
from odoo.tools import SQL
# Build query
query = SQL("SELECT id FROM table WHERE field = %s", value)
# Execute
self.env.cr.execute(query)Flush and Invalidate
Before SQL queries, flush pending data:
# Flush all records of a model
self.env['model'].flush_model(['field1', 'field2'])
# Flush specific recordset
records.flush_recordset(['field1', 'field2'])After SQL modifications, invalidate cache:
# Invalidate all records of a model
self.env['model'].invalidate_model(['field1', 'field2'])
# Invalidate specific recordset
records.invalidate_recordset(['field1', 'field2'])
# Notify field modification
records.modified(['field1', 'field2'])---
Inheritance
Classical Inheritance
Create new model from existing one:
class Inheritance1(models.Model):
_name = 'inheritance.1'
_description = 'Inheritance One'
name = fields.Char()
class Inheritance2(models.Model):
_name = 'inheritance.2'
_inherit = ['inheritance.1']
_description = 'Inheritance Two'
# Inherits name field from inheritance.1
# Adds new fields/methodsExtension
Extend existing model in-place:
class Extension0(models.Model):
_name = 'extension.0'
_description = 'Extension zero'
name = fields.Char(default="A")
class Extension0(models.Model):
_inherit = 'extension.0'
description = fields.Char(default="Extended")Delegation
Delegate fields to child records:
class Screen(models.Model):
_name = 'delegation.screen'
size = fields.Float(string='Screen Size')
class Laptop(models.Model):
_name = 'delegation.laptop'
_inherits = {
'delegation.screen': 'screen_id',
}
name = fields.Char(string='Name')
screen_id = fields.Many2one('delegation.screen', required=True, ondelete="cascade")
# Can access size directly on laptop
laptop.size---
References
- Source: Odoo 19 documentation
/doc/developer/reference/backend/orm.rst
Odoo 19 OWL Guide
Guide for building OWL (Owl Web Library) components in Odoo 19.
Table of Contents
---
OWL Overview
OWL is a JavaScript framework for building web UI components in Odoo.
Key Concepts
| Concept | Description |
|---|---|
| Component | Reusable UI building block |
| State | Reactive data |
| Props | Component properties |
| Hooks | Lifecycle functions |
| Template | QWeb template |
---
Component Structure
Basic Component
import { Component } from "@odoo/owl";
export class MyComponent extends Component {
static template = "my_module.MyComponent";
static props = {
value: { type: String, optional: true },
};
setup() {
// Component setup
}
}Register Component
import { registry } from "@web/core/registry";
registry.category("actions").add("my_component", MyComponent);Use in View
<widget name="my_component" options="{'value': 'Hello'}"/>---
Hooks
Setup Hook
Called when component is created:
setup() {
// Initialize state
this.state = useState({ count: 0 });
// Call services
this.rpc = useService("rpc");
this.orm = useService("orm");
this.action = useService("action");
}Lifecycle Hooks
| Hook | When |
|---|---|
setup() | Component creation |
onWillStart() | Before render (async) |
onMounted() | After render |
onWillUnmount() | Before destroy |
onWillPatch() | Before update |
onPatched() | After update |
Example
setup() {
onWillStart(this.onWillStart);
onMounted(this.onMounted);
onWillUnmount(this.onWillUnmount);
}
async onWillStart() {
// Load data before render
}
onMounted() {
// After render
}
onWillUnmount() {
// Cleanup
}---
Services
Common Services
| Service | Description |
|---|---|
orm | Database operations |
rpc | RPC calls |
action | Execute actions |
dialog | Show dialogs |
notification | Show notifications |
router | Navigation |
user | Current user |
company | Current company |
Use Service
setup() {
this.orm = useService("orm");
this.rpc = useService("rpc");
this.action = useService("action");
this.dialog = useService("dialog");
this.notification = useService("notification");
}ORM Service
// Search
const records = await this.orm.search("my.model", [["active", "=", true]]);
// Read
const data = await this.orm.read("my.model", ids, ["name", "value"]);
// Create
const id = await this.orm.create("my.model", { name: "Test" });
// Write
await this.orm.write("my.model", [id], { name: "Updated" });
// Unlink
await this.orm.unlink("my.model", [id]);RPC Service
// Call controller
const result = await this.rpc("/my/controller", { param: "value" });Action Service
// Do action
await this.action.doAction({
type: "ir.actions.act_window",
res_model: "my.model",
views: [
[false, "list"],
[false, "form"],
],
});Dialog Service
// Add dialog
this.dialog.add(MyDialog, {
title: "My Dialog",
confirm: () => {...},
});Notification Service
// Show notification
this.notification.notify({
message: "Success!",
type: "success",
});---
State Management
useState
setup() {
this.state = useState({
count: 0,
name: "",
});
}
increment() {
this.state.count++;
}useState in Template
<div t-out="state.count"/>
<button t-on-click="increment">+</button>Computed State
setup() {
this.state = useState({count: 0});
this.double = computed(() => this.state.count * 2);
}---
QWeb Templates
Basic Template
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="my_module.MyComponent" owl="1">
<div class="my_component">
<h1 t-out="props.title"/>
<p t-out="state.message"/>
</div>
</t>
</templates>Event Handlers
<button t-on-click="increment">Increment</button>
<input t-on-change="onChange"/>Loops and Conditions
<!-- Loop -->
<div t-foreach="state.records" t-as="record" t-key="record.id">
<span t-out="record.name"/>
</div>
<!-- Condition -->
<div t-if="state.show">Visible when true</div>
<div t-else="">Visible when false</div>---
Translations
Translate in JavaScript
import { _t } from "@web/core/l10n/translation";
this.message = _t("Hello World");Translate with Parameters
this.message = _t("Hello %(name)s", { name: "John" });Translate in Template
<span t-out="translate('Hello World')"/>---
Examples
Counter Component
import { Component, useState } from "@odoo/owl";
import { _t } from "@web/core/l10n/translation";
export class Counter extends Component {
static template = "my_module.Counter";
setup() {
this.state = useState({ count: 0 });
}
increment() {
this.state.count++;
}
decrement() {
this.state.count--;
}
}<templates xml:space="preserve">
<t t-name="my_module.Counter" owl="1">
<div class="counter">
<button class="btn btn-secondary" t-on-click="decrement">-</button>
<span t-out="state.count"/>
<button class="btn btn-secondary" t-on-click="increment">+</button>
</div>
</t>
</templates>Data Loading Component
import { Component, useState, onWillStart } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";
export class DataComponent extends Component {
static template = "my_module.DataComponent";
setup() {
this.orm = useService("orm");
this.state = useState({
records: [],
loading: true,
});
onWillStart(this.loadData);
}
async loadData() {
this.state.records = await this.orm.search("my.model", [], {
limit: 10,
});
this.state.loading = false;
}
}---
Best Practices
Use Hooks for Side Effects
setup() {
onMounted(() => {
// Side effects here
});
}Cleanup Resources
setup() {
onWillUnmount(() => {
// Cleanup here
});
}Avoid Direct DOM Manipulation
Use templates and reactive state instead.
Split Components
Keep components small and focused.
// Good: Small focused component
export class UserName extends Component {
static template = "my_module.UserName";
}
// Bad: Large monolithic component
export class Everything extends Component {
static template = "my_module.Everything";
}---
References
- OWL documentation
- Odoo 19 Web framework docs
Odoo 19 Performance Guide
Guide for optimizing Odoo 19 code: preventing N+1 queries, reducing database queries, and using profiler.
Table of Contents
---
Profiling
Odoo provides an integrated profiling tool to record SQL queries and stack traces.
Enable from User Interface
1. Enable developer mode 2. Toggle Enable profiling button 3. Choose expiry time 4. Toggle Enable profiling again to start session profiling
Options:
- Record sql - Saves all SQL queries with stack trace
- Record traces - Saves stack trace periodically (default: 10ms interval)
Enable from Python Code
from odoo.tools.profiler import Profiler
# Basic profiling
with Profiler():
do_stuff()
# With custom collectors
with Profiler(collectors=['sql', PeriodicCollector(interval=0.1)]):
do_stuff()
# In tests
with self.profile():
do_stuff()Collectors
| Collector | Key | Description |
|---|---|---|
| SQL collector | sql | Saves SQL queries with stack trace |
| Periodic collector | traces_async | Saves stack trace periodically (separate thread) |
| QWeb collector | qweb | Saves QWeb directive execution |
| Sync collector | traces_sync | Saves every function call/return (high overhead) |
Execution Context
Add context to identify calls in speedscope:
for index in range(max_index):
with ExecutionContext(current_index=index):
do_stuff()Performance Pitfalls
- Randomness can lead to different results (garbage collector, etc.)
- Blocking calls may cause unexpected long frames
- Cache state affects results (view/assets in cache)
- Profiler overhead can impact performance (especially SQL collector)
- Large profiles may cause memory issues
---
Batch Operations
Avoid Loop Queries
BAD: Search in loop (N queries)
def _compute_count(self):
for record in self:
domain = [('related_id', '=', record.id)]
record.count = other_model.search_count(domain)GOOD: Use _read_group (1 query)
Odoo 19:read_group()is deprecated. Use_read_group()(internal) orformatted_read_group()(public API).
def _compute_count(self):
domain = [('related_id', 'in', self.ids)]
counts_data = other_model._read_group(domain, ['related_id'], ['__count'])
mapped_data = {r['related_id'][0]: r['__count'] for r in counts_data}
for record in self:
record.count = mapped_data.get(record.id, 0)Batch Creates
BAD: Create in loop
for name in ['foo', 'bar']:
model.create({'name': name})GOOD: Batch create
create_values = [{'name': name} for name in ['foo', 'bar']]
records = model.create(create_values)Prefetch Records
BAD: Browse one at a time
for record_id in record_ids:
record = model.browse(record_id)
record.foo # One query per recordGOOD: Browse all together
records = model.browse(record_ids)
for record in records:
record.foo # One query for entire recordsetDisable Prefetching (when needed)
for values in values_list:
message = self.browse(values['id']).with_prefetch(self.ids)---
Algorithmic Complexity
Reduce Nested Loops
BAD: O(n²) complexity
for record in self:
for result in results:
if result['id'] == record.id:
record.foo = result['foo']
breakGOOD: Use dictionary (O(n))
mapped_result = {result['id']: result['foo'] for result in results}
for record in self:
record.foo = mapped_result.get(record.id)Use Set Operations
BAD: List-like in check (quadratic)
invalid_ids = self.search(domain).ids
for record in self:
if record.id in invalid_ids: # O(n) for each record
...GOOD: Use set (O(n) total)
invalid_ids = set(self.search(domain).ids)
for record in self:
if record.id in invalid_ids:
...ALTERNATIVE: Recordset operations
invalid_ids = self.search(domain)
for record in self - invalid_ids:
...---
Indexes
Database indexes speed up search operations.
name = fields.Char(string="Name", index=True)Warning: Don't index every field - indexes consume space and impact INSERT/UPDATE/DELETE performance.
Using Indexes
# Field-level index
name = fields.Char(index=True)
records = self.search([('name', '=', 'value')]) # Uses index scanDeclarative Index (Odoo 19)
For composite indexes, use models.Index as a model attribute:
class MyModel(models.Model):
_name = 'my.model'
name = fields.Char()
code = fields.Char()
_name_code_idx = models.Index('(name, code)')---
Performance Pitfalls
N+1 Query Problem
Occurs when you:
1. Fetch a list of records 2. Loop through them 3. Execute a query for each record
Detection: Use --log-sql CLI parameter or profiler
Solution: Fetch related data in one query using:
_read_group()search_fetch()fetch()mapped()
Large Recordsets
Processing large recordsets can cause memory issues.
Solution: Process in batches
def _process_large_dataset(self):
limit = 1000
offset = 0
while True:
records = self.search([], limit=limit, offset=offset)
if not records:
break
records.process()
offset += limitComputed Field Dependencies
Missing dependencies cause recomputation at wrong time.
BAD: Missing dotted dependency
@api.depends('partner_id')
def _compute_email(self):
for record in self:
record.email = record.partner_id.email # N queries!GOOD: Include dotted path
@api.depends('partner_id.email')
def _compute_email(self):
for record in self:
record.email = record.partner_id.email # 1 query for allContext Pollution
Excessive context changes can cause issues.
# BAD: Too many with_context calls
records.with_context(lang='fr').with_context(active_test=False).with_context(...)Solution: Consolidate context changes
# GOOD: Single with_context call
records.with_context(lang='fr', active_test=False)Unnecessary invalidate_cache
Calling invalidate_cache() too frequently defeats the purpose of caching.
Solution: Only invalidate fields that actually changed
# GOOD: Invalidate only changed fields
records.invalidate_recordset(['field1', 'field2'])---
Query Count Testing
Use assertQueryCount in tests to establish query limits.
with self.assertQueryCount(11):
do_something()Combine with profiler for analysis:
with self.profile():
with self.assertQueryCount(__system__=1211):
do_stuff()---
Good Practices Summary
| Practice | Description |
|---|---|
| Batch operations | Accumulate operations, execute in batch |
| Use \_read_group | Replace search/search_count in loops |
| Prefetch records | Browse all records together |
| Reduce complexity | Use dictionaries/sets instead of nested loops |
| Add indexes | On frequently searched fields |
| Use fetch/search_fetch | For targeted data loading |
| Profile first | Use profiler before optimizing |
| Test query counts | Use assertQueryCount in tests |
---
References
- Source: Odoo 19 documentation
/doc/developer/reference/backend/performance.rst
Related skills
FAQ
What does odoo-19 do?
Apply odoo-19 agent skill workflows from documented SKILL.md guidance.
When should I use odoo-19?
During ship testing work for testing & qa.
Is odoo-19 safe to install?
Review the Security Audits panel on this listing before production use.