Odoo 19.0 Development Skill
Source-verified knowledge of Odoo 19.0 internals — ORM, module structure, core models, field types, decorators, and critical breaking changes from earlier versions. No opinions about business logic. Pure platform facts.
Version
Current stable: 19.0 (default branch on odoo/odoo GitHub as of 2026)
Branch naming: 19.0, 18.0, 17.0 — each is a separate long-lived branch.
Community edition: odoo/odoo. Enterprise: odoo/enterprise (private).
Module Structure
Official structure from docs.odoo.com/19.0/contributing/development/coding_guidelines:
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
├── controllers/
│ ├── __init__.py
│ └── my_controller.py
├── report/
│ └── my_report.xml
└── static/
└── src/
__manifest__.py
{
'name': 'My Module',
'version': '19.0.1.0.0', # format: <odoo_version>.<major>.<minor>.<patch>.<fix>
'summary': 'One-line description',
'description': """Long description""",
'author': 'Author Name',
'website': 'https://example.com',
'category': 'Accounting/Accounting',
'depends': ['base', 'account'],
'data': [
'security/ir.model.access.csv',
'views/my_views.xml',
],
'demo': ['demo/demo_data.xml'],
'installable': True,
'application': False,
'license': 'LGPL-3',
}
Required: name, depends. Everything else is optional but recommended.
version must start with the Odoo major version (19.0.).
ORM — Imports (19.0)
# Standard
from odoo import models, fields, api, Command, _
from odoo.fields import Domain
from odoo.exceptions import UserError, ValidationError, AccessError
from odoo.tools import float_compare, float_is_zero, float_round
from odoo.tools.translate import _
# Registry (CHANGED in 19.0)
from odoo.modules.registry import Registry # NOT: from odoo import registry
Model Definition
from odoo import models, fields, api
class MyModel(models.Model):
_name = 'my.model'
_description = 'My Model'
_order = 'name asc'
_rec_name = 'name'
name = fields.Char(string='Name', required=True)
active = fields.Boolean(default=True)
Model types:
models.Model— persistent, stored in PostgreSQLmodels.TransientModel— temporary, auto-cleaned (wizards)models.AbstractModel— mixin, no table
To extend an existing model:
class ResPartner(models.Model):
_inherit = 'res.partner'
my_field = fields.Char()
Field Types
Read references/fields.md for full parameter reference. Quick types:
| Field | Python type | Notes |
|---|---|---|
Char | str | max_length optional |
Text | str | multi-line |
Html | str | sanitized HTML |
Integer | int | |
Float | float | digits=(precision, scale) |
Monetary | float | requires currency_field |
Boolean | bool | |
Date | date | stored as DATE in PG |
Datetime | datetime | stored as TIMESTAMP in PG, always UTC |
Selection | str | selection=[('key','Label')] |
Many2one | int | FK to other model |
One2many | recordset | inverse_name required |
Many2many | recordset | relation table auto-created |
Binary | bytes | attachment=True for large files |
ORM Methods
# Create
record = self.env['my.model'].create({'name': 'Test'})
# Read
record.name
records = self.env['my.model'].browse([1, 2, 3])
# Search
records = self.env['my.model'].search([('name', '=', 'Test')], limit=10, order='name asc')
count = self.env['my.model'].search_count([('active', '=', True)])
# Write
record.write({'name': 'Updated'})
# Unlink
record.unlink()
# sudo
self.env['my.model'].sudo().search([])
# with_company
self.env['my.model'].with_company(company_id).create({})
Decorators
@api.depends('field1', 'field2') # computed field trigger
def _compute_something(self):
for record in self:
record.result = record.field1 + record.field2
@api.onchange('field1') # UI-only, not stored
def _onchange_field1(self):
self.field2 = self.field1 * 2
@api.constrains('field1', 'field2') # validation, raises ValidationError
def _check_something(self):
for record in self:
if record.field1 < 0:
raise ValidationError("Field1 must be positive")
@api.model # class-level method (no self record)
def create(self, vals):
return super().create(vals)
@api.model_create_multi # batch create (preferred over @api.model for create)
def create(self, vals_list):
return super().create(vals_list)
@api.private # NEW in 19.0 — marks method as not RPC-accessible
def _internal_method(self):
pass
Commands (One2many / Many2many)
from odoo import Command
# Create and link new record
Command.create({'name': 'New'})
# Link existing record (Many2many)
Command.link(record.id)
# Unlink (Many2many — remove from relation only)
Command.unlink(record.id)
# Delete record
Command.delete(record.id)
# Replace all records
Command.set([id1, id2, id3])
# Clear all
Command.clear()
# Update existing
Command.update(record.id, {'name': 'Updated'})
Domain Syntax
# Standard domain
[('field', 'operator', value)]
# Operators: =, !=, <, >, <=, >=, in, not in, like, ilike, not like, not ilike, =like, =ilike, any, not any
# Logical operators
['&', ('a', '=', 1), ('b', '=', 2)] # AND (default)
['|', ('a', '=', 1), ('b', '=', 2)] # OR
['!', ('a', '=', 1)] # NOT
# New in 17+: Domain class
from odoo.fields import Domain
d = Domain('field', '=', value)
combined = d & Domain('other', '!=', False)
Critical Breaking Changes by Version
Read references/breaking-changes.md for full list. Critical ones:
Broken in 17.0
name_get()deprecated → override_compute_display_nameinsteadread_group()deprecated → use_read_group()(internal) orformatted_read_group()(public)group_operatorfield attr deprecated → useaggregator- Translations now stored as JSONB, not in database table
Broken in 18.0
group_operatorproduces deprecation warning — must useaggregator
Broken in 19.0
read_groupremoved from public APIname_get()removed —display_nameis the only wayodoo.osvdeprecatedrecord._cr,record._context,record._uiddeprecated (useself.env.cr,self.env.context,self.env.uid)- HTTP routes:
type='json'→ must betype='jsonrpc' res.partner.titlemodel removedfrom odoo import registry→from odoo.modules.registry import Registry- UoM: use
relative_uom_idfor direct unit relationships res.groups.privilegereplacesir.module.categoryfor group categories- Demo data not loaded by default — must be explicitly requested
- ORM code moved to
odoo/orm/subpackage (internal restructure)
Reference Files
Load on demand:
| File | Load when |
|---|---|
references/fields.md | Need full field parameter reference |
references/accounting.md | Working with account.move, account.payment, account.journal |
references/breaking-changes.md | Upgrading or porting modules between versions |
references/security.md | Access rights, record rules, ir.model.access.csv |