ERPNext Syntax: Document Controllers
Document Controllers are Python classes that implement the server-side logic of a DocType.
Quick Reference
Controller Basic Structure
import frappe from frappe.model.document import Document
class SalesOrder(Document): def validate(self): """Main validation - runs on every save.""" if not self.items: frappe.throw(_("Items are required")) self.total = sum(item.amount for item in self.items)
def on_update(self):
"""After save - changes to self are NOT saved."""
self.update_linked_docs()
Location and Naming
DocType Class File
Sales Order SalesOrder
selling/doctype/sales_order/sales_order.py
Custom Doc CustomDoc
module/doctype/custom_doc/custom_doc.py
Rule: DocType name → PascalCase (remove spaces) → snake_case filename
Most Used Hooks
Hook When Typical Use
validate
Before every save Validation, calculations
on_update
After every save Notifications, linked docs
after_insert
After new doc Creation-only actions
on_submit
After submit Ledger entries, stock
on_cancel
After cancel Reverse ledger entries
on_trash
Before delete Cleanup related data
autoname
On naming Custom document name
Complete list and execution order: See lifecycle-methods.md
Hook Selection Decision Tree
What do you want to do? │ ├─► Validate or calculate fields? │ └─► validate │ ├─► Action after save (emails, linked docs)? │ └─► on_update │ ├─► Only for NEW docs? │ └─► after_insert │ ├─► On SUBMIT? │ ├─► Check beforehand? → before_submit │ └─► Action afterwards? → on_submit │ ├─► On CANCEL? │ ├─► Check beforehand? → before_cancel │ └─► Cleanup? → on_cancel │ ├─► Custom document name? │ └─► autoname │ └─► Cleanup before delete? └─► on_trash
Critical Rules
- Changes after on_update are NOT saved
❌ WRONG - change is lost
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")
- No commits in controllers
❌ WRONG - Frappe handles commits
def on_update(self): frappe.db.commit() # DON'T DO THIS
✅ CORRECT - no commit needed
def on_update(self): self.update_related() # Frappe commits automatically
- Always call super() when overriding
❌ WRONG - parent logic is skipped
def validate(self): self.custom_check()
✅ CORRECT - parent logic is preserved
def validate(self): super().validate() self.custom_check()
- Use flags for recursion prevention
def on_update(self): if self.flags.get('from_linked_doc'): return
linked = frappe.get_doc("Linked Doc", self.linked_doc)
linked.flags.from_linked_doc = True
linked.save()
Document Naming (autoname)
Available Naming Options
Option Example Result Version
field:fieldname
field:customer_name
ABC Company
All
naming_series:
naming_series:
SO-2024-00001
All
format:PREFIX-{##}
format:INV-{YYYY}-{####}
INV-2024-0001
All
hash
hash
a1b2c3d4e5
All
Prompt
Prompt
User enters name All
UUID
UUID
01948d5f-...
v16+
Custom method Controller autoname() Any pattern All
UUID Naming (v16+)
New in v16: UUID-based naming for globally unique identifiers.
{ "doctype": "DocType", "autoname": "UUID" }
Benefits:
-
Globally unique across systems
-
Better data integrity and traceability
-
Reduced database storage
-
Faster bulk record creation
-
Link fields store UUID in native format
Implementation:
Frappe automatically generates UUID7
In naming.py:
if meta.autoname == "UUID": doc.name = str(uuid_utils.uuid7())
Validation:
UUID names are validated on import
from uuid import UUID try: UUID(doc.name) except ValueError: frappe.throw(_("Invalid UUID: {}").format(doc.name))
Custom autoname Method
from frappe.model.naming import getseries
class Project(Document): def autoname(self): # Custom naming based on customer prefix = f"P-{self.customer}-" self.name = getseries(prefix, 3) # Result: P-ACME-001, P-ACME-002, etc.
Format Patterns
Pattern Description Example
{#}
Counter 1, 2, 3
{##}
Zero-padded counter 01, 02, 03
{####}
4-digit counter 0001, 0002
{YYYY}
Full year 2024
{YY}
2-digit year 24
{MM}
Month 01-12
{DD}
Day 01-31
{fieldname}
Field value (value)
Controller Override
Via hooks.py (override_doctype_class)
hooks.py
override_doctype_class = { "Sales Order": "custom_app.overrides.CustomSalesOrder" }
custom_app/overrides.py
from erpnext.selling.doctype.sales_order.sales_order import SalesOrder
class CustomSalesOrder(SalesOrder): def validate(self): super().validate() self.custom_validation()
Via doc_events (hooks.py)
hooks.py
doc_events = { "Sales Order": { "validate": "custom_app.events.validate_sales_order", "on_submit": "custom_app.events.on_submit_sales_order" } }
custom_app/events.py
def validate_sales_order(doc, method): if doc.total > 100000: doc.requires_approval = 1
Choice: override_doctype_class for full control, doc_events for individual hooks.
Submittable Documents
Documents with is_submittable = 1 have a docstatus lifecycle:
docstatus Status Editable Can go to
0 Draft ✅ Yes 1 (Submit)
1 Submitted ❌ No 2 (Cancel)
2 Cancelled ❌ No
class StockEntry(Document): def on_submit(self): """After submit - create stock ledger entries.""" self.update_stock_ledger()
def on_cancel(self):
"""After cancel - reverse the entries."""
self.reverse_stock_ledger()
Virtual DocTypes
For external data sources (no database table):
class ExternalCustomer(Document): @staticmethod def get_list(args): return external_api.get_customers(args.get("filters"))
@staticmethod
def get_count(args):
return external_api.count_customers(args.get("filters"))
@staticmethod
def get_stats(args):
return {}
Inheritance Patterns
Standard Controller
from frappe.model.document import Document
class MyDocType(Document): pass
Tree DocType
from frappe.utils.nestedset import NestedSet
class Department(NestedSet): pass
Extend Existing Controller
from erpnext.selling.doctype.sales_order.sales_order import SalesOrder
class CustomSalesOrder(SalesOrder): def validate(self): super().validate() self.custom_validation()
Type Annotations (v15+)
class Person(Document): if TYPE_CHECKING: from frappe.types import DF first_name: DF.Data last_name: DF.Data birth_date: DF.Date
Enable in hooks.py :
export_python_type_annotations = True
Reference Files
File Contents
lifecycle-methods.md All hooks, execution order, examples
methods.md All doc.* methods with signatures
flags.md Flags system documentation
examples.md Complete working controller examples
anti-patterns.md Common mistakes and corrections
Version Differences
Feature v14 v15 v16
Type annotations ❌ ✅ Auto-generated ✅
before_discard hook ❌ ✅ ✅
on_discard hook ❌ ✅ ✅
flags.notify_update
❌ ✅ ✅
UUID autoname ❌ ❌ ✅
UUID in Link fields (native) ❌ ❌ ✅
v16-Specific Notes
UUID Naming:
-
Set autoname = "UUID" in DocType definition
-
Uses uuid7() for time-ordered UUIDs
-
Link fields store UUIDs in native format (not text)
-
Improves performance for bulk operations
Choosing UUID vs Traditional Naming:
When to use UUID: ├── Cross-system data synchronization ├── Bulk record creation ├── Global uniqueness required └── No human-readable name needed
When to use traditional naming: ├── User-facing document references (SO-00001) ├── Sequential numbering required ├── Auditing requires readable names └── Integration with legacy systems
Anti-Patterns
❌ Direct field change after on_update
def on_update(self): self.status = "Done" # Will be lost!
❌ frappe.db.commit() in controller
def validate(self): frappe.db.commit() # Breaks transaction!
❌ Forgetting to call super()
def validate(self): self.my_check() # Parent validate is skipped
→ See anti-patterns.md for complete list.
Related Skills
-
erpnext-syntax-serverscripts – Server Scripts (sandbox alternative)
-
erpnext-syntax-hooks – hooks.py configuration
-
erpnext-impl-controllers – Implementation workflows