Every override of a standard Odoo method is another opportunity for you to add unnecessary queries. Let's not do that. An overview of the caching layers the ORM gives you in Odoo 19, how Odoo 20 reworks orm cache and how we got here from the older versions.
Why bother?
When you override a standard Odoo method, you call super() and then you do ‘your thing’ on top. Most of the time that thing needs some extra data: a count of related stock, a margin on some price, a company-dependent rule…
The easy way to write this is a loop with a search() inside. That works fine on your dev database with 5 sale orders.
When deployed, it then takes 30s in production when someone confirms 500 orders.
The ORM gives you multiple caching layers to avoid exactly this. Most “we load the data twice” problems don’t need a clever cache at all, they need you to stop fighting the one the ORM already has.
The layers you have in Odoo 19
| Layer | Lives in | Lifetime | Use it for |
|---|---|---|---|
| Field (record) cache + prefetch | env.cache | One transaction | Automatic: reading rec.field loads that field for the whole prefetch set, up to 1000 records at once |
functools.cached_property | The Python object | Object lifetime | Non-ORM helper objects (tools.lazy_property is deprecated in 19) |
@api.depends_context | Field cache, one entry per context value | One transaction | Non-stored computed fields that depend on the context |
ormcache | Registry LRU, shared across requests | Until you clear it | Results of methods that are read often and change rarely |
The field cache and prefetching
When you browse a recordset of 200 sale orders and read order.partner_id on the first one, Odoo doesn’t fetch one partner id.
It fetches partner_id (and the other fields stored in the same table) for all 200 records in one query,
and puts the values in the field cache.
The next 199 reads are dictionary lookups.
That only works if the records share a prefetch set. The easiest way to break it is to browse() one id at a time, or to run a search() per record:
from odoo import models
class SaleOrder(models.Model):
_inherit = 'sale.order'
def action_confirm(self):
res = super().action_confirm()
# BAD: one query per order
# for order in self:
# n = self.env['stock.picking'].search_count(
[('sale_id', '=', order.id)],
)
# GOOD: one grouped query for the whole recordset
counts = dict(
self.env['stock.picking']._read_group(
domain=[('sale_id', 'in', self.ids)],
groupby['sale_id'],
aggregates=['__count']),
)
for order in self:
n = counts.get(order, 0)
return res
def _x_products_of_lines(self, line_ids):
# BAD: browse() in a loop, every record gets its own prefetch set
# for line_id in line_ids:
# self.env['sale.order.line'].browse(line_id).product_id
# GOOD: browse once, one query for the lines and one for the products
return self.env['sale.order.line'].browse(line_ids).product_id
def _x_lines(self):
# search_fetch (v17+):
# search and fill the field cache in a single query
return self.env['sale.order.line'].search_fetch(
domain=[('order_id', 'in', self.ids)],
field_names=['product_id', 'price_unit'],
)_read_group returns tuples of (group, aggregate...).
counts.get(order, 0) works because the group is a record for a many2one.
Raw SQL and the field cache
When writing raw SQL, you are responsible for keeping the field cache honest in both directions. Pending ORM writes need to be flushed to the database before your query. The values you changed behind the ORM’s back need to be invalidated after:
from odoo.tools import SQL
Line = self.env['sale.order.line']
Line.flush_model(['price_unit']) # push pending ORM writes to the database
self.env.cr.execute(SQL(
"UPDATE sale_order_line SET price_unit = 0 WHERE order_id IN %s",
tuple(self.ids),
))
Line.invalidate_model(['price_unit']) # drop the now stale cached valuesThere are record-level variants too: flush_recordset() and invalidate_recordset().
Their are also the “nuclear” options: env.flush_all() and env.invalidate_all().
Context-dependent computed fields
A non-stored computed field whose value depends on the context (the active company, the language, a date passed in the context…) needs to be annotated with depends_context.
Otherwise the first computed value is preserved and returned for every context:
@api.depends('product_id')
@api.depends_context('company')
def _compute_x_stock(self):
for record in self:
...Using this, the field cache keeps a separate entry per company.
ormcache
ormcache is the one people usually mean when they say “caching in Odoo”.
It “remembers” the result of a model method in a LRU (Least Recently Used) that lives on the registry.
This way it survives across requests and is shared by every user of that database on that worker.
When it gets cleared, the other workers are signalled to clear theirs as well.
from odoo import api, fields, models, tools
class ProductCategory(models.Model):
_inherit = 'product.category'
x_margin = fields.Float()
@api.model
@tools.ormcache('categ_id')
def _x_get_margin(self, categ_id):
# return plain immutable values, never a recordset
return self.browse(categ_id).x_margin
def write(self, vals):
res = super().write(vals)
if 'x_margin' in vals:
self.env.registry.clear_cache() # clears the whole 'default' cache
return res
# ... and the same in create() and unlink()Be carefull:
- This ignores access rights: only cache data that’s safe for everyone.
- Clearing the cache without argument only clears the ‘default’ one.
- Everything that the result is dependent on, needs to be in the key.
- This needs to be cleared manually on
create,writeandunlink.
What changes in Odoo 20
Odoo 20 is the biggest rework of ormcache in years.
| Odoo 19 | Odoo 20 | |
|---|---|---|
| Decorator | @tools.ormcache | @api.ormcache (tools.ormcache still works with a deprecation warning) |
| Clearing | self.env.registry.clear_cache('templates') | self.env.transaction.invalidate_ormcache('templates'); Registry.clear_cache is gone |
| Where the cache lives | One LRU on the registry, shared by everyone | A layer per transaction, pushed to the shared cache on commit and rolled back with savepoints |
| Automatic invalidation | Hand-written in create/write/unlink | Declarative: _clear_cache_name and _clear_cache_on_fields |
| Caching small tables | Not available | models.CachedModel (used by res.lang, res.country, website) |
| Removed | ormcache_context, skiparg, lazy_property; clear_access_cache is renamed to invalidate_access_cache |
ormcache on the transaction
In Odoo 19 the cache is a plain LRU on the registry. A value computed inside your transaction goes straight into the shared cache, even if it was computed from data you haven’t committed yet. If the transaction then rolls back, other concurrent requests can still have been given a value based on data that never existed.
In Odoo 20 the lookup goes through env.transaction instead.
Every transaction gets its own copy-on-write layer on top of the shared cache:
your writes and invalidations are only visible to you, they’re pushed to the shared cache when you commit
A rollback (or a rolled back savepoint) throws them away.
Declarative invalidation
The write() override from the example above disappears.
You tell the model which cache to clear (and which fields should trigger it, None means any field):
from odoo import api, fields, models
class ProductCategory(models.Model):
_inherit = 'product.category'
_clear_cache_name = 'default'
_clear_cache_on_fields = ('x_margin',)
x_margin = fields.Float()
@api.model
@api.ormcache('categ_id')
def _x_get_margin(self, categ_id):
return self.browse(categ_id).x_marginThe ORM now clears the cache itself in create, write and unlink.
CachedModel
For small reference tables where you want all the rows anyway (languages, countries, websites), there’s a new mixin.
You declare which records and which fields to keep.
Reading those fields is the value is taken from the stable ormcache:
class ResLang(models.CachedModel):
_name = 'res.lang'
_cached_data_domain = [('active', '=', True)]
_cached_data_fields = (
'name', 'code', 'iso_code', 'url_code', 'active', 'direction', ...
)get_all() returns every cached record without a query.
A comment from the from the Odoo docstring:
the cached fields must not depend on other models or on the context and must not be translated.
How we got here
If you’re maintaining (or migrating) older modules, these are the milestones:
| Version | What appears or changes |
|---|---|
| 12 | A single pool.cache cleared with model.clear_caches(); invalidate_cache(fnames, ids); prefetching through a per-model _prefetch dict; no depends_context yet |
| 13/14 | The new recompute framework: flush(fnames, records), _prefetch_ids and @api.depends_context |
| 16 | invalidate_model, invalidate_recordset, env.invalidate_all(), flush_model, flush_recordset, env.flush_all(); the old invalidate_cache() and flush() become deprecated wrappers |
| 17 | invalidate_cache() and flush() are removed; named caches arrive with ormcache(cache='templates') and registry.clear_cache(*names); clear_caches() is deprecated and clears everything |
| 18 | The groups cache is added |
| 19 | ormcache_context, skiparg and lazy_property are deprecated; the ORM moves to odoo/orm/ |
| 20 | ormcache per transaction, api.ormcache, transaction.invalidate_ormcache, CachedModel, declarative invalidation |
TL;DR
Let the ORM batch for you first:
browse once, _read_group instead of a search_count per record and search_fetch when you know which fields you need.
Then and only then you can think of ormcache and when you do:
return immutable values, put everything that influences the result in the key and invalidate on every write path.
In Odoo 20, let _clear_cache_name do this last part for you.