Este artigo está atualmente disponível apenas em inglês. Tradução em breve.
Your Odoo code throws this on a recordset operation:
TypeError: Expected singleton: sale.order(14, 15, 23)
This is the most common ORM error after MissingError. It fires whenever you try to read a single field's value from a recordset that contains more than one record. Odoo refuses to guess which record you meant. The fix is almost always one of two patterns: add a loop, or call ensure_one().
Quick Fix
Pick one of these two patterns based on intent.
Loop pattern — when you want to operate on each record:
# WRONG — singleton error if recordset has > 1 record
total = orders.amount_total
# RIGHT
for order in orders:
process(order, order.amount_total)
ensure_one pattern — when you genuinely expect exactly one record:
# Action handlers in form views always receive a single-record recordset
def action_confirm(self):
self.ensure_one()
self.state = 'confirmed'
ensure_one() raises ValueError (with a clear message) if the recordset is not exactly one record, instead of letting the singleton error surface later in opaque places.
Why This Happens
Odoo recordsets are list-like. A recordset with three records is model(14, 15, 23). When you write recordset.field, the ORM tries to return one value — and refuses if the recordset has more than one record. The four common triggers:
- Server action / button handler that the user invoked from a list view multi-selection. The handler assumed singleton but received many.
- Compute method that reads a field on
selfdirectly instead of loopingfor rec in self. - Constraint method that does the same.
- Writes to one2many fields without iterating — e.g.
self.line_ids.product_id = productwhenline_idshas 5 lines.
Compute and constraint methods are the silent cause: Odoo always passes them a recordset, never a single record, and forgetting the loop is a one-line bug that fails only when the recordset has more than one row.
Step-by-Step Diagnosis
1. Identify the call site. The traceback shows the exact line. Look at it.
2. Check self's shape. Add logging:
def _compute_total(self):
_logger.info("compute called with %s records: %s", len(self), self)
self.total = self.amount_untaxed * 1.1 # raises if len(self) > 1
3. Decide the intent. Is this code supposed to handle one record or many? Most form-view action handlers handle one. Most compute methods handle many. Most server actions handle many.
4. Check upstream callers. A method called from a list-view bulk action will always receive multiple records, even if every test case used one.
Permanent Fix
For compute methods, always loop:
@api.depends('order_line.price_subtotal', 'order_line.tax_id')
def _compute_amount(self):
for order in self:
order.amount_untaxed = sum(line.price_subtotal for line in order.order_line)
order.amount_total = order.amount_untaxed * 1.1
The for order in self is non-negotiable. Even if you "know" the recordset will have one row, Odoo can call this with hundreds during a bulk write or migration.
For constraint methods, always loop:
@api.constrains('amount_total', 'partner_id.credit_limit')
def _check_credit_limit(self):
for order in self:
if order.amount_total > order.partner_id.credit_limit:
raise ValidationError(_("Order exceeds credit limit."))
For action methods called from a button, two patterns are valid:
# Pattern A — explicit single-record action
def action_confirm(self):
self.ensure_one()
self.state = 'confirmed'
return True
# Pattern B — bulk-aware action
def action_confirm(self):
for order in self:
order.state = 'confirmed'
return True
Pattern B is more robust for list-view bulk actions. Pattern A is correct for form-view actions. Both are wrong if you do neither.
For one2many writes, always go through write on the related recordset:
# WRONG — singleton error when line_ids has > 1 line
self.line_ids.product_id = product
# RIGHT
self.line_ids.write({'product_id': product.id})
# OR loop explicitly
for line in self.line_ids:
line.product_id = product
write({'field': value}) operates on every record in the recordset and is the canonical Odoo way to do bulk updates.
How to Prevent It
- Pattern: every compute starts with
for rec in self. Make it a snippet in your editor. Compute methods that do not loop are a code smell. - Pattern: every action method starts with either
ensure_one()or a loop. Pick one explicitly. Never rely on the caller passing a single-record recordset. - Lint rule.
pylint-odooshipsbad-method-argument-nameandcompute-sudo-no-context-passedcheckers. Add a custom checker for "compute method withoutfor x in self" — it catches 90 percent of singleton bugs. - Test with multi-record recordsets. When unit-testing compute and constraint methods, always test with a recordset of size 2 or more. A test that only ever passes a single record will not catch this bug.
- Bulk-aware server actions. In Odoo 18.0+ Server Actions, prefer the "Run for all selected" model and write code that handles multiple records. Old habits of "this is a one-record action" lead to singleton errors when users multi-select.
Related Errors
- Recordset has no attribute — empty recordset; the inverse case.
- Required field not set — what you see when the loop sets the wrong field.
- Recursive dependency in computed field — when a singleton-broken compute also has bad
@api.depends. - MissingError — frequent cause of recordsets shrinking unexpectedly mid-flow.
Frequently Asked Questions
Why does Odoo not silently pick the first record?
Because that would hide bugs. If your code runs against three orders and silently uses the first, you get incorrect totals, half-applied state changes, and corrupted data. Refusing to guess is the safer default. Use recordset[0] explicitly when you really do want the first record.
Is ensure_one() always the right fix in action methods?
Only if your action genuinely cannot handle multi-record selections. List-view actions (the contextual "Action" menu) pass the entire selection. If your handler does ensure_one() there, the user clicking three records gets a confusing error. Prefer the for-loop pattern for most action methods, and reserve ensure_one() for form-view singletons.
What is the cost of looping when I "know" it is a single record?
Negligible. A for rec in self loop over a single-record recordset is one Python iteration — microseconds. The cost of not looping is the bug you are reading this article to fix. Always loop.
Can I use recordset.first() or similar helpers?
Odoo does not ship .first(). You can write your own helper:
def first(recordset):
return recordset[:1] # safe even for empty recordsets
recordset[:1] returns a recordset of size 0 or 1, never raising the singleton error and not crashing on empty input. We use this pattern across all ECOSIRE modules.
Why does my onchange method raise singleton when triggered from the UI?
onchange methods receive a NewId recordset, not a database recordset. They are always single-record by definition — no loop is required, but ensure_one() is still good practice as documentation. If your onchange is triggered with multiple records (rare, mostly via custom XML server actions), refactor as a button action instead.
Should I use .mapped('field') to avoid singleton errors?
mapped() returns a list (for non-relational fields) or a recordset (for relational). It never raises singleton because it iterates internally. Use mapped() whenever you need a list of values across a recordset:
emails = orders.mapped('partner_id.email') # returns list of strings
This is idiomatic Odoo and reads cleaner than an explicit list comprehension.
Need help with a tricky Odoo error? ECOSIRE's Odoo experts have shipped 215+ modules — get expert help.
Escrito por
ECOSIRE TeamTechnical 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
Transforme seu negócio com o Odoo ERP
Implementação, personalização e suporte especializado do Odoo para agilizar suas operações.
Artigos Relacionados
Como adicionar um botão personalizado a uma visualização de formulário Odoo (2026)
Adicione botões de ação personalizados às visualizações de formulário do Odoo 19: método de ação Python, herança de visualização, visibilidade condicional, caixas de diálogo de confirmação. Testado em produção.
Como adicionar um campo personalizado no Odoo sem Studio (2026)
Adicione campos personalizados por meio de módulo personalizado no Odoo 19: herança de modelo, extensão de visualização, campos computados, decisões de loja/não loja. Código primeiro, controlado por versão.
Como adicionar um relatório personalizado no Odoo usando layout externo
Crie um relatório PDF de marca no Odoo 19 usando web.external_layout: modelo QWeb, formato de papel, vinculação de ação. Com logotipo impresso + substituições de rodapé.