Ir al contenido

ORM de Odoo en profundidad: search, browse, read y search_read

Rendimiento, cuándo usar cada método y cómo evitar el problema N+1
16 de junio de 2026 por
ORM de Odoo en profundidad: search, browse, read y search_read
Aitor Atencia

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.

Logo Odoo PostgreSQL
Cada llamada al ORM puede traducirse en una o muchas consultas SQL.

Mapa mental

Los cuatro métodos esenciales

MétodoDevuelveConsulta SQLUso típico
search(domain)Lista de IDs [1, 5, 12]SELECT id … WHERESaber 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 INSerializar datos (API, export)
search_read(domain, fields)Lista de dictsSELECT campos … WHERE (1 query)Listados, informes, RPC

En código de negocio diario trabaja con recordsets (browse / resultado de search). Reserva read y search_read para 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 limit si 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=True en 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.

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,
)
Odoo list view
Las vistas lista del backend usan internamente patrones similares a 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 comodel
  • with_prefetch() — agrupa recordsets para compartir caché
  • read_group() — agregaciones SQL (SUM, COUNT) sin bucles Python
  • search_read con solo los campos necesarios
  • Evitar search/write dentro de bucles for
# 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ónMétodo recomendado
Lógica de negocio, botones, workflowssearch → recordset → métodos
Ya tengo los IDs (URL, cron, webhook)browse(ids)
API JSON / exportar CSVsearch_read o read
Solo contar registrossearch_count
Totales, agrupaciones, dashboardsread_group
Comprobar existenciasearch(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')

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.

en Odoo
Herencia en Odoo: _inherit vs _inherits explicado con ejemplos
Extensión clásica vs delegación — cuándo usar cada patrón y errores a evitar