/archive/articles/2026/caching
pythonodooormperformancecaching

Caching in Odoo:
How to not load the same data twice, v19 and from v12 to v20

ff
fflp•Sep 28, 2026•ODOO_DEV•7 min read

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

LayerLives inLifetimeUse it for
Field (record) cache + prefetchenv.cacheOne transactionAutomatic: reading rec.field loads that field for the whole prefetch set, up to 1000 records at once
functools.cached_propertyThe Python objectObject lifetimeNon-ORM helper objects (tools.lazy_property is deprecated in 19)
@api.depends_contextField cache, one entry per context valueOne transactionNon-stored computed fields that depend on the context
ormcacheRegistry LRU, shared across requestsUntil you clear itResults 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:

python
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:

python
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 values

There 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:

python
@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.

python
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, write and unlink.

What changes in Odoo 20

Odoo 20 is the biggest rework of ormcache in years.

Odoo 19Odoo 20
Decorator@tools.ormcache@api.ormcache (tools.ormcache still works with a deprecation warning)
Clearingself.env.registry.clear_cache('templates')self.env.transaction.invalidate_ormcache('templates'); Registry.clear_cache is gone
Where the cache livesOne LRU on the registry, shared by everyoneA layer per transaction, pushed to the shared cache on commit and rolled back with savepoints
Automatic invalidationHand-written in create/write/unlinkDeclarative: _clear_cache_name and _clear_cache_on_fields
Caching small tablesNot availablemodels.CachedModel (used by res.lang, res.country, website)
Removedormcache_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):

python
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_margin

The 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:

python
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:

VersionWhat appears or changes
12A single pool.cache cleared with model.clear_caches(); invalidate_cache(fnames, ids); prefetching through a per-model _prefetch dict; no depends_context yet
13/14The new recompute framework: flush(fnames, records), _prefetch_ids and @api.depends_context
16invalidate_model, invalidate_recordset, env.invalidate_all(), flush_model, flush_recordset, env.flush_all(); the old invalidate_cache() and flush() become deprecated wrappers
17invalidate_cache() and flush() are removed; named caches arrive with ormcache(cache='templates') and registry.clear_cache(*names); clear_caches() is deprecated and clears everything
18The groups cache is added
19ormcache_context, skiparg and lazy_property are deprecated; the ORM moves to odoo/orm/
20ormcache 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.