ERPNext Hooks - Error Handling
This skill covers error handling patterns for hooks.py configurations. For syntax, see erpnext-syntax-hooks . For implementation workflows, see erpnext-impl-hooks .
Version: v14/v15/v16 compatible
Hooks Error Handling Overview
┌─────────────────────────────────────────────────────────────────────┐ │ HOOKS HAVE UNIQUE ERROR HANDLING CHARACTERISTICS │ ├─────────────────────────────────────────────────────────────────────┤ │ │ │ ✅ Full Python power (try/except, raise) │ │ ⚠️ Multiple handlers in chain - one failure affects others │ │ ⚠️ Some hooks are silent (scheduler, permission_query) │ │ ⚠️ Transaction behavior varies by hook type │ │ │ │ Key differences from controllers: │ │ • doc_events runs AFTER controller methods │ │ • Multiple apps can register handlers (order matters!) │ │ • Scheduler has NO user feedback - logging is critical │ │ • Permission hooks should NEVER throw errors │ │ │ └─────────────────────────────────────────────────────────────────────┘
Main Decision: Error Handling by Hook Type
┌─────────────────────────────────────────────────────────────────────────┐ │ WHICH HOOK TYPE ARE YOU USING? │ ├─────────────────────────────────────────────────────────────────────────┤ │ │ │ ► doc_events (validate, on_update, on_submit, etc.) │ │ └─► Same as controllers: frappe.throw() rolls back in validate │ │ └─► Multiple handlers: first error stops chain │ │ └─► Isolate non-critical operations in try/except │ │ │ │ ► scheduler_events (daily, hourly, cron) │ │ └─► NO user feedback - frappe.log_error() is essential │ │ └─► ALWAYS use try/except around operations │ │ └─► MUST call frappe.db.commit() manually │ │ │ │ ► permission_query_conditions │ │ └─► NEVER throw errors - return empty string on error │ │ └─► Silent failures break list views │ │ └─► Log errors but return safe fallback │ │ │ │ ► has_permission │ │ └─► NEVER throw errors - return False on error │ │ └─► Return None to defer to default permission │ │ │ │ ► override_doctype_class / extend_doctype_class │ │ └─► ALWAYS call super() in try/except │ │ └─► Parent errors should usually propagate │ │ │ │ ► extend_bootinfo │ │ └─► Errors break page load entirely! │ │ └─► ALWAYS wrap in try/except with fallback │ │ │ └─────────────────────────────────────────────────────────────────────────┘
doc_events Error Handling
Transaction Behavior (Same as Controllers)
Event 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
on_submit
⚠️ docstatus=1, error shown
on_cancel
⚠️ docstatus=2, error shown
Multiple Handler Chain
hooks.py - Multiple apps can register handlers
App A
doc_events = { "Sales Invoice": { "validate": "app_a.events.validate_si" # Runs first } }
App B
doc_events = { "Sales Invoice": { "validate": "app_b.events.validate_si" # Runs second } }
If App A throws error, App B's handler NEVER runs!
Pattern: Validate Handler
myapp/events/sales_invoice.py
import frappe from frappe import _
def validate(doc, method=None): """Validate handler with proper error handling.""" errors = []
# Collect validation errors
if doc.grand_total < 0:
errors.append(_("Total cannot be negative"))
if doc.custom_field and not doc.customer:
errors.append(_("Customer required when custom field is set"))
# Throw all at once
if errors:
frappe.throw("<br>".join(errors))
Pattern: on_update Handler (Isolated Operations)
def on_update(doc, method=None): """Post-save handler with isolated operations.""" # Critical operation - let errors propagate update_linked_records(doc)
# Non-critical operations - isolate errors
try:
send_notification(doc)
except Exception:
frappe.log_error(
frappe.get_traceback(),
f"Notification failed for {doc.name}"
)
try:
sync_to_external(doc)
except Exception:
frappe.log_error(
frappe.get_traceback(),
f"External sync failed for {doc.name}"
)
scheduler_events Error Handling
Critical: No User Feedback!
┌─────────────────────────────────────────────────────────────────────┐ │ ⚠️ SCHEDULER TASKS HAVE NO USER - LOGGING IS ESSENTIAL │ ├─────────────────────────────────────────────────────────────────────┤ │ │ │ • No one sees frappe.throw() - task just fails silently │ │ • No automatic email on failure (unless configured) │ │ • frappe.log_error() is your ONLY debugging tool │ │ • Always commit changes manually │ │ │ └─────────────────────────────────────────────────────────────────────┘
Pattern: Scheduler Task with Error Handling
myapp/tasks.py
import frappe
def daily_sync(): """Daily sync task with comprehensive error handling.""" results = { "processed": 0, "errors": [] }
try:
# Get records to process (ALWAYS with limit!)
records = frappe.get_all(
"Sales Invoice",
filters={"sync_status": "Pending"},
limit=500
)
for record in records:
try:
process_record(record.name)
results["processed"] += 1
except Exception as e:
results["errors"].append(f"{record.name}: {str(e)}")
frappe.log_error(
frappe.get_traceback(),
f"Sync error: {record.name}"
)
# REQUIRED: Commit changes
frappe.db.commit()
except Exception as e:
# Log fatal errors
frappe.log_error(
frappe.get_traceback(),
"Daily Sync Fatal Error"
)
return
# Log summary
if results["errors"]:
summary = f"Processed: {results['processed']}, Errors: {len(results['errors'])}"
frappe.log_error(
summary + "\n\n" + "\n".join(results["errors"][:50]),
"Daily Sync Summary"
)
Pattern: Scheduler with Batch Commits
def process_large_dataset(): """Process large dataset with periodic commits.""" BATCH_SIZE = 100
try:
records = frappe.get_all("Item", limit=5000)
total = len(records)
for i in range(0, total, BATCH_SIZE):
batch = records[i:i + BATCH_SIZE]
for record in batch:
try:
update_item(record.name)
except Exception:
frappe.log_error(
frappe.get_traceback(),
f"Item update error: {record.name}"
)
# Commit after each batch
frappe.db.commit()
except Exception:
frappe.log_error(frappe.get_traceback(), "Batch Processing Error")
Permission Hooks Error Handling
permission_query_conditions - NEVER Throw!
❌ WRONG - Breaks list view entirely!
def query_conditions(user): if not user: frappe.throw("User required") # DON'T DO THIS! return f"owner = '{user}'"
✅ CORRECT - Return safe fallback
def query_conditions(user): """Permission query with error handling.""" try: if not user: user = frappe.session.user
if "System Manager" in frappe.get_roles(user):
return "" # No restrictions
return f"`tabSales Invoice`.owner = {frappe.db.escape(user)}"
except Exception:
frappe.log_error(
frappe.get_traceback(),
"Permission Query Error"
)
# Safe fallback - restrict to own records
return f"`tabSales Invoice`.owner = {frappe.db.escape(frappe.session.user)}"
has_permission - NEVER Throw!
❌ WRONG - Breaks document access!
def has_permission(doc, user=None, permission_type=None): if doc.status == "Locked": frappe.throw("Document is locked") # DON'T DO THIS!
✅ CORRECT - Return boolean or None
def has_permission(doc, user=None, permission_type=None): """Document permission check with error handling.""" try: user = user or frappe.session.user
# Deny access to locked documents
if doc.status == "Locked" and permission_type == "write":
return False
# Custom logic
if permission_type == "delete":
if doc.has_linked_records():
return False
# Return None to defer to default permission system
return None
except Exception:
frappe.log_error(
frappe.get_traceback(),
f"Permission check error: {doc.name}"
)
# Safe fallback - defer to default
return None
Override Hooks Error Handling
override_doctype_class
myapp/overrides.py
from erpnext.selling.doctype.sales_order.sales_order import SalesOrder import frappe from frappe import _
class CustomSalesOrder(SalesOrder): def validate(self): """Override with proper error handling.""" # ALWAYS call parent first in try/except try: super().validate() except frappe.ValidationError: # Re-raise validation errors raise except Exception as e: frappe.log_error(frappe.get_traceback(), "Parent Validate Error") raise
# Custom validation
self.custom_validate()
def custom_validate(self):
if self.custom_approval_required and not self.custom_approved:
frappe.throw(_("Approval required before saving"))
extend_doctype_class (V16+)
myapp/extends.py
import frappe from frappe import _
class SalesOrderExtend: """Extension class - only add new methods."""
def custom_approval_check(self):
"""New method with error handling."""
try:
if not self.custom_approver:
frappe.throw(_("Approver not set"))
approver = frappe.get_doc("User", self.custom_approver)
if not approver.enabled:
frappe.throw(_("Approver is disabled"))
except frappe.DoesNotExistError:
frappe.throw(_("Approver not found"))
extend_bootinfo Error Handling
Critical: Errors Break Page Load!
❌ WRONG - Unhandled error breaks desk entirely!
def extend_boot(bootinfo): settings = frappe.get_single("My Settings") # What if it doesn't exist? bootinfo.my_config = settings.config
✅ CORRECT - Always handle errors
def extend_boot(bootinfo): """Extend bootinfo with error handling.""" try: if frappe.db.exists("My Settings", "My Settings"): settings = frappe.get_single("My Settings") bootinfo.my_config = settings.config or {} else: bootinfo.my_config = {}
except Exception:
frappe.log_error(
frappe.get_traceback(),
"Bootinfo Extension Error"
)
# Safe fallback
bootinfo.my_config = {}
Critical Rules
✅ ALWAYS
-
Use try/except in scheduler tasks - No user feedback otherwise
-
Call frappe.db.commit() in scheduler - Changes aren't auto-saved
-
Return safe fallbacks in permission hooks - Never throw
-
Call super() in override classes - Preserve parent behavior
-
Log errors with context - Include document name, operation
-
Wrap extend_bootinfo in try/except - Errors break page load
❌ NEVER
-
Don't throw in permission_query_conditions - Breaks list views
-
Don't throw in has_permission - Breaks document access
-
Don't assume single handler - Multiple apps can register
-
Don't commit in doc_events - Framework handles transactions
-
Don't ignore scheduler errors - They fail silently
Quick Reference: Error Handling by Hook
Hook Type Can Throw? Commit? Key Pattern
doc_events (validate) ✅ YES ❌ NO Collect errors, throw once
doc_events (on_update) ⚠️ Careful ❌ NO Isolate non-critical ops
scheduler_events ❌ Pointless ✅ YES Try/except + log_error
permission_query_conditions ❌ NEVER ❌ NO Return "" on error
has_permission ❌ NEVER ❌ NO Return None on error
extend_bootinfo ❌ NEVER ❌ NO Try/except + fallback
override class ✅ YES ❌ NO super() + re-raise
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-hooks
-
Hooks syntax
-
erpnext-impl-hooks
-
Implementation workflows
-
erpnext-errors-controllers
-
Controller error handling
-
erpnext-errors-serverscripts
-
Server Script error handling