Odoo · ORM · Rendimiento
El ORM de Odoo abstrae PostgreSQL, pero no hace milagros. Saber cuándo usar search, browse, read y search_read marca la diferencia entre un módulo rápido y uno que dispara cientos de consultas por clic.
Mapa mental
Los cuatro métodos esenciales
| Método | Devuelve | Consulta SQL | Uso típico |
|---|---|---|---|
search(domain) | Lista de IDs [1, 5, 12] | SELECT id … WHERE | Saber qué registros existen |
browse(ids) | Recordset (objetos Python) | Diferida (lazy) | Trabajar con registros ya conocidos |
read(fields) | Lista de dicts [{{'id':1,'name':'…'}}] | SELECT campos … WHERE id IN | Serializar datos (API, export) |
search_read(domain, fields) | Lista de dicts | SELECT campos … WHERE (1 query) | Listados, informes, RPC |
En código de negocio diario trabaja con recordsets (
browse/ resultado desearch). Reservareadysearch_readpara serialización o cuando necesitas dicts planos.
search
search() — encontrar IDs
# Dominio: partners activos en España
partner_ids = self.env['res.partner'].search([
('active', '=', True),
('country_id.code', '=', 'ES'),
])
# → res.partner(14, 28, 103)
# Con límite y orden
recent = self.env['sale.order'].search(
[('state', '=', 'sale')],
limit=10,
order='date_order desc',
)
search devuelve un recordset, no una lista de enteros. Puedes encadenar métodos ORM directamente:
orders = self.env['sale.order'].search([('state', '=', 'draft')])
orders.action_confirm() # Opera sobre todo el recordset
Buenas prácticas con search
- Usa
limitsi no necesitas todos los registros - Filtra en el dominio, no en Python (
if rec.state == 'done'en bucle) search_count(domain)si solo necesitas el número- Índices en campos de dominio frecuente (
store=Trueen computes buscados)
browse
browse() — recordsets desde IDs
# Desde IDs conocidos (web, cron, otro sistema)
order = self.env['sale.order'].browse(42)
# Recordset vacío del modelo correcto
empty = self.env['sale.order']
# Acceso a campos (dispara SQL si no está en caché)
print(order.name)
print(order.partner_id.email) # ⚠️ posible query extra
browse no valida que el ID exista: un ID inexistente devuelve un registro «vacío» que fallará al acceder a campos o al hacer write.
for id in ids: self.env['model'].browse(id).field — es el patrón N+1 clásico. Usa un solo browse(ids) o search.
read
read() — dicts serializables
partners = self.env['res.partner'].browse([1, 2, 3])
data = partners.read(['name', 'email', 'country_id'])
# [
# {{'id': 1, 'name': 'Acme', 'email': 'a@acme.com',
# 'country_id': (68, 'Spain')}},
# ...
# ]
Los Many2one se devuelven como tupla (id, display_name). Los One2many/Many2many como lista de IDs.
Útil en controladores JSON, exportaciones y tests. En lógica de negocio interna, prefiere acceder a record.name directamente: es más legible y el ORM optimiza el prefetch.
search_read
search_read() — buscar y leer en un paso
# Equivalente eficiente a search() + read()
lines = self.env['sale.order.line'].search_read(
domain=[('order_id', '=', order.id)],
fields=['product_id', 'product_uom_qty', 'price_subtotal'],
)
# Para RPC / APIs externas
self.env['product.product'].search_read(
[('sale_ok', '=', True)],
['name', 'list_price', 'qty_available'],
limit=50,
)
search_read.Una sola consulta SQL con los campos pedidos. Ideal para informes ligeros y endpoints. No devuelve recordsets: no puedes llamar métodos del modelo sobre el resultado.
N+1
El problema N+1 y cómo evitarlo
Ocurre cuando recorres N registros y cada acceso a un campo relacional dispara una query adicional.
❌ N+1 (lento)
orders = self.env['sale.order'].search([])
for order in orders:
# 1 query por pedido
print(order.partner_id.name)
✅ Prefetch (rápido)
orders = self.env['sale.order'].search([])
# Una query precarga todos los partners
orders.mapped('partner_id')
for order in orders:
print(order.partner_id.name)
Más técnicas anti-N+1
mapped('campo')— precarga y devuelve recordset del comodelwith_prefetch()— agrupa recordsets para compartir cachéread_group()— agregaciones SQL (SUM, COUNT) sin bucles Pythonsearch_readcon solo los campos necesarios- Evitar
search/writedentro de buclesfor
# Agregación eficiente: total vendido por comercial
self.env['sale.order'].read_group(
domain=[('state', '=', 'sale')],
fields=['amount_total'],
groupby=['user_id'],
)
Decisión
¿Qué método elijo?
| Situación | Método recomendado |
|---|---|
| Lógica de negocio, botones, workflows | search → recordset → métodos |
| Ya tengo los IDs (URL, cron, webhook) | browse(ids) |
| API JSON / exportar CSV | search_read o read |
| Solo contar registros | search_count |
| Totales, agrupaciones, dashboards | read_group |
| Comprobar existencia | search(domain, limit=1) |
Debug
Medir antes de optimizar
# Modo debug SQL en odoo.conf o --log-level=debug_sql
# Verás cada query en el log
# En tests: assertQueryCount (Odoo test framework)
with self.assertQueryCount(2):
orders = self.env['sale.order'].search([('state', '=', 'sale')], limit=5)
orders.mapped('partner_id.name')
💡 Consejo
--log-level=debug_sql en desarrollo al refactorizar métodos lentos. Un informe que tarda 30 segundos suele tener un N+1 escondido en un bucle.Resumen
search encuentra, browse materializa, read serializa y search_read combina búsqueda + lectura en una query. En el día a día vive en recordsets; usa mapped y read_group para evitar N+1. Mide con debug_sql y tests de conteo de queries antes de micro-optimizar.