This article is currently available in English only. Translation coming soon.
Odoo ORM API Cheat Sheet 2026: search, read, write, create, unlink
If you've spent any time writing Odoo modules, you've memorized the four core ORM operations: search, read, write, create. Plus unlink, browse, and a half-dozen other helpers you reach for monthly. This cheat sheet collects the patterns we use daily, with examples that run on Odoo 17/18/19, organized by the question you're most likely typing into search: "how do I do X with the Odoo ORM."
Bookmark this. We refer to it ourselves when context-switching between projects.
Key Takeaways
searchreturns a recordset (lazy),readreturns dicts (eager) — pick based on what you need nextbrowse(id)skips the search query — use when you already have the IDwriteandcreateaccept dicts; many2many writes use special tuple commands- Domain syntax is
[('field', 'operator', value)]; combine with&,|,!prefix operators- Recordsets support set arithmetic:
|,&,-,+for union/intersect/diff/concatwith_context(),sudo(),with_user()modify the recordset's environment- For bulk operations, batch by 1000-5000 records to keep memory bounded
Recordsets vs records
A recordset is Odoo's central abstraction. It can hold zero, one, or many records of the same model:
# Empty recordset
empty = self.env['res.partner']
len(empty) == 0 # True
# Single record (still a recordset of length 1)
one = self.env['res.partner'].browse(5)
len(one) == 1 # True
# Multi-record
many = self.env['res.partner'].search([('is_company', '=', True)])
len(many) # depends on data
Iteration over a recordset gives you single-record recordsets, not "raw records":
for partner in many:
type(partner) # res.partner recordset, length 1
partner.name # works
search
The fundamental query method:
# All companies
companies = self.env['res.partner'].search([('is_company', '=', True)])
# With limit and order
top_10 = self.env['res.partner'].search(
[('is_company', '=', True)],
limit=10,
order='create_date desc',
)
# Just the count
count = self.env['res.partner'].search_count([('is_company', '=', True)])
# Just the IDs (faster than full recordset for huge sets)
ids = self.env['res.partner'].search([('is_company', '=', True)]).ids
Domains
Domain syntax = list of triples: [('field', 'operator', value), ...]. Implicit AND between triples. Use '&', '|', '!' prefixes for explicit logic:
# Implicit AND
domain = [
('is_company', '=', True),
('country_id.code', '=', 'US'),
]
# Explicit OR
domain = [
'|',
('is_company', '=', True),
('parent_id', '!=', False),
]
# Complex: A AND (B OR C)
domain = [
('is_company', '=', True),
'|',
('country_id.code', '=', 'US'),
('country_id.code', '=', 'CA'),
]
# NOT
domain = [
('is_company', '=', True),
'!', ('email', '=', False), # email not False
]
Operators
| Operator | Meaning |
|---|---|
=, != | equals, not equals |
>, <, >=, <= | comparisons |
in, not in | list membership |
like, ilike | SQL LIKE (case-sensitive / insensitive) |
=like, =ilike | LIKE without auto-wildcards |
child_of, parent_of | hierarchy traversal |
any | match any sub-record (related) |
browse
When you already have the ID, skip the search:
partner = self.env['res.partner'].browse(5)
# Single fetch on first attribute access; cheaper than search
print(partner.name)
For multiple known IDs:
partners = self.env['res.partner'].browse([5, 6, 7])
browse does NOT validate that the IDs exist; accessing a field on a non-existent ID raises an error.
read
read returns a list of dicts (not a recordset):
data = partner.read(['name', 'email', 'country_id'])
# [{'id': 5, 'name': 'Foo', 'email': 'foo@x', 'country_id': (1, 'United States')}]
Use read when you're going to serialize (JSON-RPC response, CSV export). Otherwise prefer recordsets — they cache.
create
new_partner = self.env['res.partner'].create({
'name': 'New Customer',
'email': '[email protected]',
'is_company': True,
})
# Bulk create
new_partners = self.env['res.partner'].create([
{'name': 'A'},
{'name': 'B'},
{'name': 'C'},
])
# Returns recordset of length 3
Bulk create (list of dicts) is faster than looping single creates because Odoo can batch SQL.
write
# Single record
partner.write({'email': '[email protected]'})
# Many records, same values
partners.write({'active': False}) # updates all in recordset
# Different values per record: use loop
for partner in partners:
partner.write({'name': partner.name + ' (archived)'})
For many2many or one2many fields, write uses tuple commands:
# (0, 0, values_dict) → create new related record
# (1, id, values_dict) → update existing related record
# (2, id, _) → delete related record
# (3, id, _) → unlink (remove relation, don't delete)
# (4, id, _) → link (add relation)
# (5, _, _) → unlink all
# (6, _, ids) → replace with given IDs
partner.write({
'category_id': [(6, 0, [tag1.id, tag2.id])], # replace tags
})
partner.write({
'child_ids': [(0, 0, {'name': 'New Contact', 'email': 'c@x'})], # create contact
})
unlink
Delete records:
partner.unlink()
partners.unlink() # bulk delete
unlink runs _check_company and other constraints. For "soft delete" (mark inactive), prefer write({'active': False}).
Recordset arithmetic
all_companies = self.env['res.partner'].search([('is_company', '=', True)])
us_partners = self.env['res.partner'].search([('country_id.code', '=', 'US')])
us_companies = all_companies & us_partners # intersection
companies_or_us = all_companies | us_partners # union
non_us_companies = all_companies - us_partners # difference
combined = all_companies + us_partners # concatenate (allows duplicates)
filtered, sorted, mapped
# filter without re-querying DB
big_orders = orders.filtered(lambda o: o.amount_total > 1000)
big_orders = orders.filtered_domain([('amount_total', '>', 1000)]) # domain version
# sort
orders_sorted = orders.sorted(key='amount_total', reverse=True)
# map: extract field values (returns recordset for relational, list for primitive)
total = sum(orders.mapped('amount_total')) # list of floats
partners = orders.mapped('partner_id') # recordset of partners
Environment modifiers
# Run as superuser (bypass record rules and security)
admin_partners = self.env['res.partner'].sudo().search([])
# Run as different user
manager_view = self.env['res.partner'].with_user(manager_user).search([])
# Add to context
no_email_send = order.with_context(no_email_notification=True).action_confirm()
# Switch company
other_co_orders = self.env['sale.order'].with_company(other_company).search([])
Computed fields
class MyModel(models.Model):
_name = 'my.model'
qty = fields.Float()
price = fields.Float()
total = fields.Float(compute='_compute_total', store=True)
@api.depends('qty', 'price')
def _compute_total(self):
for record in self:
record.total = record.qty * record.price
store=True persists in DB; recompute triggers when dependencies change. For unstored computed fields, evaluation happens on every read.
Comparison: read vs search vs browse
| Method | Returns | When to use |
|---|---|---|
search(domain) | Recordset | Find records by criteria |
search([]) | Recordset (all) | When you really want all records |
browse(ids) | Recordset | You already have IDs |
read(fields) | List of dicts | Serialization / RPC response |
read_group(...) | List of dicts | Aggregations (SUM, COUNT, etc.) |
Bulk operation patterns
For 100K+ record operations, batch by chunks:
def bulk_update(self, ids, batch_size=2000):
Model = self.env['my.model']
for i in range(0, len(ids), batch_size):
batch = Model.browse(ids[i:i+batch_size])
batch.write({'status': 'processed'})
self.env.cr.commit() # commit per batch to avoid huge transaction
self.env.cache.invalidate() # release cached records
Performance tips
- Use
search_countinstead oflen(search(...))when you only need the count - Use
mapped()instead of list comprehensions for simple field extraction - Avoid
browse(id)inside loops — collect IDs first, browse once - Index fields used in domains: add
index=Trueto the field definition - For complex aggregations, use
read_group(single query) instead of Python loops - Avoid
search([])without limit on large tables
Frequently Asked Questions
What's the difference between read and mapped?
read(['field']) returns a list of dicts including the ID and field value, with proper representation for relational fields. mapped('field') returns just the values: a recordset for relational fields, or a list of primitive values. mapped is cheaper (no dict construction); read is needed for JSON-RPC serialization.
When should I use with_context vs custom keyword args?
Use with_context when the change should propagate to all subsequent ORM calls within that operation (e.g., disabling email notifications cascades to mail.thread methods). Use custom keyword args when only your method needs the flag. Context is the Odoo idiom; method args are pure Python.
How do I write a many2many add/remove without replacing the whole list?
Use the (4, id) command to add and (3, id) to remove without affecting other relations:
partner.write({'category_id': [(4, tag.id)]}) # add tag
partner.write({'category_id': [(3, tag.id)]}) # remove tag
Why does search([]) return only some records?
Likely a record-rule (ir.rule) is filtering. Run .sudo() to bypass rules and confirm. If the count differs, you have access restrictions in play. Audit ir.rule records for the model.
Can I use raw SQL when ORM is too slow?
Yes via self.env.cr.execute(query, params). But this bypasses access control, computed fields, and audit trails. Use as a last resort, only for read-only analytics, and never embed user input into the query string. Always use parameterized queries.
This cheat sheet is the reference we hand new Odoo developers on day one. ECOSIRE's Odoo developer hire service places senior Python developers who know these patterns cold; our Odoo customization service covers building production modules that use them well. Browse our Odoo modules catalog for examples of well-structured ORM code.
تحریر
ECOSIRE TeamTechnical Writing
The ECOSIRE technical writing team covers Odoo ERP, Shopify eCommerce, AI agents, Power BI analytics, GoHighLevel automation, and enterprise software best practices. Our guides help businesses make informed technology decisions.
ECOSIRE
Odoo ERP کے ساتھ اپنے کاروبار کو تبدیل کریں
آپ کے کاموں کو ہموار کرنے کے لیے ماہر Odoo کا نفاذ، حسب ضرورت، اور معاونت۔
متعلقہ مضامین
Drizzle ORM vs Prisma 2026: Schema, Performance, DX Comparison
Balanced Drizzle vs Prisma comparison for TypeScript: schema design, performance, migrations, query DX, edge runtimes. Real production benchmarks.
How to Add a Custom Button to an Odoo Form View (2026)
Add custom action buttons to Odoo 19 form views: Python action method, view inheritance, conditional visibility, confirmation dialogs. Production-tested.
How to Add a Custom Field in Odoo Without Studio (2026)
Add custom fields via custom module in Odoo 19: model inheritance, view extension, computed fields, store/non-store decisions. Code-first, version-controlled.