
Frappe Errors Controllers
- 1 installs
- 159 repo stars
- Updated July 8, 2026
- openaec-foundation/erpnext_anthropic_claude_development_skill_package
Diagnoses Frappe Document Controller errors by lifecycle phase, including autoname failures, validate loops, on_submit misuse, and override conflicts.
About
A troubleshooting skill for diagnosing errors in Frappe Document Controllers by lifecycle phase. A developer uses it when saves fail, validate loops, or controller overrides break.
- Diagnoses autoname failures, validate loops, and on_submit misuse
- Covers NestedSet, extend_doctype_class conflicts, missing super(), recursion
Frappe Errors Controllers by the numbers
- 1 all-time installs (skills.sh)
- Ranked #489 of 596 Debugging skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/openaec-foundation/erpnext_anthropic_claude_development_skill_package --skill frappe-errors-controllersAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| repo stars | ★ 159 |
| Last updated | July 8, 2026 |
| Repository | openaec-foundation/erpnext_anthropic_claude_development_skill_package ↗ |
What it does
Diagnoses Frappe Document Controller errors by lifecycle phase, including autoname failures, validate loops, on_submit misuse, and override conflicts.
Files
Controller Errors — Diagnosis and Resolution
Cross-refs: frappe-syntax-controllers (syntax), frappe-impl-controllers (workflows), frappe-errors-serverscripts (server scripts).
---
Error Diagnosis by Lifecycle Phase
CONTROLLER ERROR
│
├─► NAMING PHASE (autoname / before_naming)
│ ├─► NamingSeries not set → Add naming_series field or autoname property
│ ├─► DuplicateEntryError → Name collision, check uniqueness
│ └─► "name cannot be set directly" → Use autoname method, not self.name = x
│
├─► VALIDATION PHASE (before_validate / validate / before_save)
│ ├─► Infinite recursion → doc.save() called inside validate
│ ├─► Validation skipped → Missing super().validate() in override
│ └─► Wrong error timing → Use validate, not on_update, to block save
│
├─► SAVE PHASE (before_save / on_update / after_insert)
│ ├─► Changes lost in on_update → Use db_set(), not self.field = x
│ ├─► Infinite loop → self.save() in on_update triggers on_update again
│ └─► Transaction broken → frappe.db.commit() in controller (DON'T)
│
├─► SUBMIT PHASE (before_submit / on_submit)
│ ├─► "Not allowed to submit" → DocType missing is_submittable = 1
│ ├─► Partial state → Validation in on_submit (too late, already submitted)
│ └─► Stock/GL failures → Entries fail but docstatus already = 1
│
├─► CANCEL PHASE (before_cancel / on_cancel)
│ ├─► "Cannot cancel: linked docs" → Check and handle linked documents
│ └─► Partial cleanup → One reversal fails, rest skipped
│
└─► PERMISSION PHASE (has_permission / get_list)
├─► "Not permitted" → has_permission returns None (should be True/False)
├─► get_list returns nothing → permission_query_conditions SQL error
└─► SQL injection → User input in conditions without escape---
Error Message → Cause → Fix Table
| Error Message | Cause | Fix |
|---|---|---|
NamingSeries is not set | DocType uses naming_series but field is missing | Add naming_series field to DocType or set autoname in controller |
DuplicateEntryError | autoname generated non-unique name | Use naming_series with counter, or add hash suffix |
Maximum recursion depth exceeded | self.save() called in validate/on_update | NEVER call self.save() in hooks; use self.db_set() in on_update |
Not allowed to submit | DocType lacks is_submittable = 1 | Enable "Is Submittable" in DocType settings |
Cannot cancel: linked docs exist | Submitted linked documents block cancellation | Cancel linked docs first, or use before_cancel to check |
AttributeError: super() | Missing super() call in overridden hook | ALWAYS call super().method_name() first in overrides |
Value missing for: field | Controller validate skipped parent logic | Ensure super().validate() is called |
frappe.db.commit() breaks transactions | Manual commit in controller hook | NEVER call frappe.db.commit() in controllers |
Changes lost in on_update | Set self.field = x instead of self.db_set() | Use self.db_set("field", value) after save hooks |
NestedSet: root cannot be child | Parent set to itself or circular reference | Validate parent != self in validate, check lft/rgt |
extend_doctype_class conflict [v16+] | Multiple apps extend same class with conflicting methods | Use MRO-aware design, check method resolution order |
has_permission returns wrong result | Function returns None instead of True/False | ALWAYS return explicit True or False |
permission_query_conditions SQL error | Malformed WHERE clause fragment | Test conditions string independently, use frappe.db.escape() |
---
Critical Error Patterns
1. Autoname Failures
# ❌ WRONG — Setting name directly fails
class CustomDoc(Document):
def autoname(self):
self.name = f"DOC-{self.customer}" # May cause DuplicateEntryError
# ✅ CORRECT — Use naming utilities
class CustomDoc(Document):
def autoname(self):
# Option 1: Naming series
from frappe.model.naming import set_name_by_naming_series
set_name_by_naming_series(self)
# Option 2: Safe format with counter
self.name = frappe.model.naming.make_autoname(
f"DOC-.{self.customer}.-.####"
)
# Option 3: Hash for guaranteed uniqueness
# Set autoname = "hash" in DocType JSON insteadAutoname options: naming_series, field:fieldname, format:PREFIX-{fieldname}-.####, hash, Prompt, or custom autoname() method.
2. Validate Loop: self.save() in Hooks
# ❌ WRONG — Infinite recursion
class SalesOrder(Document):
def validate(self):
self.calculate_totals()
self.save() # Triggers validate again → infinite loop!
def on_update(self):
self.status = "Updated"
self.save() # Triggers on_update again → infinite loop!
# ✅ CORRECT — Framework handles save; use db_set after save
class SalesOrder(Document):
def validate(self):
self.calculate_totals()
# No save() — framework saves after validate completes
def on_update(self):
self.db_set("status", "Updated") # Direct DB write, no trigger3. on_submit Without is_submittable
# ❌ ERROR — "Not allowed to submit"
class MyDoc(Document):
def on_submit(self):
self.create_entries()
# This fails if DocType JSON lacks: "is_submittable": 1
# ✅ FIX — Enable in DocType definition
# In my_doc.json:
# { "is_submittable": 1 }
# Then before_submit and on_submit hooks work4. Wrong Lifecycle Hook: Error Timing
# ❌ WRONG — Validation in on_submit (document already submitted!)
class SalesOrder(Document):
def on_submit(self):
if not self.has_stock():
frappe.throw(_("Insufficient stock")) # docstatus already = 1!
# ✅ CORRECT — ALWAYS validate in before_submit
class SalesOrder(Document):
def before_submit(self):
if not self.has_stock():
frappe.throw(_("Insufficient stock")) # Clean abort, stays Draft
def on_submit(self):
self.create_stock_entries() # Only post-submit actions hereTransaction Rollback Rules by Hook:
| Hook | frappe.throw() Effect |
|---|---|
validate / before_save | Full rollback — document NOT saved |
before_submit | Full rollback — stays Draft |
before_cancel | Full rollback — stays Submitted |
on_update / after_insert | Document IS saved — error shown but doc persists |
on_submit | docstatus = 1 — error shown but ALREADY submitted |
on_cancel | docstatus = 2 — error shown but ALREADY cancelled |
5. Missing super() in Overrides
# ❌ WRONG — Parent validation completely skipped
from erpnext.selling.doctype.sales_order.sales_order import SalesOrder
class CustomSalesOrder(SalesOrder):
def validate(self):
# Parent validate() never runs! All ERPNext validations bypassed!
self.custom_check()
# ✅ CORRECT — ALWAYS call super() first
class CustomSalesOrder(SalesOrder):
def validate(self):
super().validate() # Run all parent validations first
self.custom_check() # Then add custom logic6. extend_doctype_class [v16+]
# In hooks.py — v16+ preferred approach
extend_doctype_class = {
"Sales Order": ["myapp.overrides.sales_order.SalesOrderMixin"]
}
# myapp/overrides/sales_order.py
class SalesOrderMixin:
"""Mixin class — extends, does not replace."""
def validate(self):
super().validate() # ALWAYS call super — runs original + other mixins
self.custom_validation()Resolution order: class ExtendedSalesOrder(Mixin2, Mixin1, OriginalSalesOrder) — last mixin listed has highest priority.
7. Flags for Recursion Guard
# ❌ WRONG — on_update of linked doc triggers this doc's on_update
class SalesOrder(Document):
def on_update(self):
self.update_quotation() # Quotation.on_update triggers back here
# ✅ CORRECT — Use flags to prevent recursion
class SalesOrder(Document):
def on_update(self):
if self.flags.get("skip_linked_update"):
return
self.flags.skip_linked_update = True
self.update_quotation()
def update_quotation(self):
if self.quotation:
q = frappe.get_doc("Quotation", self.quotation)
q.flags.skip_linked_update = True # Prevent back-trigger
q.db_set("status", "Ordered")8. get_list Permission Errors
# ❌ WRONG — permission_query_conditions returns None (fallback to no filter)
def get_permission_query(user):
pass # Returns None — shows ALL records!
# ❌ WRONG — SQL injection
def get_permission_query(user):
dept = frappe.db.get_value("User", user, "department")
return f"department = '{dept}'" # INJECTION RISK
# ✅ CORRECT — Explicit conditions with escape
def get_permission_query(user):
if "System Manager" in frappe.get_roles(user):
return "" # No filter — full access
dept = frappe.db.get_value("User", user, "department")
if dept:
return f"department = {frappe.db.escape(dept)}"
return "owner = {0}".format(frappe.db.escape(user))Note: permission_query_conditions affects frappe.db.get_list() only, NOT frappe.db.get_all().
9. NestedSet Errors
# ❌ WRONG — Circular reference causes lft/rgt corruption
class Territory(NestedSet):
def validate(self):
# No parent validation!
pass
# ✅ CORRECT — Validate parent chain
class Territory(NestedSet):
def validate(self):
super().validate()
if self.parent_territory == self.name:
frappe.throw(_("Territory cannot be its own parent"))
# NestedSet.validate() checks circular refs automatically
# but explicit check gives better error message---
on_cancel: Isolate Cleanup Operations
# ❌ WRONG — First failure stops all cleanup
def on_cancel(self):
self.reverse_stock() # If this fails...
self.reverse_gl() # ...this never runs
self.update_linked() # ...neither does this
# ✅ CORRECT — Isolate each reversal
def on_cancel(self):
errors = []
for operation, label in [
(self.reverse_stock, "Stock reversal"),
(self.reverse_gl, "GL reversal"),
(self.update_linked, "Linked docs"),
]:
try:
operation()
except Exception as e:
errors.append(f"{label}: {str(e)}")
frappe.log_error(frappe.get_traceback(), f"{label} Error")
if errors:
frappe.msgprint(
_("Cancelled with errors:<br>{0}").format("<br>".join(errors)),
indicator="orange"
)---
ALWAYS / NEVER Rules
ALWAYS
1. Call `super().method()` in overridden hooks — Preserve parent logic 2. Validate in `before_submit` not on_submit — Last clean abort point 3. Use `self.db_set()` in `on_update` — Direct self.field = x is lost 4. Use `self.flags` for recursion guards — Prevent circular hook triggers 5. Isolate cleanup operations in `on_cancel` — Don't let one failure stop all 6. Use `frappe.db.escape()` in permission queries — Prevent SQL injection 7. Return explicit True/False from `has_permission` — None falls back to default 8. Use `frappe.log_error()` for unexpected exceptions — Never swallow silently 9. Use `_()` wrapper for all user-facing error messages — Enable translation
NEVER
1. NEVER call `self.save()` in validate/on_update — Causes infinite recursion 2. NEVER call `frappe.db.commit()` in controllers — Framework manages transactions 3. NEVER put blocking validation in `on_submit` — Document already submitted 4. NEVER skip `super()` in overridden methods — Breaks parent class logic 5. NEVER return None from `has_permission` — Returns unpredictable results 6. NEVER swallow exceptions with bare `except: pass` — Always log errors 7. NEVER use `override_doctype_class` when `extend_doctype_class` works [v16+] 8. NEVER put heavy operations in `validate` — Use frappe.enqueue() from on_update
---
Reference Files
| File | Contents |
|---|---|
references/examples.md | Real controller error scenarios with diagnosis |
references/anti-patterns.md | Common controller mistakes with fixes |
references/patterns.md | Defensive error handling patterns by lifecycle hook |
Controller Anti-Patterns — Error Prevention
Each anti-pattern shows the mistake, why it fails, and the correct approach.
---
1. Calling self.save() in Hooks
# ❌ WRONG — Infinite recursion
def validate(self):
self.calculate()
self.save() # Triggers validate again
def on_update(self):
self.status = "Done"
self.save() # Triggers on_update again
# ✅ CORRECT
def validate(self):
self.calculate()
# No save — framework handles it
def on_update(self):
self.db_set("status", "Done") # Direct DB, no triggerWhy: save() triggers the same hook, causing infinite recursion.
---
2. Missing super() in Overrides
# ❌ WRONG — All parent logic bypassed
class CustomInvoice(SalesInvoice):
def validate(self):
self.my_check() # ERPNext validations skipped!
# ✅ CORRECT
class CustomInvoice(SalesInvoice):
def validate(self):
super().validate() # Run parent first
self.my_check()Why: Without super(), critical framework and parent app validations are silently skipped.
---
3. frappe.db.commit() in Controllers
# ❌ WRONG — Breaks transaction management
def validate(self):
frappe.db.set_value("Counter", "main", "count", 1)
frappe.db.commit() # Committed even if save fails later!
# ✅ CORRECT
def validate(self):
frappe.db.set_value("Counter", "main", "count", 1)
# Framework commits everything together, or rolls back togetherWhy: Manual commits break Frappe's request-level transaction, causing partial saves.
---
4. Changes in on_update Without db_set()
# ❌ WRONG — Changes lost
def on_update(self):
self.sync_status = "Synced" # NOT saved!
# ✅ CORRECT
def on_update(self):
self.db_set("sync_status", "Synced")Why: After on_update, the document is already committed. Changes to self are not persisted.
---
5. Validation in on_submit (Too Late)
# ❌ WRONG — Document already submitted!
def on_submit(self):
if self.grand_total > self.credit_limit:
frappe.throw("Credit limit exceeded") # docstatus already 1!
# ✅ CORRECT — Validate in before_submit
def before_submit(self):
if self.grand_total > self.credit_limit:
frappe.throw("Credit limit exceeded") # Clean abort, stays DraftWhy: In on_submit, docstatus is already 1. Throwing creates an inconsistent state.
---
6. Swallowing Errors Silently
# ❌ WRONG — Debugging impossible
def validate(self):
try:
self.check_stock()
except Exception:
pass # What went wrong? Nobody knows.
# ✅ CORRECT
def validate(self):
try:
self.check_stock()
except frappe.ValidationError:
raise # Re-raise validation errors
except Exception as e:
frappe.log_error(frappe.get_traceback(), "Stock Check Error")
frappe.throw(_("Stock check failed: {0}").format(str(e)))Why: Silent error swallowing makes production debugging impossible.
---
7. Not Checking Database Results
# ❌ WRONG — Crashes on missing record
def validate(self):
customer = frappe.get_doc("Customer", self.customer) # DoesNotExistError!
prices = frappe.db.sql("SELECT price FROM tabPrices WHERE item=%s", self.item)
self.price = prices[0][0] # IndexError if empty!
# ✅ CORRECT
def validate(self):
if not frappe.db.exists("Customer", self.customer):
frappe.throw(_("Customer not found"))
customer = frappe.get_doc("Customer", self.customer)
prices = frappe.db.sql("SELECT price FROM tabPrices WHERE item=%s", self.item)
self.price = prices[0][0] if prices else 0Why: ALWAYS verify data exists before accessing it.
---
8. Not Isolating Errors in on_update/on_cancel
# ❌ WRONG — First failure stops all operations
def on_update(self):
self.send_email() # If this fails...
self.sync_to_crm() # ...this never runs
self.update_dashboard() # ...neither does this
# ✅ CORRECT — Each operation isolated
def on_update(self):
operations = [
(self.send_email, "Email"),
(self.sync_to_crm, "CRM sync"),
(self.update_dashboard, "Dashboard"),
]
errors = []
for op, label in operations:
try:
op()
except Exception:
errors.append(label)
frappe.log_error(frappe.get_traceback(), f"{label} Error")
if errors:
frappe.msgprint(
_("Saved. Some operations failed: {0}").format(", ".join(errors)),
indicator="orange"
)Why: Independent post-save operations should not block each other.
---
9. Exposing Technical Errors to Users
# ❌ WRONG
except Exception as e:
frappe.throw(str(e)) # Stack trace to user!
frappe.throw(frappe.get_traceback()) # Even worse!
# ✅ CORRECT
except requests.Timeout:
frappe.throw(_("Service timed out. Please try again."))
except ConnectionError:
frappe.throw(_("Could not connect to external service."))
except Exception as e:
frappe.log_error(frappe.get_traceback(), "External Service Error")
frappe.throw(_("Service error. Please contact support."))Why: Technical details confuse users and may expose sensitive information.
---
10. Broad Exception Handling Without Specificity
# ❌ WRONG — All errors get same vague message
try:
self.check_customer()
self.validate_items()
self.calculate_totals()
except Exception:
frappe.throw(_("Validation failed"))
# ✅ CORRECT — Specific handling per operation
try:
self.check_customer()
except frappe.DoesNotExistError:
frappe.throw(_("Customer not found"))
try:
self.validate_items()
except frappe.ValidationError:
raise # Re-raise with original message
self.calculate_totals() # Let errors propagate naturallyWhy: Specific exception handling gives better error messages and debugging.
---
11. Heavy Operations in validate
# ❌ WRONG — 30-second API call blocks save
def validate(self):
self.sync_to_external_api() # Slow!
self.generate_pdf() # Slow!
# ✅ CORRECT — Queue heavy work
def validate(self):
self.validate_fields() # Fast validation only
def on_update(self):
frappe.enqueue("myapp.tasks.sync_and_generate",
doctype=self.doctype, name=self.name, queue="long")Why: Heavy operations in validate make the UI unresponsive and can timeout.
---
12. Missing Translation Wrapper
# ❌ WRONG — Not translatable
frappe.throw("Customer is required")
frappe.msgprint("Order saved")
# ✅ CORRECT
frappe.throw(_("Customer is required"))
frappe.msgprint(_("Order saved"))Why: Without _(), messages are English-only regardless of user's language.
---
13. Throwing in on_cancel Cleanup
# ❌ WRONG — First throw stops remaining cleanup
def on_cancel(self):
self.reverse_stock() # If this throws...
self.reverse_gl() # ...this never runs!
# ✅ CORRECT — Collect errors, try all operations
def on_cancel(self):
errors = []
for op, label in [
(self.reverse_stock, "Stock"),
(self.reverse_gl, "GL entries"),
]:
try:
op()
except Exception as e:
errors.append(f"{label}: {str(e)}")
frappe.log_error(frappe.get_traceback(), f"{label} Error")
if errors:
frappe.msgprint(
_("Cancelled with issues: {0}").format("<br>".join(errors)),
indicator="orange"
)Why: Cancel cleanup should attempt ALL operations, not stop at the first failure.
---
14. Not Using Flags for Recursion Guard
# ❌ WRONG — Updating linked doc triggers back-update loop
def on_update(self):
if self.quotation:
q = frappe.get_doc("Quotation", self.quotation)
q.db_set("status", "Ordered") # May trigger Quotation.on_update → back to here
# ✅ CORRECT
def on_update(self):
if self.flags.get("skip_linked_update"):
return
if self.quotation:
q = frappe.get_doc("Quotation", self.quotation)
q.flags.skip_linked_update = True
q.db_set("status", "Ordered")Why: Cross-document updates can create circular hook triggers without recursion guards.
---
Pre-Deploy Checklist
- [ ]
super()called in ALL overridden hooks - [ ] No
self.save()in any hook - [ ] No
frappe.db.commit()calls - [ ]
on_updatechanges usedb_set() - [ ] All validation in
before_submit, noton_submit - [ ] All exceptions logged with
frappe.log_error() - [ ] Error messages use
_()wrapper - [ ] None/empty values handled safely
- [ ] Post-save operations isolated in try/except
- [ ] Heavy operations enqueued, not inline
- [ ] Specific exceptions caught before generic
Exception - [ ] Recursion guards (
flags) on cross-document updates - [ ]
on_canceloperations isolated — don't stop on first failure
Controller Error Examples — Real Scenarios
Complete diagnosis-oriented examples showing actual errors, root cause, and fix.
---
Scenario 1: Autoname Failure — DuplicateEntryError
Error:
frappe.exceptions.DuplicateEntryError: ('Custom Doc', 'DOC-CustomerA', ...)The broken code:
class CustomDoc(Document):
def autoname(self):
self.name = f"DOC-{self.customer}" # Not unique if customer has multiple docs!Root cause: Using customer name as document name without a counter. Multiple documents for the same customer collide.
The fix:
class CustomDoc(Document):
def autoname(self):
# Option 1: Name with auto-incrementing counter
self.name = frappe.model.naming.make_autoname(
f"DOC-{self.customer}-.####"
)
# Produces: DOC-CustomerA-0001, DOC-CustomerA-0002, etc.Alternative: Set autoname in DocType JSON:
"autoname": "naming_series:"— Uses naming_series field"autoname": "format:DOC-{customer}-.####"— Format with counter"autoname": "hash"— Random unique hash
---
Scenario 2: Infinite Recursion — self.save() in validate
Error:
RecursionError: maximum recursion depth exceeded while calling a Python objectThe broken code:
class SalesOrder(Document):
def validate(self):
self.calculate_totals()
self.save() # validate → save → validate → save → ...Root cause: self.save() triggers validate() again, creating infinite recursion.
The fix:
class SalesOrder(Document):
def validate(self):
self.calculate_totals()
# No self.save() — framework saves automatically after validateSame issue in on_update:
# ❌ WRONG
def on_update(self):
self.status = "Updated"
self.save() # on_update → save → on_update → ...
# ✅ CORRECT
def on_update(self):
self.db_set("status", "Updated") # Direct DB write, no trigger---
Scenario 3: on_submit Without is_submittable
Error:
frappe.exceptions.DocstatusTransitionError: Cannot change docstatus from 0 to 1The broken code:
# In custom_doc.py
class CustomDoc(Document):
def on_submit(self):
self.create_stock_entries()The DocType JSON lacks "is_submittable": 1.
Root cause: The submit action (docstatus 0 → 1) is blocked because the DocType is not marked as submittable.
The fix: Enable in DocType definition:
{
"name": "Custom Doc",
"is_submittable": 1
}Then before_submit, on_submit, before_cancel, and on_cancel hooks work.
---
Scenario 4: Missing super() — Parent Validation Bypassed
Error: No error thrown, but critical business logic is silently skipped. For example, ERPNext's standard stock validations don't run.
The broken code:
from erpnext.selling.doctype.sales_order.sales_order import SalesOrder
class CustomSalesOrder(SalesOrder):
def validate(self):
# All ERPNext validation skipped! Taxes, permissions, workflow — all bypassed.
self.custom_check()The fix:
class CustomSalesOrder(SalesOrder):
def validate(self):
super().validate() # ALWAYS call parent first
self.custom_check() # Then add custom logicThis applies to ALL overridden hooks: validate, on_submit, on_cancel, before_submit, etc.
---
Scenario 5: Changes Lost in on_update
Symptom: Field is set in on_update, but database shows old value.
The broken code:
class SalesOrder(Document):
def on_update(self):
self.sync_status = "Synced"
self.sync_date = frappe.utils.now()
# Changes are NOT saved — document already committed!Root cause: on_update fires AFTER the save. Changes to self attributes are not persisted.
The fix:
class SalesOrder(Document):
def on_update(self):
self.db_set({
"sync_status": "Synced",
"sync_date": frappe.utils.now()
})
# Or individual: self.db_set("sync_status", "Synced")---
Scenario 6: frappe.db.commit() Breaks Transaction
Error: Partial save — some data committed, other data lost on error.
The broken code:
class SalesOrder(Document):
def validate(self):
self.calculate_totals()
frappe.db.set_value("Counter", "main", "count", self.count + 1)
frappe.db.commit() # Commits counter change even if save fails!Root cause: Manual commit() breaks Frappe's request-level transaction. If the save fails later, the counter is already committed but the document is rolled back.
The fix:
class SalesOrder(Document):
def validate(self):
self.calculate_totals()
frappe.db.set_value("Counter", "main", "count", self.count + 1)
# No commit — framework manages the transaction---
Scenario 7: Validation in on_submit — Partial State
Error: Document is submitted (docstatus=1) but stock entries failed.
The broken code:
class SalesOrder(Document):
def on_submit(self):
if not self.has_stock():
frappe.throw(_("Insufficient stock"))
# Document is ALREADY submitted! frappe.throw shows error
# but docstatus is already 1 — inconsistent state!The fix:
class SalesOrder(Document):
def before_submit(self):
# ALWAYS validate here — last chance for clean abort
if not self.has_stock():
frappe.throw(_("Insufficient stock"))
# Clean abort — document stays Draft
def on_submit(self):
# Only post-submit actions (create entries, send notifications)
self.create_stock_entries()---
Scenario 8: NestedSet Circular Reference
Error:
frappe.exceptions.ValidationError: Item cannot be added as its own parentThe broken code:
class Territory(NestedSet):
def validate(self):
pass # No parent validationThe fix:
from frappe.utils.nestedset import NestedSet
class Territory(NestedSet):
nsm_parent_field = "parent_territory"
def validate(self):
super().validate() # NestedSet checks circular refs
# Additional check for self-reference
if self.parent_territory == self.name:
frappe.throw(_("Territory cannot be its own parent"))---
Scenario 9: extend_doctype_class Method Conflict [v16+]
Symptom: Two apps extend Sales Order — second app's validate replaces first app's.
The broken code:
# App A: hooks.py
extend_doctype_class = {"Sales Order": ["app_a.overrides.SalesOrderMixin"]}
# App A: overrides.py
class SalesOrderMixin:
def validate(self):
self.custom_a_check() # Missing super()!
# App B: hooks.py
extend_doctype_class = {"Sales Order": ["app_b.overrides.SalesOrderMixin"]}
# App B: overrides.py
class SalesOrderMixin:
def validate(self):
self.custom_b_check() # Missing super()!Root cause: Both mixins override validate without calling super(). Only the last one in MRO runs.
The fix:
# App A
class SalesOrderMixin:
def validate(self):
super().validate() # Calls next in MRO chain
self.custom_a_check()
# App B
class SalesOrderMixin:
def validate(self):
super().validate() # Calls App A's validate, then original
self.custom_b_check()---
Scenario 10: Flags Not Used — Recursive Hook Trigger
Symptom: Updating a linked document in on_update triggers that document's on_update, which updates back, creating a loop.
The broken code:
class SalesOrder(Document):
def on_update(self):
if self.quotation:
q = frappe.get_doc("Quotation", self.quotation)
q.db_set("status", "Ordered")
# If Quotation.on_update updates SalesOrder back → loop!The fix:
class SalesOrder(Document):
def on_update(self):
if self.flags.get("from_quotation_update"):
return # Break the cycle
if self.quotation:
q = frappe.get_doc("Quotation", self.quotation)
q.flags.from_order_update = True
q.db_set("status", "Ordered")---
Quick Diagnosis by Error Type
| Error Type | Likely Hook | Common Cause |
|---|---|---|
RecursionError | validate / on_update | self.save() in hook |
DuplicateEntryError | autoname | Non-unique name generation |
DocstatusTransitionError | on_submit | is_submittable not set |
ValidationError (missing) | validate | super() not called |
| Changes lost | on_update | self.field = x instead of db_set() |
| Partial state | on_submit | Validation too late |
| Circular ref | validate (NestedSet) | Parent set to self |
Controller Error Handling Patterns
Reusable patterns for defensive error handling in Frappe Document Controllers.
---
Pattern 1: Validation Error Collector
import frappe
from frappe import _
class SalesOrder(Document):
def validate(self):
errors = []
warnings = []
# Required fields
if not self.customer:
errors.append(_("Customer is required"))
if not self.items:
errors.append(_("At least one item is required"))
# Business rules
if self.discount_percent and self.discount_percent > 50:
errors.append(_("Discount cannot exceed 50%"))
# Child table validation
for idx, item in enumerate(self.items or [], 1):
if not item.item_code:
errors.append(_("Row {0}: Item Code is required").format(idx))
if (item.qty or 0) <= 0:
errors.append(_("Row {0}: Quantity must be positive").format(idx))
# Warnings (non-blocking)
if self.grand_total and self.grand_total > 100000:
warnings.append(_("Large order — may require approval"))
if warnings:
frappe.msgprint("<br>".join(warnings), title=_("Warnings"), indicator="orange")
if errors:
frappe.throw("<br>".join(errors), title=_("Validation Error"))---
Pattern 2: Safe Controller Override
from erpnext.selling.doctype.sales_order.sales_order import SalesOrder
import frappe
from frappe import _
class CustomSalesOrder(SalesOrder):
def validate(self):
# ALWAYS call parent first
super().validate()
# Then add custom logic
self.validate_credit()
def on_submit(self):
super().on_submit()
# Non-critical post-submit
try:
self.sync_external()
except Exception:
frappe.log_error(frappe.get_traceback(), f"Sync failed: {self.name}")
frappe.msgprint(_("Submitted. External sync will retry."), indicator="orange")
def validate_credit(self):
if not self.customer:
return
limit = frappe.db.get_value("Customer", self.customer, "credit_limit") or 0
if limit and self.grand_total > limit:
frappe.throw(
_("Amount {0} exceeds credit limit {1}").format(
frappe.format_value(self.grand_total, {"fieldtype": "Currency"}),
frappe.format_value(limit, {"fieldtype": "Currency"})
)
)---
Pattern 3: Isolated Post-Save Operations
class SalesOrder(Document):
def on_update(self):
"""Each operation independent — one failure doesn't block others."""
errors = []
operations = [
(self.update_quotation, "Quotation update"),
(self.sync_to_crm, "CRM sync"),
(self.send_notification, "Notification"),
]
for operation, label in operations:
try:
operation()
except Exception:
errors.append(label)
frappe.log_error(frappe.get_traceback(), f"{label} failed: {self.name}")
if errors:
frappe.msgprint(
_("Saved. Failed: {0}").format(", ".join(errors)),
indicator="orange"
)---
Pattern 4: Submit with Proper Hook Separation
class SalesOrder(Document):
def before_submit(self):
"""ALL validation here — last clean abort point."""
# Stock check
for item in self.items:
if item.warehouse:
available = self.get_stock(item.item_code, item.warehouse)
if available < item.qty:
frappe.throw(
_("Row {0}: Insufficient stock for {1}. Available: {2}").format(
item.idx, item.item_code, available
)
)
# Approval check
if self.grand_total > 100000 and not self.manager_approval:
frappe.throw(_("Manager approval required for orders over 100,000"))
def on_submit(self):
"""Post-submit actions only — document is already submitted."""
# Critical: create stock entries
try:
self.create_stock_entries()
except Exception as e:
frappe.log_error(frappe.get_traceback(), "Stock Entry Error")
frappe.throw(_("Stock entries failed: {0}").format(str(e)))
# Non-critical: update customer stats
try:
frappe.db.set_value("Customer", self.customer, "last_order_date", self.transaction_date)
except Exception:
frappe.log_error(frappe.get_traceback(), "Customer Update Error")---
Pattern 5: Cancel with Full Cleanup
class SalesOrder(Document):
def before_cancel(self):
"""Check if cancel is allowed."""
linked = frappe.get_all("Delivery Note Item",
filters={"against_sales_order": self.name, "docstatus": 1},
pluck="parent")
if linked:
frappe.throw(
_("Cannot cancel. Linked Delivery Notes: {0}").format(
", ".join(set(linked))
)
)
def on_cancel(self):
"""Attempt all cleanup operations."""
errors = []
for operation, label in [
(self.release_stock, "Stock release"),
(self.reverse_gl, "GL reversal"),
(self.update_linked_docs, "Linked docs"),
]:
try:
operation()
except Exception as e:
errors.append(f"{label}: {str(e)}")
frappe.log_error(frappe.get_traceback(), f"{label} Error")
if errors:
frappe.msgprint(
_("Cancelled with errors:<br>{0}").format("<br>".join(errors)),
indicator="orange"
)---
Pattern 6: External API Call with Fallback
import requests
from requests.exceptions import Timeout, ConnectionError, RequestException
class PaymentDoc(Document):
def validate(self):
if self.requires_verification:
self.verify_external()
def verify_external(self):
try:
response = requests.post(
self.api_endpoint,
json={"ref": self.name},
timeout=10 # ALWAYS set timeout
)
if response.status_code == 200:
self.verified = 1
elif response.status_code == 401:
frappe.throw(_("API credentials invalid"))
elif response.status_code >= 500:
frappe.throw(_("External service unavailable"))
else:
frappe.throw(_("Verification failed: {0}").format(response.text[:200]))
except Timeout:
frappe.throw(_("Verification timed out. Please try again."))
except ConnectionError:
frappe.throw(_("Could not connect to verification service."))
except RequestException as e:
frappe.log_error(frappe.get_traceback(), "Verification Error")
frappe.throw(_("Verification error. Please try again."))---
Pattern 7: Recursion Guard with Flags
class SalesOrder(Document):
def on_update(self):
if self.flags.get("skip_linked_update"):
return
if self.quotation:
self.update_quotation()
def update_quotation(self):
q = frappe.get_doc("Quotation", self.quotation)
q.flags.skip_linked_update = True # Prevent Quotation from updating back
q.db_set("status", "Ordered")---
Pattern 8: Batch Processing with Savepoints
class BulkProcessor(Document):
def on_submit(self):
results = {"success": [], "failed": []}
for item in self.items:
frappe.db.savepoint(f"item_{item.idx}")
try:
self.process_item(item)
results["success"].append(item.item_code)
except frappe.ValidationError as e:
frappe.db.rollback(save_point=f"item_{item.idx}")
results["failed"].append({"item": item.item_code, "error": str(e)})
except Exception as e:
frappe.db.rollback(save_point=f"item_{item.idx}")
frappe.log_error(frappe.get_traceback(), f"Batch Error: {item.item_code}")
results["failed"].append({"item": item.item_code, "error": "Unexpected error"})
self.db_set("processed_count", len(results["success"]))
self.db_set("failed_count", len(results["failed"]))
if results["failed"]:
detail = "<br>".join(f"{f['item']}: {f['error']}" for f in results["failed"][:10])
frappe.msgprint(
_("{0} processed, {1} failed:<br>{2}").format(
len(results["success"]), len(results["failed"]), detail
),
indicator="orange"
)---
Pattern 9: Change Detection with Safe Comparison
class Contract(Document):
def validate(self):
if not self.is_new():
self.validate_changes()
def validate_changes(self):
old = self.get_doc_before_save()
if not old:
return
# Safe comparison (handle None)
if (old.get("status") or "") != (self.status or ""):
self.validate_status_transition(old.status, self.status)
if (old.get("contract_value") or 0) != (self.contract_value or 0):
change = abs((self.contract_value or 0) - (old.contract_value or 0))
if old.contract_value and change / old.contract_value > 0.25:
frappe.throw(_("Value change exceeds 25% limit"))
def validate_status_transition(self, old_status, new_status):
allowed = {
"Draft": ["Active", "Cancelled"],
"Active": ["Completed", "Suspended"],
"Suspended": ["Active", "Cancelled"],
}
if old_status and new_status not in allowed.get(old_status, []):
frappe.throw(_("Cannot change from {0} to {1}").format(old_status, new_status))---
Pattern 10: Async Background Task with Error Tracking
class DataImport(Document):
def on_submit(self):
if not self.import_file:
frappe.throw(_("Import file is required"))
frappe.enqueue(
"myapp.tasks.run_import",
queue="long",
timeout=3600,
job_id=f"import_{self.name}",
import_name=self.name
)
self.db_set("status", "Processing")
frappe.msgprint(_("Import started. You will be notified when complete."))
# In myapp/tasks.py
def run_import(import_name):
doc = frappe.get_doc("Data Import", import_name)
try:
# Process...
doc.db_set("status", "Completed")
frappe.db.commit()
frappe.publish_realtime("import_done", {"name": import_name}, user=doc.owner)
except Exception:
frappe.log_error(frappe.get_traceback(), f"Import Failed: {import_name}")
doc.db_set("status", "Failed")
frappe.db.commit()
frappe.publish_realtime("import_failed", {"name": import_name}, user=doc.owner)