This article is currently available in English only. Translation coming soon.
Your Odoo code raises this on a routine ORM call:
AttributeError: 'sale.order' object has no attribute 'partner_invoice_email'
Or the empty-recordset variant:
AttributeError: 'sale.order()' object has no attribute 'amount_total'
Both fire on Odoo 17.0/18.0/19.0 and both look identical at first glance — but the empty parentheses in 'sale.order()' are the tell. They point at completely different bugs. This guide separates the two and fixes each properly.
Quick Fix
If the recordset is empty (the model name is followed by empty parens like 'sale.order()'), guard the access:
# WRONG — fails when search returns nothing
order = self.env['sale.order'].search([('name', '=', ref)], limit=1)
total = order.amount_total
# RIGHT — guard the empty case
order = self.env['sale.order'].search([('name', '=', ref)], limit=1)
total = order.amount_total if order else 0.0
If the field genuinely does not exist on the model, you have a typo or a missing dependency:
# Use the field that actually exists
email = order.partner_id.email # not partner_invoice_email
Why This Happens
Odoo recordsets behave like Python objects but with one crucial difference: an empty recordset still answers attribute lookups on its model, but field accesses return falsy or raise depending on context. The error has four common roots:
- Empty recordset.
search()returned nothing, but the next line assumes a record exists..amount_totalon an emptysale.orderrecordset evaluates fine in some flows and raises in others (especially afterread()ormapped()). - Field renamed between versions. Odoo 17.0 renamed several fields. Custom code referring to the old name breaks on upgrade.
- Custom field defined in a module that is not installed in the current database. Code expects
x_studio_prioritybut the customization is only on production, not staging. - Wrong model. A typo like
self.env['sale_order'](underscore instead of dot) returns the right model anyway in Odoo 17+ for legacy reasons, butself.env['sale.orders'](plural) returns nothing — and accessing fields onnothingblows up. - Subclass mismatch. A controller calls a method on a recordset it received via JSON-RPC, but the recordset's model is the parent class and the method lives on a child class.
Step-by-Step Diagnosis
1. Read the error precisely. Note the model and the missing attribute. The model name with empty parens means empty recordset. The model name without parens means the field genuinely does not exist on that model.
2. Confirm the field exists. In a shell:
'partner_invoice_email' in env['sale.order']._fields # True or False
If False, the field is not on the model — typo, version rename, or missing module.
3. Check installed modules.
env['ir.module.module'].search([
('state', '=', 'installed'),
('name', 'in', ['my_custom_module']),
])
If your custom module is not installed in this database, fields it adds will be missing.
4. Check for empty recordsets in the call chain. Add logging:
order = self.env['sale.order'].search(domain, limit=1)
_logger.info("Order found: %s", order) # logs sale.order() if empty
5. Inspect the field across versions. Field renames are documented in the upgrade migration scripts. Check /odoo/addons/<module>/migrations/<version>/ for the field's history.
Permanent Fix
For empty recordsets, use Python's truthiness check on recordsets — empty recordsets are falsy:
order = self.env['sale.order'].search([('name', '=', ref)], limit=1)
if not order:
raise UserError(_("No order matches reference %s.") % ref)
return order.amount_total
For loops, iterate the recordset directly — Python handles empty cases gracefully:
for order in self.env['sale.order'].search(domain):
order.action_confirm() # safe — does nothing if domain matched zero rows
For field renames after a major upgrade, find the new name in Odoo's upgrade scripts:
grep -r "rename_field" /opt/odoo/addons/sale/migrations/ 2>/dev/null
# or check the OCA upgrade analysis report
Then update your code:
# Odoo 17.0
total = order.partner_invoice_id.email_normalized
# Odoo 18.0+ — field consolidated
total = order.partner_id.email_normalized
For missing custom modules, make the dependency explicit in your module's manifest:
{
'name': 'My Sales Extension',
'depends': ['sale', 'sale_management', 'my_custom_partner_fields'],
...
}
Without the explicit depends, Odoo will install your module against a base that lacks the field, and runtime will fail.
For controller / JSON-RPC type mismatches, validate the model server-side:
@http.route('/api/order/confirm', auth='user', type='json')
def confirm_order(self, order_id):
order = request.env['sale.order'].browse(int(order_id)).exists()
if not order:
return {'error': 'not_found'}
if not hasattr(order, 'action_confirm'):
return {'error': 'unsupported_model'}
order.action_confirm()
return {'ok': True}
How to Prevent It
hasattr()for optional fields. When you read a field that might not exist (cross-module, optional integration), guard it:if hasattr(rec, 'x_studio_priority').- Always check
if recordset:before field access when the recordset came fromsearch()orbrowse(int). Make this a code-review rule. - Lint for stale field names post-upgrade. The OCA
pylint-odooextension catches many of these. Wire it into CI before you upgrade. - Test on a database with the same modules as production. Empty recordsets and missing fields are the kind of bug that only shows up against the real data shape. Restore production into staging before every major change.
- Use type hints. Odoo's type stubs (
odoo-stubs) help your IDE catch typos likesale.ordersbefore runtime. Worth the setup cost on any module larger than 1,000 lines. - Read
mapped()carefully.recordset.mapped('partner_id.email')returns an empty list for empty recordsets — safe.recordset.partner_id.emailon an empty recordset can raise. Prefermapped()when you want list semantics.
Related Errors
- Expected singleton — closely related; same family of recordset misuse.
- MissingError: Record does not exist — what happens when the recordset's id has been deleted.
- Required field not set — when an empty recordset's field write fails validation.
Frequently Asked Questions
Why does recordset.field not raise on an empty recordset in some places but does in others?
Empty recordsets return field defaults for Char/Float/Boolean fields silently, but Many2one fields can return either an empty recordset or raise depending on whether the field has been read into the cache. The behaviour shifted slightly in 18.0 and 19.0. Always guard with if recordset: for non-trivial paths instead of relying on the implicit default.
Is getattr(recordset, 'field', None) a good defense?
It is the wrong tool. getattr returns the field descriptor, not its value, and can mask real typos. Use hasattr(recordset, 'field_name') to test existence, then access normally. For values, guard with if recordset:.
How do I know if a field exists on a model without starting Odoo?
Check the model's Python source: every fields.Type(...) declaration. For inherited models, walk the inheritance chain. Or use the OCA module_analysis tool which dumps the full effective field list for a model. In a running shell, env['sale.order']._fields is authoritative.
Why does the same code work in Odoo SaaS but fail in self-hosted?
Almost always because the SaaS instance has a Studio customization (a custom field added via the UI) that does not exist in your self-hosted database. Studio fields are stored as ir.model.fields rows and module-data; export the customization and install it on self-hosted to match.
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.
ECOSIRE
Odoo ERP کے ساتھ اپنے کاروبار کو تبدیل کریں
آپ کے کاموں کو ہموار کرنے کے لیے ماہر Odoo کا نفاذ، حسب ضرورت، اور معاونت۔
متعلقہ مضامین
How to Add a Custom Button to an Odoo Form View (2026)
Add custom action buttons to Odoo 19 form views: Python action method, view inheritance, conditional visibility, confirmation dialogs. Production-tested.
How to Add a Custom Field in Odoo Without Studio (2026)
Add custom fields via custom module in Odoo 19: model inheritance, view extension, computed fields, store/non-store decisions. Code-first, version-controlled.
How to Add a Custom Report in Odoo Using External Layout
Build a branded PDF report in Odoo 19 using web.external_layout: QWeb template, paperformat, action binding. With print logo + footer overrides.