Passer au contenu principal

Odoo ORM API Cheat Sheet 2026 : rechercher, lire, écrire, créer

Aide-mémoire pratique Odoo ORM avec des exemples : rechercher, parcourir, lire, écrire, créer, dissocier, jeux d'enregistrements, domaines, champs calculés, conseils de performances.

E
ECOSIRE Research and Development Team
|4 mai 20268 min de lecture1.5k Mots|

Cet article est actuellement disponible en anglais uniquement. Traduction à venir.

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.

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

  • search returns a recordset (lazy), read returns dicts (eager) — pick based on what you need next
  • browse(id) skips the search query — use when you already have the ID
  • write and create accept 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/concat
  • with_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

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

OperatorMeaning
=, !=equals, not equals
>, <, >=, <=comparisons
in, not inlist membership
like, ilikeSQL LIKE (case-sensitive / insensitive)
=like, =ilikeLIKE without auto-wildcards
child_of, parent_ofhierarchy traversal
anymatch 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': 'customer@example.com',
    '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': 'updated@example.com'})

# 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
})

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

MethodReturnsWhen to use
search(domain)RecordsetFind records by criteria
search([])Recordset (all)When you really want all records
browse(ids)RecordsetYou already have IDs
read(fields)List of dictsSerialization / RPC response
read_group(...)List of dictsAggregations (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_count instead of len(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=True to 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.

E

Rédigé par

ECOSIRE Team

Technical 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

Transformez votre entreprise avec Odoo ERP

Implémentation, personnalisation et assistance expertes d'Odoo pour rationaliser vos opérations.