---
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
```python
# ❌ 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 instead
```
**Autoname options**: `naming_series`, `field:fieldname`, `format:PREFIX-{fieldname}-.####`, `hash`, `Prompt`, or custom `autoname()` method.
### 2. Validate Loop: self.save() in Hooks
```python
# ❌ 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 trigger
```
### 3. on_submit Without is_submittable
```python
# ❌ 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 work
```
### 4. Wrong Lifecycle Hook: Error Timing
```python
# ❌ 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 here
```
**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
```python
# ❌ 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 logic
```
### 6. extend_doctype_class [v16+]
```python
# 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
```python
# ❌ 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
```python
# ❌ 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
```python
# ❌ 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
```python
# ❌ 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:
{0}").format("
".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 |