ERPNext Controllers - Error Handling
This skill covers error handling patterns for Document Controllers. For syntax, see erpnext-syntax-controllers . For implementation workflows, see erpnext-impl-controllers .
Version: v14/v15/v16 compatible
Controllers vs Server Scripts: Error Handling
┌─────────────────────────────────────────────────────────────────────┐ │ CONTROLLERS HAVE FULL PYTHON POWER │ ├─────────────────────────────────────────────────────────────────────┤ │ │ │ ✅ try/except blocks - Full exception handling │ │ ✅ raise statements - Custom exceptions │ │ ✅ Multiple except clauses - Handle specific errors │ │ ✅ finally blocks - Cleanup operations │ │ ✅ frappe.throw() - Stop with user message │ │ ✅ frappe.log_error() - Silent error logging │ │ │ │ ⚠️ Transaction behavior varies by hook: │ │ • validate: throw rolls back entire save │ │ • on_update: document already saved! │ │ • on_submit: partial rollback possible │ │ │ └─────────────────────────────────────────────────────────────────────┘
Main Decision: Error Handling by Hook
┌─────────────────────────────────────────────────────────────────────────┐ │ WHICH LIFECYCLE HOOK ARE YOU IN? │ ├─────────────────────────────────────────────────────────────────────────┤ │ │ │ ► validate / before_save │ │ └─► frappe.throw() → Rolls back, document NOT saved │ │ └─► try/except → Catch and re-throw or handle gracefully │ │ │ │ ► on_update / after_insert │ │ └─► Document already saved! frappe.throw() shows error but saved │ │ └─► Use try/except + log_error for non-critical operations │ │ └─► Critical failures: frappe.throw() (shows error, doc is saved) │ │ │ │ ► before_submit │ │ └─► frappe.throw() → Prevents submit, stays draft │ │ └─► Last chance for validation before docstatus=1 │ │ │ │ ► on_submit │ │ └─► Document is submitted! throw shows error but docstatus=1 │ │ └─► Critical: throw causes partial state (submitted but failed) │ │ └─► Better: validate everything in before_submit │ │ │ │ ► on_cancel │ │ └─► Reverse operations - use try/except for each reversal │ │ └─► Log errors but try to continue cleanup │ │ │ └─────────────────────────────────────────────────────────────────────────┘
Error Methods Reference
Quick Reference
Method Stops Execution? Rolls Back? User Sees? Use For
frappe.throw()
✅ YES Depends on hook Dialog Validation errors
raise Exception
✅ YES Depends on hook Error page Internal errors
frappe.msgprint()
❌ NO ❌ NO Dialog Warnings
frappe.log_error()
❌ NO ❌ NO Error Log Debug/audit
Transaction Rollback by Hook
Hook frappe.throw() Effect
validate
✅ Full rollback - document NOT saved
before_save
✅ Full rollback - document NOT saved
on_update
⚠️ Document IS saved, error shown
after_insert
⚠️ Document IS saved, error shown
before_submit
✅ Full rollback - stays Draft
on_submit
⚠️ docstatus=1, error shown
before_cancel
✅ Full rollback - stays Submitted
on_cancel
⚠️ docstatus=2, error shown
Error Handling Patterns
Pattern 1: Validation with Error Collection
def validate(self): """Collect all errors before throwing.""" errors = []
# 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 > 50:
errors.append(_("Discount cannot exceed 50%"))
# Child table validation
for idx, item in enumerate(self.items, 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))
# Throw all errors at once
if errors:
frappe.throw("<br>".join(errors), title=_("Validation Error"))
Pattern 2: External API Call with Fallback
def validate(self): """Call external API with error handling.""" if self.requires_credit_check: try: result = self.check_credit_external() self.credit_score = result.get("score", 0) except requests.Timeout: # Timeout - use cached value frappe.msgprint( _("Credit check timed out. Using cached value."), indicator="orange" ) self.credit_score = self.get_cached_credit_score() except requests.RequestException as e: # API error - log and continue with warning frappe.log_error( f"Credit check failed: {str(e)}", "External API Error" ) frappe.msgprint( ("Credit check unavailable. Please verify manually."), indicator="orange" ) self.credit_check_pending = 1 except Exception as e: # Unexpected error - log and re-raise frappe.log_error(frappe.get_traceback(), "Credit Check Error") frappe.throw(("Credit check failed. Please try again."))
Pattern 3: Safe Post-Save Operations
def on_update(self): """Handle post-save operations safely.""" # Critical operation - throw on failure self.update_linked_documents()
# Non-critical operations - log errors, don't throw
try:
self.send_notification()
except Exception:
frappe.log_error(
frappe.get_traceback(),
f"Notification failed for {self.name}"
)
try:
self.sync_to_external_system()
except Exception:
frappe.log_error(
frappe.get_traceback(),
f"External sync failed for {self.name}"
)
# Queue for retry
frappe.enqueue(
"myapp.tasks.retry_sync",
doctype=self.doctype,
name=self.name,
queue="short"
)
Pattern 4: Submittable Document Error Handling
def before_submit(self): """All validations that must pass before submit.""" # Validate everything here - last chance to abort cleanly if not self.items: frappe.throw(_("Cannot submit without items"))
if self.grand_total <= 0:
frappe.throw(_("Total must be greater than zero"))
# Check stock availability
for item in self.items:
available = get_stock_balance(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
)
)
def on_submit(self): """Post-submit actions - document is already submitted!""" # These operations should not fail if before_submit passed try: self.create_stock_ledger_entries() except Exception as e: # CRITICAL: Document is submitted but entries failed! frappe.log_error(frappe.get_traceback(), "Stock Ledger Error") frappe.throw( _("Stock entries failed. Please cancel and retry. Error: {0}").format(str(e)) )
try:
self.create_gl_entries()
except Exception as e:
# Rollback stock entries if GL fails
self.reverse_stock_ledger_entries()
frappe.log_error(frappe.get_traceback(), "GL Entry Error")
frappe.throw(_("Accounting entries failed. Stock entries reversed."))
Pattern 5: Cancel with Cleanup
def before_cancel(self): """Validate cancel is allowed.""" # Check for linked documents linked_invoices = frappe.get_all( "Sales Invoice Item", filters={"sales_order": self.name, "docstatus": 1}, pluck="parent" )
if linked_invoices:
frappe.throw(
_("Cannot cancel. Linked invoices exist: {0}").format(
", ".join(linked_invoices)
)
)
def on_cancel(self): """Reverse operations - try to complete all cleanup.""" errors = []
# Reverse stock
try:
self.reverse_stock_ledger_entries()
except Exception as e:
errors.append(f"Stock reversal: {str(e)}")
frappe.log_error(frappe.get_traceback(), "Stock Reversal Error")
# Reverse GL
try:
self.reverse_gl_entries()
except Exception as e:
errors.append(f"GL reversal: {str(e)}")
frappe.log_error(frappe.get_traceback(), "GL Reversal Error")
# Update linked docs
try:
self.update_linked_on_cancel()
except Exception as e:
errors.append(f"Linked docs: {str(e)}")
frappe.log_error(frappe.get_traceback(), "Linked Doc Update Error")
# Report any errors but don't prevent cancel
if errors:
frappe.msgprint(
_("Cancel completed with errors:<br>{0}").format("<br>".join(errors)),
title=_("Warning"),
indicator="orange"
)
Pattern 6: Database Operation Error Handling
def validate(self): """Handle database errors gracefully.""" try: # Check for duplicates existing = frappe.db.exists( "Customer Contract", {"customer": self.customer, "status": "Active", "name": ["!=", self.name]} ) if existing: frappe.throw(_("Active contract already exists for this customer"))
except frappe.db.InternalError as e:
# Database error - log and show user-friendly message
frappe.log_error(frappe.get_traceback(), "Database Error")
frappe.throw(_("Database error. Please try again or contact support."))
See: references/patterns.md for more error handling patterns.
Transaction Management
Understanding Transactions
Frappe wraps each request in a transaction
- On success: auto-commit
- On exception: auto-rollback
def validate(self): # All these changes are in ONE transaction self.calculate_totals() frappe.db.set_value("Counter", "main", "count", 100)
if error_condition:
frappe.throw("Error") # EVERYTHING rolls back
def on_update(self): # Document save is already committed! # New changes here are in a NEW transaction frappe.db.set_value("Other", "doc", "field", "value")
if error_condition:
frappe.throw("Error") # Only on_update changes roll back
# The document itself is already saved!
Manual Savepoints (Advanced)
def on_submit(self): """Use savepoints for partial rollback.""" # Create savepoint before risky operation frappe.db.savepoint("before_stock")
try:
self.create_stock_entries()
except Exception:
# Rollback only stock entries
frappe.db.rollback(save_point="before_stock")
frappe.log_error(frappe.get_traceback(), "Stock Entry Error")
frappe.throw(_("Stock entries failed"))
frappe.db.savepoint("before_gl")
try:
self.create_gl_entries()
except Exception:
frappe.db.rollback(save_point="before_gl")
frappe.log_error(frappe.get_traceback(), "GL Entry Error")
frappe.throw(_("GL entries failed"))
Critical Rules
✅ ALWAYS
-
Collect multiple validation errors - Better UX than one at a time
-
Use try/except around external calls - APIs, file I/O, network
-
Log unexpected errors - frappe.log_error(frappe.get_traceback())
-
Call super() in overridden methods - Preserve parent behavior
-
Validate in before_submit - Last clean abort point for submittables
-
Use _() for error messages - Enable translation
❌ NEVER
-
Don't call frappe.db.commit() - Framework handles transactions
-
Don't swallow errors silently - Always log unexpected exceptions
-
Don't assume on_update can rollback doc - It's already saved
-
Don't put critical logic in on_submit - Validate in before_submit
-
Don't ignore return values - Check for None/empty results
Quick Reference: Exception Handling
Catch specific exceptions first, general last
try: result = risky_operation() except frappe.ValidationError: # Re-raise validation errors raise except frappe.DoesNotExistError: # Handle missing document frappe.throw(("Referenced document not found")) except requests.Timeout: # Handle timeout specifically frappe.msgprint(("Operation timed out"), indicator="orange") except Exception as e: # Log and handle unexpected errors frappe.log_error(frappe.get_traceback(), "Unexpected Error") frappe.throw(_("An error occurred: {0}").format(str(e)))
Reference Files
File Contents
references/patterns.md
Complete error handling patterns
references/examples.md
Full working examples
references/anti-patterns.md
Common mistakes to avoid
See Also
-
erpnext-syntax-controllers
-
Controller syntax
-
erpnext-impl-controllers
-
Implementation workflows
-
erpnext-errors-serverscripts
-
Server Script error handling (sandbox)
-
erpnext-errors-hooks
-
Hook error handling