- ERPNext Controllers - Implementation
- This skill helps you determine HOW to implement server-side DocType logic. For exact syntax, see
- erpnext-syntax-controllers
- .
- Version
-
- v14/v15/v16 compatible
- Main Decision: Controller vs Server Script?
- ┌───────────────────────────────────────────────────────────────────┐
- │ WHAT DO YOU NEED? │
- ├───────────────────────────────────────────────────────────────────┤
- │ │
- │ ► Import external libraries (requests, pandas, numpy) │
- │ └── Controller ✓ │
- │ │
- │ ► Complex multi-document transactions with rollback │
- │ └── Controller ✓ │
- │ │
- │ ► Full Python power (try/except, classes, generators) │
- │ └── Controller ✓ │
- │ │
- │ ► Extend/override standard ERPNext DocType │
- │ └── Controller (override_doctype_class in hooks.py) │
- │ │
- │ ► Quick validation without custom app │
- │ └── Server Script │
- │ │
- │ ► Simple auto-fill or calculation │
- │ └── Server Script │
- │ │
- └───────────────────────────────────────────────────────────────────┘
- Rule
- Controllers for custom apps with full Python power. Server Scripts for quick no-code solutions.
Decision Tree: Which Hook?
WHAT DO YOU WANT TO DO?
│
├─► Validate data or calculate fields before save?
│ └─► validate
│ NOTE: Changes to self ARE saved
│
├─► Action AFTER save (emails, linked docs, logs)?
│ └─► on_update
│ ⚠️ Changes to self are NOT saved! Use db_set instead
│
├─► Only for NEW documents?
│ └─► after_insert
│
├─► Only for SUBMIT (docstatus 0→1)?
│ ├─► Check before submit? → before_submit
│ └─► Action after submit? → on_submit
│
├─► Only for CANCEL (docstatus 1→2)?
│ ├─► Prevent cancel? → before_cancel
│ └─► Cleanup after cancel? → on_cancel
│
├─► Before DELETE?
│ └─► on_trash
│
├─► Custom document naming?
│ └─► autoname
│
└─► Detect any change (including db_set)?
└─► on_change
→ See
references/decision-tree.md
for complete decision tree with all hooks.
CRITICAL: Changes After on_update
┌─────────────────────────────────────────────────────────────────────┐
│ ⚠️ CHANGES TO self AFTER on_update ARE NOT SAVED │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ❌ WRONG - This does NOTHING: │
│ def on_update(self): │
│ self.status = "Completed" # NOT SAVED! │
│ │
│ ✅ CORRECT - Use db_set: │
│ def on_update(self): │
│ frappe.db.set_value(self.doctype, self.name, │
│ "status", "Completed") │
│ │
└─────────────────────────────────────────────────────────────────────┘
Hook Comparison: validate vs on_update
Aspect
validate
on_update
When
Before DB write
After DB write
Changes to self
✅ Saved
❌ NOT saved
Can throw error
✅ Aborts save
⚠️ Already saved
Use for
Validation, calculations
Notifications, linked docs
get_doc_before_save()
✅ Available
✅ Available
Common Implementation Patterns
Pattern 1: Validation with Error
def
validate
(
self
)
:
if
not
self
.
items
:
frappe
.
throw
(
_
(
"At least one item is required"
)
)
if
self
.
from_date
self . to_date : frappe . throw ( _ ( "From Date cannot be after To Date" ) ) Pattern 2: Auto-Calculate Fields def validate ( self ) : self . total = sum ( item . amount for item in self . items ) self . tax_amount = self . total * 0.1 self . grand_total = self . total + self . tax_amount Pattern 3: Detect Field Changes def validate ( self ) : old_doc = self . get_doc_before_save ( ) if old_doc and old_doc . status != self . status : self . flags . status_changed = True def on_update ( self ) : if self . flags . get ( 'status_changed' ) : self . notify_status_change ( ) Pattern 4: Post-Save Actions def on_update ( self ) :
Update linked document
if self . linked_doc : frappe . db . set_value ( "Other DocType" , self . linked_doc , "status" , "Updated" )
Send notification (never fails the save)
try : self . send_notification ( ) except Exception : frappe . log_error ( "Notification failed" ) Pattern 5: Custom Naming from frappe . model . naming import getseries def autoname ( self ) :
Format: CUST-ABC-001
prefix
f"CUST- { self . customer [ : 3 ] . upper ( ) } -" self . name = getseries ( prefix , 3 ) → See references/workflows.md for more implementation patterns. Submittable Documents Workflow DRAFT (docstatus=0) │ ├── save() → validate → on_update │ └── submit() │ ├── validate ├── before_submit ← Last chance to abort ├── [DB: docstatus=1] ├── on_update └── on_submit ← Post-submit actions SUBMITTED (docstatus=1) │ └── cancel() │ ├── before_cancel ← Last chance to abort ├── [DB: docstatus=2] ├── on_cancel ← Reverse actions └── [check_no_back_links] Submittable Implementation def before_submit ( self ) :
Validation that only applies on submit
if self . total
50000 and not self . manager_approval : frappe . throw ( _ ( "Manager approval required for orders over 50,000" ) ) def on_submit ( self ) :
Actions after submit
self . update_stock_ledger ( ) self . make_gl_entries ( ) def before_cancel ( self ) :
Prevent cancel if linked docs exist
if self . has_linked_invoices ( ) : frappe . throw ( _ ( "Cannot cancel - linked invoices exist" ) ) def on_cancel ( self ) :
Reverse submitted actions
self . reverse_stock_ledger ( ) self . reverse_gl_entries ( ) Controller Override (hooks.py) Method 1: Full Override
hooks.py
override_doctype_class
{ "Sales Invoice" : "myapp.overrides.CustomSalesInvoice" }
myapp/overrides.py
from erpnext . accounts . doctype . sales_invoice . sales_invoice import SalesInvoice class CustomSalesInvoice ( SalesInvoice ) : def validate ( self ) : super ( ) . validate ( )
ALWAYS call parent
self . custom_validation ( ) Method 2: Add Event Handler (Safer)
hooks.py
doc_events
{ "Sales Invoice" : { "validate" : "myapp.events.validate_sales_invoice" , } }
myapp/events.py
def validate_sales_invoice ( doc , method = None ) : if doc . grand_total < 0 : frappe . throw ( _ ( "Invalid total" ) ) V16: extend_doctype_class (New)
hooks.py (v16+)
extend_doctype_class
{ "Sales Invoice" : "myapp.extends.SalesInvoiceExtend" }
myapp/extends.py - Only methods to add/override
class SalesInvoiceExtend : def custom_method ( self ) : pass Flags System
Document-level flags
doc . flags . ignore_permissions = True
Bypass permissions
doc . flags . ignore_validate = True
Skip validate()
doc . flags . ignore_mandatory = True
Skip required fields
Custom flags for inter-hook communication
def validate ( self ) : if self . is_urgent : self . flags . needs_notification = True def on_update ( self ) : if self . flags . get ( 'needs_notification' ) : self . notify_team ( )
Insert/save with flags
doc . insert ( ignore_permissions = True , ignore_mandatory = True ) doc . save ( ignore_permissions = True ) Execution Order Reference INSERT (New Document) before_insert → before_naming → autoname → before_validate → validate → before_save → [DB INSERT] → after_insert → on_update → on_change SAVE (Existing Document) before_validate → validate → before_save → [DB UPDATE] → on_update → on_change SUBMIT validate → before_submit → [DB: docstatus=1] → on_update → on_submit → on_change → See references/decision-tree.md for all execution orders. Quick Anti-Pattern Check ❌ Don't ✅ Do Instead self.x = y in on_update frappe.db.set_value(...) frappe.db.commit() in hooks Let framework handle commits Heavy operations in validate Use frappe.enqueue() in on_update self.save() in on_update Causes infinite loop! Assume hook order across docs Each doc has its own cycle → See references/anti-patterns.md for complete list. References decision-tree.md - Complete hook selection with all execution orders workflows.md - Extended implementation patterns examples.md - Complete working examples anti-patterns.md - Common mistakes to avoid