本文目前仅提供英文版本。翻译即将推出。
You open a record in Odoo and the UI throws a red error banner. The server log shows a familiar Python traceback ending with this line:
odoo.exceptions.MissingError: Record does not exist or has been deleted.
(Record: sale.order(457,), User: 12)
This is one of the most common runtime errors in Odoo 17.0, 18.0, and 19.0. It surfaces in views, automated actions, scheduled crons, and JSON-RPC calls. The error itself is usually correct — the record genuinely is gone — but the reason it disappeared (or appears to have disappeared) is rarely obvious. This guide walks through the fix, the root cause, the diagnostic playbook, and the guardrails to stop it recurring.
Quick Fix
If a single user is blocked right now, ask them to refresh the browser tab and clear the breadcrumbs. Most MissingError instances come from a stale UI pointing at a record another user (or a workflow) deleted seconds ago. If the error happens in code you control, wrap the offending read with exists():
order = self.env['sale.order'].browse(order_id)
if not order.exists():
return # or raise UserError(_("Order no longer exists."))
order.action_confirm()
That single line removes 90 percent of MissingError exceptions from custom modules.
Why This Happens
MissingError is raised by BaseModel._check_recordset_validity() whenever Odoo tries to read or write a record whose database row has been deleted, archived in a way that violates a related-field rule, or is no longer accessible to the current user under the active record rules. The root causes fall into four buckets:
- Race condition between two users. User A opens a quotation in their browser. User B deletes it. User A clicks "Confirm" — the form sends the now-orphaned
idto the server. - Cascade deletion. A parent record (for example a
res.partner) is deleted withondelete='cascade'and quietly takes a child sale order with it. A scheduled cron then tries to invoice that sale order. - Multi-database / multi-company drift. A scheduled action reads ids that exist in database A but is executing against database B (common in staging/restore mistakes).
- Record rules. The record is not deleted, but the current user lost access to it via a record rule (
ir.rule) — Odoo treats inaccessible records as missing rather than raiseAccessErrorin some contexts.
Knowing which bucket you are in is the entire job. Bucket 4 is the trap most teams fall into; they assume the row was deleted and start restoring backups.
Step-by-Step Diagnosis
Run these checks in order before changing any code.
1. Confirm the row really is gone. Connect to PostgreSQL and check:
SELECT id, write_date, create_uid, write_uid
FROM sale_order
WHERE id = 457;
If you get zero rows, the record was deleted. If you get one row, it is a record-rule problem — skip to step 4.
2. Find who deleted it and when. Odoo's audit log (base.automation activity, or mail.message with mail_activity_type_id) often records deletions. For unlogged deletions, check the PostgreSQL pg_stat_activity and replication logs if you have them. The audit_log community module is worth installing on production from day one.
3. Check for cascade deletions. Inspect the model definition for foreign keys with ondelete='cascade':
order_id = fields.Many2one('sale.order', ondelete='cascade')
If a parent of your missing record was deleted, your record went with it.
4. Test record-rule access. As the affected user, run:
# In Odoo shell — odoo-bin shell -c odoo.conf
self.env['sale.order'].with_user(user_id).search([('id', '=', 457)])
If this returns an empty recordset but sudo() returns the record, you have a record-rule problem, not a deletion.
5. Check for stale browser state. Ask the user to hard-refresh (Ctrl+Shift+R). If the error stops happening, it was a browser-cache or client-tab race.
Permanent Fix
For each bucket, the durable fix differs:
Bucket 1 — Race condition. Always call .exists() before acting on an externally provided id. In particular, every server action, automated action, and webhook handler must do this:
def _process_webhook(self, payload):
order = self.env['sale.order'].browse(int(payload['order_id']))
order = order.exists()
if not order:
_logger.warning("Webhook for missing order %s", payload['order_id'])
return {'status': 'ignored'}
order.write({'note': payload['note']})
Bucket 2 — Cascade deletion. Audit your ondelete declarations. Production systems should default to ondelete='restrict' for business records and ondelete='set null' for soft references. Reserve ondelete='cascade' for true child rows (lines on an order, attachments on a record).
Bucket 3 — Cross-database drift. Scheduled crons should validate self.env.cr.dbname matches the expected database name, and any external system pushing ids should include a database identifier in the payload.
Bucket 4 — Record rules. Add an explicit AccessError early so the user gets a meaningful message:
order = self.env['sale.order'].browse(order_id)
if not order.exists():
if self.env['sale.order'].sudo().browse(order_id).exists():
raise AccessError(_("You do not have access to this order."))
raise UserError(_("This order has been deleted."))
How to Prevent It
- Lint rule. Add a custom Pylint check that flags any
browse(int)not followed by.exists()within ten lines. We ship this rule with every Odoo module ECOSIRE delivers. - Default
ondelete='restrict'. Make this the team default for all newMany2onefields. Override only with a written reason in the field's docstring. - Audit log on production. Install
auditlog(OCA) and turn on logging for any model touched by external integrations. You will save hours every time you debug a missing record. - Soft delete for high-value records. Add
active = fields.Boolean(default=True)and prefer archiving over hard deletion.MissingErrorbecomes impossible because the row is still there. - Idempotent webhooks. External callers should retry. Your handler should treat "row gone" as success-with-warning, not failure.
Related Errors
- Cannot delete record because of dependency — the inverse of cascade deletion.
- AccessError: Not allowed to access res.users — when record rules block reads.
- Recordset has no attribute — empty recordset edge cases.
- Expected singleton — closely related ORM error.
Frequently Asked Questions
Does MissingError mean the record was definitely deleted?
No. It means Odoo cannot read the record under the current user, in the current cursor, on the current database. Record rules, multi-company filters, archived parent records, and database mix-ups can all surface as MissingError. Check PostgreSQL directly before assuming a deletion happened.
How do I recover a record that was deleted by mistake?
If you have point-in-time PostgreSQL backups (which every production Odoo should), restore the base backup to a temporary database, dump only the missing rows, and re-import. If you do not have backups, check mail.message for any payload that might contain the lost data — Odoo logs many record changes there. ECOSIRE's Odoo support and maintenance team handles this kind of recovery routinely.
Why does the same code work in development but throw MissingError in production?
Almost always a timing issue. Development databases are small, transactions commit fast, and you are usually the only user. Production has concurrent users, longer transactions, and crons running in parallel. Add .exists() checks, and the race goes away.
Is there a way to log every MissingError automatically?
Yes. Add a custom logging handler in odoo.conf for the odoo.exceptions logger at level INFO, and pipe it to your monitoring stack. Sentry's Odoo integration (or self-hosted GlitchTip) is the cleanest way — every MissingError becomes an event with a full traceback and you can spot patterns by user, model, and time.
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、操作绑定。带有印刷徽标+页脚覆盖。