この記事は現在英語版のみです。翻訳は近日公開予定です。
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.
関連記事
ADempiere と Odoo の比較(2026):オープンソース ERP
ADempiere と Odoo を公平に比較:アーキテクチャ、コミュニティの健全性、実際の総コスト、移行経路、そして ADempiere を継続すべきか。
Axelor と Odoo の比較(2026):2つのオープンソース ERP の率直な検証
2026 年の Axelor と Odoo の比較:ローコード BPM 中心の Axelor と、アプリストア生態系を持つ Odoo。実際の強み、ライセンス、料金モデル、そしてそれぞれが適する場面を解説します。
クラウド ERP の総所有コスト:5 年間のリアルな数値
クラウド ERP の透明な 5 年間 TCO モデル:サブスクリプション、導入、カスタマイズ、データ移行、トレーニング、ホスティング、アップグレード、社内管理工数を網羅し、25 ユーザーの中小企業について Odoo、NetSuite 級、ERPNext を比較します。