| name | frappe-errors-controllers |
| description | Use when debugging or preventing errors in Frappe Document Controllers. Prevents autoname failures, validate loops, on_submit without is_submittable, wrong lifecycle hook choice, get_list permission errors, NestedSet errors, extend_doctype_class conflicts, missing super() calls, and recursion without flags. Covers error diagnosis by lifecycle phase for v14/v15/v16. Keywords: controller error, autoname, validate loop, on_submit, is_submittable,, save fails, validate error, on_submit not working, autoname broken, controller crash. get_list, NestedSet, extend_doctype_class, super, flags, recursion guard.
|
| license | MIT |
| compatibility | Claude Code, Claude.ai Projects, Claude API. Frappe v14-v16. |
| metadata | {"author":"OpenAEC-Foundation","version":"2.0"} |
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
class CustomDoc(Document):
def autoname(self):
self.name = f"DOC-{self.customer}"
class CustomDoc(Document):
def autoname(self):
from frappe.model.naming import set_name_by_naming_series
set_name_by_naming_series(self)
self.name = frappe.model.naming.make_autoname(
f"DOC-.{self.customer}.-.####"
)
Autoname options: naming_series, field:fieldname, format:PREFIX-{fieldname}-.####, hash, Prompt, or custom autoname() method.
2. Validate Loop: self.save() in Hooks
class SalesOrder(Document):
def validate(self):
self.calculate_totals()
self.save()
def on_update(self):
self.status = "Updated"
self.save()
class SalesOrder(Document):
def validate(self):
self.calculate_totals()
def on_update(self):
self.db_set("status", "Updated")
3. on_submit Without is_submittable
class MyDoc(Document):
def on_submit(self):
self.create_entries()
4. Wrong Lifecycle Hook: Error Timing
class SalesOrder(Document):
def on_submit(self):
if not self.has_stock():
frappe.throw(_("Insufficient stock"))
class SalesOrder(Document):
def before_submit(self):
if not self.has_stock():
frappe.throw(_("Insufficient stock"))
def on_submit(self):
self.create_stock_entries()
Transaction 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
from erpnext.selling.doctype.sales_order.sales_order import SalesOrder
class CustomSalesOrder(SalesOrder):
def validate(self):
self.custom_check()
class CustomSalesOrder(SalesOrder):
def validate(self):
super().validate()
self.custom_check()
6. extend_doctype_class [v16+]
extend_doctype_class = {
"Sales Order": ["myapp.overrides.sales_order.SalesOrderMixin"]
}
class SalesOrderMixin:
"""Mixin class — extends, does not replace."""
def validate(self):
super().validate()
self.custom_validation()
Resolution order: class ExtendedSalesOrder(Mixin2, Mixin1, OriginalSalesOrder) — last mixin listed has highest priority.
7. Flags for Recursion Guard
class SalesOrder(Document):
def on_update(self):
self.update_quotation()
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
q.db_set("status", "Ordered")
8. get_list Permission Errors
def get_permission_query(user):
pass
def get_permission_query(user):
dept = frappe.db.get_value("User", user, "department")
return f"department = '{dept}'"
def get_permission_query(user):
if "System Manager" in frappe.get_roles(user):
return ""
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
class Territory(NestedSet):
def validate(self):
pass
class Territory(NestedSet):
def validate(self):
super().validate()
if self.parent_territory == self.name:
frappe.throw(_("Territory cannot be its own parent"))
on_cancel: Isolate Cleanup Operations
def on_cancel(self):
self.reverse_stock()
self.reverse_gl()
self.update_linked()
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
- Call
super().method() in overridden hooks — Preserve parent logic
- Validate in
before_submit not on_submit — Last clean abort point
- Use
self.db_set() in on_update — Direct self.field = x is lost
- Use
self.flags for recursion guards — Prevent circular hook triggers
- Isolate cleanup operations in
on_cancel — Don't let one failure stop all
- Use
frappe.db.escape() in permission queries — Prevent SQL injection
- Return explicit True/False from
has_permission — None falls back to default
- Use
frappe.log_error() for unexpected exceptions — Never swallow silently
- Use
_() wrapper for all user-facing error messages — Enable translation
NEVER
- NEVER call
self.save() in validate/on_update — Causes infinite recursion
- NEVER call
frappe.db.commit() in controllers — Framework manages transactions
- NEVER put blocking validation in
on_submit — Document already submitted
- NEVER skip
super() in overridden methods — Breaks parent class logic
- NEVER return None from
has_permission — Returns unpredictable results
- NEVER swallow exceptions with bare
except: pass — Always log errors
- NEVER use
override_doctype_class when extend_doctype_class works [v16+]
- 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 |