本文目前仅提供英文版本。翻译即将推出。
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.
作者
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.
相关文章
如何将自定义按钮添加到 Odoo 表单视图 (2026)
将自定义操作按钮添加到 Odoo 19 表单视图:Python 操作方法、视图继承、条件可见性、确认对话框。经过生产测试。
如何在没有 Studio 的情况下在 Odoo 中添加自定义字段 (2026)
通过 Odoo 19 中的自定义模块添加自定义字段:模型继承、视图扩展、计算字段、存储/非存储决策。代码优先,版本控制。
如何使用外部布局在 Odoo 中添加自定义报告
使用 web.external_layout 在 Odoo 19 中构建品牌 PDF 报告:QWeb 模板、paperformat、操作绑定。带有印刷徽标+页脚覆盖。