
Frappe Syntax Controllers
- 24 installs
- 159 repo stars
- Updated July 8, 2026
- openaec-foundation/frappe_claude_skill_package
Helps with ai & agent building tasks.
About
frappe-syntax-controllers is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- frappe-syntax-controllers
- AI & Agent Building
- AI-coding skill
Frappe Syntax Controllers by the numbers
- 24 all-time installs (skills.sh)
- Ranked #9,910 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/openaec-foundation/frappe_claude_skill_package --skill frappe-syntax-controllersAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 24 |
|---|---|
| repo stars | ★ 159 |
| Last updated | July 8, 2026 |
| Repository | openaec-foundation/frappe_claude_skill_package ↗ |
What it does
Helps with ai & agent building tasks.
Files
Frappe Syntax: Document Controllers
Document Controllers are Python classes that define all server-side logic for a DocType. EVERY DocType has exactly one controller file. The controller class extends frappe.model.document.Document.
Quick Reference
import frappe
from frappe import _
from frappe.model.document import Document
class SalesOrder(Document):
def autoname(self):
"""Custom naming logic. Sets self.name."""
self.name = f"SO-{self.customer_code}-{frappe.utils.now_datetime().year}"
def validate(self):
"""MAIN validation — runs on EVERY save (insert and update).
Changes to self ARE saved to database."""
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.
Use frappe.db.set_value() for post-save field changes."""
self.notify_linked_docs()
def on_submit(self):
"""After submit (docstatus 0 -> 1). Create ledger entries here."""
self.create_gl_entries()
def on_cancel(self):
"""After cancel (docstatus 1 -> 2). Reverse ledger entries here."""
self.reverse_gl_entries()
@frappe.whitelist()
def recalculate(self):
"""Exposed to client JS via frm.call('recalculate')."""
self.total = sum(item.amount for item in self.items)
return {"total": self.total}File Location and Naming
| DocType Name | Class Name | File Path |
|---|---|---|
| Sales Order | SalesOrder | selling/doctype/sales_order/sales_order.py |
| My Custom Doc | MyCustomDoc | module/doctype/my_custom_doc/my_custom_doc.py |
Rule: DocType name -> PascalCase class -> snake_case filename. ALWAYS match exactly.
---
Lifecycle Hook Execution Order
INSERT (new document)
before_insert -> before_naming -> autoname -> before_validate -> validate
-> before_save -> [db_insert] -> after_insert -> on_update -> on_changeSAVE (existing document)
before_validate -> validate -> before_save -> [db_update]
-> on_update -> on_changeSUBMIT (docstatus 0 -> 1)
before_validate -> validate -> before_submit -> [db_update]
-> on_submit -> on_update -> on_changeCANCEL (docstatus 1 -> 2)
before_cancel -> [db_update] -> on_cancel -> on_changeUPDATE AFTER SUBMIT
before_update_after_submit -> [db_update]
-> on_update_after_submit -> on_changeDELETE
on_trash -> [db_delete] -> after_deleteDISCARD [v15+]
before_discard -> [db_set docstatus=2] -> on_discardComplete hook reference with parameters: See lifecycle-methods.md
---
Hook Selection Decision Tree
What do you need to do?
|
+-- Validate data or calculate fields?
| +-- validate (changes to self ARE saved)
|
+-- Action AFTER save (emails, sync, linked docs)?
| +-- on_update (changes to self are NOT saved)
|
+-- Only for NEW documents?
| +-- after_insert (runs once on first save only)
|
+-- Custom document name?
| +-- autoname (set self.name)
|
+-- Before/after SUBMIT?
| +-- Validate before submit? -> before_submit
| +-- Create entries after submit? -> on_submit
|
+-- Before/after CANCEL?
| +-- Check linked docs? -> before_cancel
| +-- Reverse entries? -> on_cancel
|
+-- Cleanup before delete?
| +-- on_trash
|
+-- React to ANY value change (including db_set)?
| +-- on_change (MUST be idempotent)---
Critical Rules
1. Changes after on_update are NOT saved
# WRONG - change is lost after on_update
def on_update(self):
self.status = "Completed" # NOT saved to database
# CORRECT - use db_set or frappe.db.set_value
def on_update(self):
self.db_set("status", "Completed")2. NEVER call frappe.db.commit() in controllers
# WRONG - breaks Frappe transaction management
def validate(self):
frappe.db.commit() # Can cause partial updates on error
# CORRECT - Frappe commits automatically at end of request
def validate(self):
self.update_related() # No commit needed3. ALWAYS call super() when overriding
# WRONG - parent validation is skipped entirely
def validate(self):
self.custom_check()
# CORRECT - parent logic preserved
def validate(self):
super().validate()
self.custom_check()4. 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()5. NEVER put validation logic in on_update
# WRONG - document is already saved when this throws
def on_update(self):
if self.total < 0:
frappe.throw("Invalid total") # Too late!
# CORRECT - validate BEFORE save
def validate(self):
if self.total < 0:
frappe.throw("Invalid total") # Blocks save---
Document Naming (autoname)
| Method | Example | Result | Version |
|---|---|---|---|
field:fieldname | field:customer_name | ABC Company | All |
naming_series: | naming_series: | SO-2024-00001 | All |
| Expression | PRE-.##### | PRE-00001 | All |
| Old-style format | INV-{YYYY}-{####} | INV-2024-0001 | Deprecated v16 |
hash / random | hash | a1b2c3d4e5 | All |
Prompt | Prompt | User enters name | All |
autoincrement | autoincrement | 1, 2, 3 | All |
| `UUID` | UUID | 550e8400-e29b-... | v16+ |
| Custom method | autoname() in controller | Any pattern | All |
Custom autoname Method
from frappe.model.naming import getseries
class Project(Document):
def autoname(self):
prefix = f"P-{self.customer[:3].upper()}-"
self.name = getseries(prefix, 3)
# Result: P-ACM-001, P-ACM-002, etc.UUID Naming [v16+]
Set autoname = "UUID" in DocType definition. Frappe generates UUID v4.
When to use UUID: When to use traditional naming:
- Cross-system sync - User-facing references (SO-00001)
- Bulk record creation - Sequential numbering required
- Global uniqueness needed - Auditing requires readable names---
Controller Extension Mechanisms
1. override_doctype_class (full replacement) [All versions]
# 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() # ALWAYS call super()
self.custom_validation()WARNING: Only ONE app can override a DocType class. Multiple overrides conflict.
2. extend_doctype_class (mixin, non-destructive) [v16+]
# hooks.py
extend_doctype_class = {
"Address": ["custom_app.extensions.address.GeocodingMixin"],
"Contact": [
"custom_app.extensions.common.ValidationMixin",
"custom_app.extensions.contact.PhoneMixin"
]
}
# custom_app/extensions/address.py
from frappe.model.document import Document
class GeocodingMixin(Document):
@property
def full_address(self):
return f"{self.address_line1}, {self.city}, {self.country}"
def validate(self):
super().validate()
self.geocode_address()ALWAYS prefer `extend_doctype_class` over `override_doctype_class` in v16+. Multiple apps can safely extend the same DocType.
3. doc_events (hook individual events) [All versions]
# hooks.py
doc_events = {
"Sales Order": {
"validate": "custom_app.events.validate_sales_order",
"on_submit": "custom_app.events.on_submit_sales_order"
},
"*": { # ALL DocTypes
"after_insert": "custom_app.events.log_creation"
}
}
# custom_app/events.py
def validate_sales_order(doc, method=None):
if doc.total > 100000:
doc.requires_approval = 1When to Use Which
Need full class replacement? -> override_doctype_class [all versions]
Need to add methods/properties? -> extend_doctype_class [v16+]
Need to hook one or two events? -> doc_events [all versions]
Need to extend in v14/v15? -> override_doctype_class or doc_events---
Whitelisted Methods
Expose controller methods to client-side JavaScript with @frappe.whitelist():
class SalesOrder(Document):
@frappe.whitelist()
def send_email(self, recipient):
"""Callable from JS: frm.call('send_email', {recipient: '...'})"""
frappe.sendmail(recipients=[recipient], message="Order confirmed")
return {"status": "sent"}// Client-side call
frm.call('send_email', { recipient: 'customer@example.com' })
.then(r => frappe.msgprint(r.message.status));Rules:
- ALWAYS add
@frappe.whitelist()decorator — without it, the method is NOT callable from client - The method MUST be defined on the controller class (not standalone)
- Permission checks happen automatically (user must have read access to the document)
---
Submittable Documents
Documents with is_submittable = 1 follow the docstatus lifecycle:
| docstatus | State | Editable | Transitions |
|---|---|---|---|
| 0 | Draft | Yes | -> 1 (Submit) |
| 1 | Submitted | Only "Allow on Submit" fields | -> 2 (Cancel) |
| 2 | Cancelled | No | None (amend creates new Draft) |
ALWAYS implement both on_submit and on_cancel as a pair. ALWAYS reverse in on_cancel what on_submit created.
---
Inheritance Patterns
# Standard controller
from frappe.model.document import Document
class MyDoc(Document): pass
# Tree DocType (hierarchical)
from frappe.utils.nestedset import NestedSet
class Department(NestedSet):
nsm_parent_field = "parent_department"
# Virtual DocType (no database table)
class ExternalData(Document):
def load_from_db(self): ...
def db_insert(self, *args, **kwargs): ...
def db_update(self, *args, **kwargs): ...
@staticmethod
def get_list(args): ...
@staticmethod
def get_count(args): ...---
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
company: DF.LinkEnable auto-generation in hooks.py: export_python_type_annotations = True
---
Version Differences
| Feature | v14 | v15 | v16 |
|---|---|---|---|
| Type annotations | No | Auto-generated | Yes |
before_discard / on_discard | No | Yes | Yes |
flags.notify_update | No | Yes | Yes |
extend_doctype_class | No | No | Yes |
| UUID autoname | No | No | Yes |
| Old-style format naming | Yes | Yes | Deprecated |
---
Reference Files
| File | Contents |
|---|---|
| lifecycle-methods.md | All hooks with execution order diagrams |
| document-api-complete.md | Complete Document API: all methods by category (CRUD, fields, DB, permissions, flags, child tables, naming) |
| methods.md | Document class method signatures |
| events.md | All document events in order |
| examples.md | Complete working controller examples |
| anti-patterns.md | Common mistakes and corrections |
| flags.md | Flags system (doc.flags, frappe.flags) |
| hooks.md | Controller interaction with hooks.py |
| patterns.md | Common controller patterns |
| syntax.md | Controller class syntax reference |
Related Skills
frappe-syntax-serverscripts-- Server Scripts (sandbox alternative)frappe-syntax-hooks-- hooks.py configurationfrappe-impl-controllers-- Implementation workflowsfrappe-core-permissions-- Permission system
Anti-Patterns Reference
Common controller mistakes and their correct alternatives.
---
Lifecycle Hook Mistakes
WRONG: Modifying self in on_update
Changes to self after on_update are NOT saved to database.
# WRONG - change is lost
def on_update(self):
self.status = "Completed" # NOT saved
# CORRECT - use db_set for post-save changes
def on_update(self):
self.db_set("status", "Completed")
# Or for multiple fields:
self.db_set({"status": "Completed", "completed_at": frappe.utils.now()})
# CORRECT - move calculation to validate (changes ARE saved)
def validate(self):
if self.all_items_delivered():
self.status = "Completed"WRONG: Calling save() in on_update (infinite loop)
# WRONG - infinite recursion: on_update -> save -> on_update -> ...
def on_update(self):
self.counter = (self.counter or 0) + 1
self.save()
# CORRECT - use db_set (no hooks triggered)
def on_update(self):
new_count = (self.counter or 0) + 1
self.db_set("counter", new_count, update_modified=False)
# CORRECT - use flag to prevent recursion
def on_update(self):
if self.flags.get("in_recursive_update"):
return
self.flags.in_recursive_update = True
# ... operations ...WRONG: Validation in on_update
# WRONG - document already saved when this throws
def on_update(self):
if self.grand_total < 0:
frappe.throw("Invalid total") # Data already in DB!
# CORRECT - validate BEFORE save
def validate(self):
if self.grand_total < 0:
frappe.throw("Invalid total") # Blocks save entirelyWRONG: Using after_insert for all-save logic
# WRONG - only runs on first save, never on updates
def after_insert(self):
self.send_notification() # Never triggers on update!
# CORRECT - use on_update for all saves, check if needed
def on_update(self):
if self.is_new():
self.send_welcome_notification()
else:
self.send_update_notification()---
Database Mistakes
WRONG: Calling frappe.db.commit() in controllers
# WRONG - breaks transaction management
def on_update(self):
frappe.db.sql("UPDATE tabItem SET ...")
frappe.db.commit() # Can cause partial updates on error
# CORRECT - Frappe commits automatically at end of request
def on_update(self):
frappe.db.sql("UPDATE tabItem SET ...")
# No commit neededWRONG: Using db_insert/db_update for normal operations
# WRONG - bypasses ALL hooks and validation
def create_related(self):
doc = frappe.get_doc({"doctype": "Task", "title": "New"})
doc.db_insert() # No validate, no permissions
# CORRECT - use insert() for normal operations
def create_related(self):
doc = frappe.get_doc({"doctype": "Task", "title": "New"})
doc.insert() # All hooks and validation runWRONG: SQL injection via string formatting
# WRONG - SQL injection vulnerability
def get_items(self):
return frappe.db.sql(f"SELECT * FROM tabItem WHERE name = '{self.item_code}'")
# CORRECT - parameterized query
def get_items(self):
return frappe.db.sql("SELECT * FROM tabItem WHERE name = %s", [self.item_code])
# CORRECT - use ORM
def get_items(self):
return frappe.get_all("Item", filters={"name": self.item_code})---
Permission Mistakes
WRONG: No permission check on whitelisted methods
# WRONG - anyone can update salary
@frappe.whitelist()
def update_salary(employee, new_salary):
frappe.db.set_value("Employee", employee, "salary", new_salary)
# CORRECT - check permissions
@frappe.whitelist()
def update_salary(employee, new_salary):
if not frappe.has_permission("Employee", "write"):
frappe.throw(_("Not permitted"))
if "HR Manager" not in frappe.get_roles():
frappe.throw(_("Only HR Manager can update salary"))
frappe.db.set_value("Employee", employee, "salary", new_salary)WRONG: ignore_permissions everywhere
# WRONG - security bypass without justification
def on_update(self):
doc = frappe.get_doc("Sales Invoice", self.invoice)
doc.flags.ignore_permissions = True
doc.submit()
# CORRECT - only where justified, with documentation
def on_update(self):
# System operation: auto-submit invoice from approved order
# Permission bypass justified: order approval already verified
doc = frappe.get_doc("Sales Invoice", self.invoice)
doc.flags.ignore_permissions = True
doc.submit()---
Performance Mistakes
WRONG: N+1 query problem
# WRONG - N database queries in loop
def validate(self):
for item in self.items:
stock = frappe.db.get_value("Bin",
{"item_code": item.item_code, "warehouse": item.warehouse},
"actual_qty"
) # 1 query per item!
# CORRECT - batch query
def validate(self):
item_codes = [item.item_code for item in self.items]
stock_data = frappe.get_all("Bin",
filters={"item_code": ["in", item_codes]},
fields=["item_code", "warehouse", "actual_qty"]
)
stock_map = {(d.item_code, d.warehouse): d.actual_qty for d in stock_data}
for item in self.items:
stock = stock_map.get((item.item_code, item.warehouse), 0)
if item.qty > stock:
frappe.throw(f"Insufficient stock for {item.item_code}")WRONG: Heavy operations in validate
# WRONG - blocks UI for 30+ seconds
def validate(self):
self.generate_100_page_pdf()
self.send_emails_to_all_customers()
# CORRECT - enqueue heavy tasks
def on_update(self):
frappe.enqueue(
"myapp.tasks.generate_pdf",
queue="long",
doc_name=self.name,
enqueue_after_commit=True
)WRONG: Not using cache for repeated lookups
# WRONG - multiple DB calls for same record
def validate(self):
customer = frappe.get_doc("Customer", self.customer)
customer_email = frappe.get_value("Customer", self.customer, "email")
# CORRECT - use cached doc
def validate(self):
customer = frappe.get_cached_doc("Customer", self.customer)
email = customer.email---
Override Mistakes
WRONG: Missing super() call
# WRONG - all parent validation skipped
class CustomSalesInvoice(SalesInvoice):
def validate(self):
self.my_check() # Parent validate never runs!
# CORRECT - ALWAYS call super()
class CustomSalesInvoice(SalesInvoice):
def validate(self):
super().validate()
self.my_check()WRONG: doc_events with wrong signature
# WRONG - incorrect parameter names
def on_validate(document):
pass
# CORRECT - use (doc, method=None) signature
def on_validate(doc, method=None):
passWRONG: Full override for minor changes
# WRONG - override entire class for one check
override_doctype_class = {"Sales Invoice": "myapp.override.CustomSI"}
class CustomSI(SalesInvoice):
def validate(self):
super().validate()
if self.total < 100:
frappe.msgprint("Small order")
# CORRECT - use doc_events for simple additions
doc_events = {
"Sales Invoice": {
"validate": "myapp.events.si_validate"
}
}
def si_validate(doc, method=None):
if doc.total < 100:
frappe.msgprint("Small order")---
Async Mistakes
WRONG: Enqueue without error handling
# WRONG - failures are silently lost
def on_submit(self):
frappe.enqueue("myapp.tasks.process", doc_name=self.name)
# CORRECT - with error handling and retry
def on_submit(self):
frappe.enqueue(
"myapp.tasks.process",
doc_name=self.name,
queue="short",
timeout=300,
retry=3,
enqueue_after_commit=True
)
# myapp/tasks.py
def process(doc_name):
try:
doc = frappe.get_doc("MyDocType", doc_name)
doc.do_processing()
except Exception:
frappe.log_error(title=f"Process failed: {doc_name}")
raise # Re-raise for retry mechanismWRONG: Synchronous external API in validate
# WRONG - user waits for external API (could be 30+ seconds)
def validate(self):
response = requests.get("https://api.external.com/validate", timeout=30)
# CORRECT - async external call
def on_update(self):
frappe.enqueue(
"myapp.integrations.validate_external",
doc_name=self.name,
queue="short",
enqueue_after_commit=True
)---
Summary
| Anti-Pattern | Correct Approach |
|---|---|
self.x = ... in on_update | self.db_set("x", ...) |
self.save() in on_update | self.db_set() or flags |
| Validation in on_update | Move to validate |
frappe.db.commit() | Remove -- Frappe handles it |
| Query in loop | Batch query with get_all |
| Heavy ops in validate | frappe.enqueue() |
| Override without super() | ALWAYS super().method() |
ignore_permissions everywhere | Only where justified |
| Sync external API in validate | Async with enqueue |
| f-string in SQL | Parameterized %s queries |
Document API Complete Reference
Comprehensive reference for ALL frappe.model.document.Document class methods. This consolidates the full Document API in one place for quick lookup.
Source: Frappe Document API
---
1. CRUD Methods
| Method | Signature | Returns | Description |
|---|---|---|---|
insert | insert(ignore_permissions=False, ignore_links=False, ignore_if_duplicate=False, ignore_mandatory=False, set_name=None, set_child_names=True) | self | Insert new document. Runs full lifecycle: before_insert → validate → db_insert → after_insert → on_update. |
save | save(ignore_permissions=False, ignore_version=False) | self | Save existing document. Runs: before_validate → validate → before_save → db_update → on_update. |
submit | submit() | self | Submit document (docstatus 0→1). ONLY for submittable DocTypes. Runs validate then on_submit. |
cancel | cancel() | self | Cancel submitted document (docstatus 1→2). Runs before_cancel → on_cancel. |
amend_doc | amend_doc() | Document | Create amended copy from cancelled document. Sets amended_from on the new doc. |
delete | delete(ignore_permissions=False, force=False, ignore_doctypes=None, for_reload=False) | None | Delete document and linked records. Runs on_trash → after_delete. |
CRUD via frappe module (not doc methods)
| Function | Signature | Returns | Description |
|---|---|---|---|
frappe.get_doc | get_doc(doctype, name=None, **kwargs) | Document | Retrieve existing doc or create new in memory from dict. |
frappe.new_doc | new_doc(doctype, **kwargs) | Document | Create a new document in memory with defaults applied. |
frappe.get_last_doc | get_last_doc(doctype, filters=None, order_by="creation desc") | Document | Return most recently created document matching filters. |
frappe.get_cached_doc | get_cached_doc(doctype, name) | Document | Retrieve from cache first, database second. Read-only — NEVER modify and save. |
frappe.delete_doc | delete_doc(doctype, name, force=0, ignore_doctypes=None, for_reload=False, ignore_permissions=False, flags=None) | None | Delete document by doctype and name. |
frappe.rename_doc | rename_doc(doctype, old_name, new_name, force=False, merge=False, ignore_permissions=False, ignore_if_exists=False) | str | Rename document (changes primary key). Returns new name. |
frappe.copy_doc | copy_doc(doc, ignore_no_copy=True) | Document | Deep copy document. Clears name, amended_from, and "No Copy" fields. |
---
2. Field Access Methods
| Method | Signature | Returns | Description |
|---|---|---|---|
get | get(fieldname, default=None) | any | Safely retrieve field value. Returns child table as list. |
set | set(fieldname, value) | None | Set field value. For child tables, pass list of dicts to replace all rows. |
as_dict | as_dict(no_nulls=False, no_default_fields=False) | dict | Serialize document to dictionary. |
update | update(d) | None | Bulk-update fields from dictionary d. |
get_valid_dict | get_valid_dict(sanitize=True, convert_dates_to_str=False) | dict | Return dict with only fields defined in DocType meta. |
is_new | is_new() | bool | True if document has not yet been saved to database. |
has_value_changed | has_value_changed(fieldname) | bool | True if field value differs from saved version. Available from validate onwards. |
get_doc_before_save | get_doc_before_save() | `Document \ | None` |
Direct attribute access
# These are equivalent:
value = doc.get("customer")
value = doc.customer
# Setting:
doc.set("status", "Completed")
doc.status = "Completed"---
3. Database Shortcut Methods
| Method | Signature | Returns | Description |
|---|---|---|---|
db_set | db_set(fieldname, value=None, notify=True, commit=False, update_modified=True) | None | Direct DB field update. Use in post-save hooks (on_update, on_submit). Also accepts a dict: db_set({"field1": val1, "field2": val2}). |
reload | reload() | None | Refresh all fields from database. Discards in-memory changes. |
get_doc_before_save | get_doc_before_save() | `Document \ | None` |
db_insert | db_insert(*args, **kwargs) | None | Low-level insert. Bypasses ALL hooks and validation. Use only for bulk ops or Virtual DocTypes. |
db_update | db_update() | None | Low-level update. Bypasses ALL hooks and validation. Use only for performance-critical bulk ops. |
When to use db_set vs save
Need to update a field AFTER on_update? → db_set()
Need full validation and hooks? → save()
Need to update another document? → frappe.db.set_value()
Bulk update thousands of records? → frappe.db.sql() or db_update()---
4. Permission Methods
| Method | Signature | Returns | Description |
|---|---|---|---|
has_permission | has_permission(permtype="read", user=None) | bool | Check if user has specified permission on this document. |
check_permission | check_permission(permtype="read") | None | Same as has_permission but raises frappe.PermissionError if denied. |
raise_no_permission_to | raise_no_permission_to(perm_type) | None | Raise a formatted permission error for the given permission type. |
add_comment | add_comment(comment_type="Comment", text=None, comment_email=None, comment_by=None) | Comment | Add a comment to the document timeline. |
Comment types
Comment, Edit, Created, Submitted, Cancelled, Info, Label, Shared, Assigned, Attachment, Like
Permission check pattern
def validate(self):
if self.total > 100000:
if not frappe.has_permission("Sales Order", "submit", user=frappe.session.user):
frappe.throw(_("Not authorized for high-value orders"))---
5. Flags
Flags are runtime-only attributes (NOT saved to database) that control behavior during the current request.
doc.flags (per-document)
| Flag | Type | Description |
|---|---|---|
ignore_permissions | bool | Skip all permission checks for this document. |
ignore_validate | bool | Skip the validate controller method. |
ignore_mandatory | bool | Skip mandatory field checks. |
ignore_links | bool | Skip Link field validation. |
in_insert | bool | Set by Frappe during insert. True inside before_insert through after_insert. |
ignore_if_duplicate | bool | Silently skip insert if DuplicateEntryError. |
ignore_update_after_submit | bool | Allow field changes on submitted documents. |
ignore_validate_update_after_submit | bool | Skip update-after-submit field restrictions. |
notify_update | bool | [v15+] Trigger realtime update notification after db_set. |
from_linked_doc | bool | Custom flag — use to prevent recursion between linked docs. |
frappe.flags (global, per-request)
| Flag | Type | Description |
|---|---|---|
frappe.flags.in_import | bool | True during data import. |
frappe.flags.in_install | bool | True during app installation. |
frappe.flags.in_migrate | bool | True during bench migrate. |
frappe.flags.in_test | bool | True during test execution. |
frappe.flags.mute_emails | bool | Suppress all email sending. |
frappe.flags.mute_messages | bool | Suppress all msgprint output. |
Usage pattern
doc.flags.ignore_permissions = True
doc.save()
# Or inline:
doc.insert(ignore_permissions=True)
# Custom flag for recursion prevention:
def on_update(self):
if self.flags.get("skip_cascade"):
return
linked = frappe.get_doc("Other Doc", self.linked_name)
linked.flags.skip_cascade = True
linked.save()---
6. Workflow and Docstatus
doc.docstatus
| Value | State | Description |
|---|---|---|
0 | Draft | Default state. Fully editable. |
1 | Submitted | Locked. Only "Allow on Submit" fields editable. |
2 | Cancelled | Fully locked. Cannot be edited or re-submitted. |
Docstatus transitions
0 (Draft) --submit()--> 1 (Submitted) --cancel()--> 2 (Cancelled)
|
amend_doc()
|
v
0 (New Draft with amended_from set)Workflow state (separate from docstatus)
doc.workflow_state # Current workflow state name (str)
frappe.model.workflow.apply_workflow(doc, action) # Trigger workflow actionNEVER set doc.docstatus directly. ALWAYS use doc.submit() and doc.cancel().
---
7. Child Table Methods
Child table rows are instances of Document with extra fields: parent, parenttype, parentfield, idx.
| Method | Signature | Returns | Description |
|---|---|---|---|
append | append(fieldname, value=None) | Document | Add one row to child table. Returns the new row object. |
extend | extend(fieldname, rows) | None | Add multiple rows from a list of dicts. |
remove | remove(row) | None | Remove a specific row object from its child table. |
get (with filters) | get(fieldname, filters=None, limit=0) | list | Get child rows, optionally filtered by dict. |
set | set(fieldname, value) | None | Replace entire child table with list of dicts. |
Examples
# Append single row
row = self.append("items", {
"item_code": "ITEM-001",
"qty": 10,
"rate": 100.0
})
row.warehouse = "Main - WH" # Can set more fields on returned row
# Extend with multiple rows
self.extend("items", [
{"item_code": "ITEM-001", "qty": 10},
{"item_code": "ITEM-002", "qty": 5},
])
# Get with filters
pending = self.get("items", filters={"status": "Pending"})
# Equivalent list comprehension (more common):
pending = [row for row in self.items if row.status == "Pending"]
# Remove a row
for row in self.items:
if row.qty == 0:
self.remove(row)
# Replace entire child table
self.set("items", [{"item_code": "NEW-001", "qty": 1}])
# Clear child table
self.set("items", [])
# Iterate
for idx, row in enumerate(self.items):
row.idx = idx + 1 # Re-index after removal
# Count
total_qty = sum(row.qty for row in self.items)Child table special fields
| Field | Type | Description |
|---|---|---|
parent | str | Name of the parent document. |
parenttype | str | DocType of the parent document. |
parentfield | str | Fieldname of the table field in parent. |
idx | int | Row index (1-based). Frappe auto-manages this. |
name | str | Unique row identifier (auto-generated hash). |
---
8. Utility Methods
| Method | Signature | Returns | Description |
|---|---|---|---|
run_method | run_method(method, *args, **kwargs) | any | Execute controller method AND trigger associated doc_events and server scripts. |
get_title | get_title() | str | Return document title based on title_field meta configuration. |
get_url | get_url() | str | Return desk URL path (e.g., /app/sales-order/SO-00001). |
notify_update | notify_update() | None | Publish realtime event so open browser forms refresh. |
queue_action | queue_action(action, **kwargs) | None | Execute controller method asynchronously in background worker. |
add_seen | add_seen(user=None) | None | Mark document as seen by user (updates _seen field). |
add_viewed | add_viewed(user=None) | None | Log a view access for the user. |
add_tag | add_tag(tag) | None | Associate a tag with this document. |
get_tags | get_tags() | list[str] | Return all tags associated with this document. |
get_url | get_url() | str | Return URL path for this document in Frappe Desk. |
get_signature | get_signature() | str | Return email signature of document owner. |
get_liked_by | get_liked_by() | list[str] | Return list of users who liked this document. |
run_method vs direct call
# Direct call — runs ONLY the controller method:
doc.validate()
# run_method — runs controller method + doc_events + server scripts:
doc.run_method("validate")ALWAYS use run_method() when you need hooks to fire. Use direct calls only inside the controller itself.
queue_action pattern
class HeavyDoc(Document):
def on_submit(self):
# Run email sending in background worker
self.queue_action("send_notifications", recipients=self.get_recipients())
def send_notifications(self, recipients):
for r in recipients:
frappe.sendmail(recipients=[r], subject="Submitted", message="Done")---
9. Naming
autoname property (set in DocType definition)
| Pattern | Example Config | Example Output | Version |
|---|---|---|---|
field:fieldname | field:customer_name | ABC Company | All |
naming_series: | naming_series: | SO-2024-00001 | All |
| Expression | PRE-.##### | PRE-00001 | All |
hash | hash | a1b2c3d4e5 | All |
Prompt | Prompt | User-entered | All |
autoincrement | autoincrement | 1, 2, 3 | All |
UUID | UUID | 550e8400-e29b-... | v16+ |
| Format string | INV-{YYYY}-{####} | INV-2024-0001 | Deprecated v16 |
| Controller method | autoname() | Any | All |
doc.name
- Set during insert, between
before_namingandautonamehooks. - Immutable after insert (use
frappe.rename_doc()to change). - ALWAYS a string. Acts as primary key in the database.
Custom autoname in controller
from frappe.model.naming import getseries, make_autoname
class Project(Document):
def autoname(self):
# Option 1: getseries (counter-based)
prefix = f"P-{self.customer[:3].upper()}-"
self.name = getseries(prefix, 3) # P-ACM-001
# Option 2: make_autoname (pattern-based)
self.name = make_autoname("PRJ-.YYYY.-.#####") # PRJ-2024-00001naming_series
When autoname = "naming_series:", the DocType gets a naming_series field. Users select from options defined in DocType or Property Setter.
# In DocType JSON or via Property Setter:
# naming_series options: "SO-.YYYY.-\nSO-NEW-.YYYY.-"
# Programmatic naming series override:
doc.naming_series = "CUSTOM-.####"
doc.insert()---
Method Quick-Lookup Table
| Category | Method | Modifies DB | Runs Hooks |
|---|---|---|---|
| CRUD | insert() | Yes | Yes |
save() | Yes | Yes | |
submit() | Yes | Yes | |
cancel() | Yes | Yes | |
delete() | Yes | Yes | |
amend_doc() | No (creates copy) | No | |
| Field | get() | No | No |
set() | No | No | |
as_dict() | No | No | |
update() | No | No | |
is_new() | No | No | |
has_value_changed() | No | No | |
get_doc_before_save() | No | No | |
| DB Shortcut | db_set() | Yes | No (notify optional) |
reload() | No | No | |
db_insert() | Yes | No | |
db_update() | Yes | No | |
| Permission | has_permission() | No | No |
check_permission() | No | No | |
raise_no_permission_to() | No | No | |
| Child Table | append() | No | No |
extend() | No | No | |
remove() | No | No | |
| Utility | run_method() | Depends | Yes |
queue_action() | Depends | Yes (async) | |
notify_update() | No | No | |
add_comment() | Yes | No | |
get_title() | No | No | |
get_url() | No | No | |
add_tag() | Yes | No | |
get_tags() | No | No |
Document Events Reference
All document events in correct execution order, with their trigger context and behavior.
---
Events by Operation
Insert Events (in order)
| # | Event | self.name Available | Changes Saved | Notes |
|---|---|---|---|---|
| 1 | before_insert | No | Yes | Modify fields before naming |
| 2 | before_naming | No | Yes | Adjust naming parameters |
| 3 | autoname | Sets it | Yes | Generate custom name |
| 4 | before_validate | Yes | Yes | Pre-validation setup |
| 5 | validate | Yes | Yes | Main validation and calculations |
| 6 | before_save | Yes | Yes | Final pre-DB modifications |
| 7 | after_insert | Yes | No | First-time creation actions |
| 8 | on_update | Yes | No | Post-save actions |
| 9 | on_change | Yes | No | Must be idempotent |
Save Events (in order)
| # | Event | Changes Saved | Notes |
|---|---|---|---|
| 1 | before_validate | Yes | Pre-validation setup |
| 2 | validate | Yes | Main validation and calculations |
| 3 | before_save | Yes | Final pre-DB modifications |
| 4 | on_update | No | Post-save actions |
| 5 | on_change | No | Must be idempotent |
Submit Events (in order)
| # | Event | Changes Saved | Notes |
|---|---|---|---|
| 1 | before_validate | Yes | Pre-validation setup |
| 2 | validate | Yes | Runs even during submit |
| 3 | before_submit | Yes | Block with frappe.throw() |
| 4 | on_submit | No | Create ledger entries |
| 5 | on_update | No | Also fires after submit |
| 6 | on_change | No | Must be idempotent |
Cancel Events (in order)
| # | Event | Changes Saved | Notes |
|---|---|---|---|
| 1 | before_cancel | Yes | Check linked docs |
| 2 | on_cancel | No | Reverse ledger entries |
| 3 | on_change | No | Must be idempotent |
Update After Submit Events (in order)
| # | Event | Changes Saved | Notes |
|---|---|---|---|
| 1 | before_update_after_submit | Yes | Only "Allow on Submit" fields |
| 2 | on_update_after_submit | No | React to submitted doc changes |
| 3 | on_change | No | Must be idempotent |
Delete Events (in order)
| # | Event | Doc Exists in DB | Notes |
|---|---|---|---|
| 1 | on_trash | Yes | Clean up related data |
| 2 | after_delete | No | Post-deletion cleanup |
Discard Events [v15+] (in order)
| # | Event | Notes |
|---|---|---|
| 1 | before_discard | Only for draft documents |
| 2 | on_discard | After docstatus set to 2 |
Rename Events (in order)
| # | Event | Notes |
|---|---|---|
| 1 | before_rename | Receives old_name, new_name, merge |
| 2 | after_rename | Receives old_name, new_name, merge |
Print Events
| # | Event | Notes |
|---|---|---|
| 1 | before_print | Modify data before print render |
---
Event Trigger Sources
Events are triggered by multiple sources. The execution order is:
1. Controller method (defined in the controller class) 2. doc_events from hooks.py (from all installed apps) 3. Server Scripts (if configured for the event) 4. Webhooks (if configured for the event)
All four run in sequence for each event. NEVER assume your controller method is the only handler.
---
Events That Run on Every Save
These events run regardless of whether the document is new or existing:
before_validatevalidatebefore_saveon_updateon_change
To distinguish new vs update inside these hooks:
def validate(self):
if self.is_new():
# First save (insert)
pass
else:
# Subsequent save (update)
old = self.get_doc_before_save()---
Events That Run Only Once
| Event | When |
|---|---|
before_insert | Only on first save (insert) |
after_insert | Only on first save (insert) |
autoname | Only on first save (insert) |
before_naming | Only on first save (insert) |
on_submit | Only on submit |
on_cancel | Only on cancel |
on_trash | Only on delete |
---
doc_events Mapping
The doc_events hook in hooks.py uses the controller method name as the event key:
doc_events = {
"Sales Order": {
"validate": "app.events.so_validate", # Runs after controller validate
"on_update": "app.events.so_on_update", # Runs after controller on_update
"on_submit": "app.events.so_on_submit", # Runs after controller on_submit
"on_cancel": "app.events.so_on_cancel", # Runs after controller on_cancel
"on_trash": "app.events.so_on_trash", # Runs after controller on_trash
"after_insert": "app.events.so_after_insert", # Runs after controller after_insert
"before_insert": "app.events.so_before_insert",
"on_change": "app.events.so_on_change",
}
}Wildcard "*" applies to ALL DocTypes:
doc_events = {
"*": {
"after_insert": "app.events.log_all_creations"
}
}ALWAYS use the function signature def handler(doc, method=None): for doc_events handlers.
Controller Examples Reference
Complete working examples of Document Controllers for common patterns.
---
1. Basic Controller with Validation
# myapp/module/doctype/invoice/invoice.py
import frappe
from frappe import _
from frappe.model.document import Document
class Invoice(Document):
def validate(self):
"""Runs on EVERY save (insert and update)."""
self.validate_dates()
self.calculate_totals()
self.set_status()
def validate_dates(self):
if self.due_date and self.posting_date:
if self.due_date < self.posting_date:
frappe.throw(_("Due Date cannot be before Posting Date"))
def calculate_totals(self):
self.total = 0
for item in self.items:
item.amount = item.qty * item.rate
self.total += item.amount
self.tax_amount = self.total * 0.21
self.grand_total = self.total + self.tax_amount
def set_status(self):
if self.is_new():
self.status = "Draft"---
2. Controller with Change Detection
# myapp/module/doctype/project/project.py
import frappe
from frappe import _
from frappe.model.document import Document
class Project(Document):
def validate(self):
old = self.get_doc_before_save()
if old is None:
self.created_by_user = frappe.session.user
else:
self.check_status_transition(old)
self.check_protected_fields(old)
def check_status_transition(self, old):
valid_transitions = {
"Open": ["In Progress", "Cancelled"],
"In Progress": ["Completed", "On Hold"],
"On Hold": ["In Progress", "Cancelled"],
}
if old.status != self.status:
allowed = valid_transitions.get(old.status, [])
if self.status not in allowed:
frappe.throw(
_("Cannot change status from {0} to {1}").format(
old.status, self.status
)
)
def check_protected_fields(self, old):
protected = ["customer", "project_type"]
for field in protected:
if old.get(field) != self.get(field):
frappe.throw(_("Cannot change {0} after creation").format(field))
def on_update(self):
# Changes to self are NOT saved here
self.add_comment("Edit", f"Updated by {frappe.session.user}")
if self.flags.get("status_changed"):
self.notify_team()---
3. Submittable Controller (Submit/Cancel Flow)
# myapp/module/doctype/purchase_order/purchase_order.py
import frappe
from frappe import _
from frappe.model.document import Document
class PurchaseOrder(Document):
def validate(self):
"""Runs on EVERY save including submit."""
self.validate_items()
self.calculate_totals()
def validate_items(self):
if not self.items:
frappe.throw(_("At least one item is required"))
for item in self.items:
if item.qty <= 0:
frappe.throw(_("Quantity must be positive for {0}").format(item.item_code))
def calculate_totals(self):
self.total_qty = sum(item.qty for item in self.items)
self.total_amount = sum(item.amount for item in self.items)
def before_submit(self):
"""Block submit if conditions not met."""
if self.total_amount > 50000 and not self.manager_approval:
frappe.throw(_("Manager approval required for orders over 50,000"))
def on_submit(self):
"""Create entries AFTER submit. Changes to self NOT saved."""
self.update_ordered_qty()
self.notify_supplier()
def update_ordered_qty(self):
for item in self.items:
frappe.db.set_value(
"Item", item.item_code, "ordered_qty",
frappe.db.get_value("Item", item.item_code, "ordered_qty") + item.qty
)
def before_cancel(self):
"""Check linked docs before allowing cancel."""
linked = frappe.get_all(
"Purchase Invoice Item",
filters={"purchase_order": self.name, "docstatus": 1},
pluck="parent"
)
if linked:
frappe.throw(
_("Cannot cancel - linked to: {0}").format(", ".join(set(linked)))
)
def on_cancel(self):
"""ALWAYS reverse what on_submit created."""
self.reverse_ordered_qty()
def reverse_ordered_qty(self):
for item in self.items:
frappe.db.set_value(
"Item", item.item_code, "ordered_qty",
frappe.db.get_value("Item", item.item_code, "ordered_qty") - item.qty
)
def on_update_after_submit(self):
"""Runs when 'Allow on Submit' fields change on submitted doc."""
if self.has_value_changed("status"):
self.add_comment("Edit", f"Status changed to {self.status}")---
4. Custom Autoname
# myapp/module/doctype/sales_order/sales_order.py
import frappe
from frappe.model.document import Document
from frappe.model.naming import getseries
class SalesOrder(Document):
def autoname(self):
"""Custom naming: SO-{CUSTOMER_CODE}-{SERIES}"""
customer_code = frappe.db.get_value(
"Customer", self.customer, "customer_code"
) or "GEN"
prefix = f"SO-{customer_code[:3].upper()}-"
self.name = getseries(prefix, 5)
# Result: SO-ACM-00001, SO-ACM-00002---
5. Controller Override via override_doctype_class
# hooks.py
override_doctype_class = {
"Sales Invoice": "myapp.overrides.sales_invoice.CustomSalesInvoice"
}# myapp/overrides/sales_invoice.py
import frappe
from frappe import _
from erpnext.accounts.doctype.sales_invoice.sales_invoice import SalesInvoice
class CustomSalesInvoice(SalesInvoice):
def validate(self):
super().validate() # ALWAYS call super() first
self.validate_credit_limit()
def validate_credit_limit(self):
customer = frappe.get_cached_doc("Customer", self.customer)
if customer.credit_limit and self.grand_total > customer.credit_limit:
frappe.throw(
_("Exceeds credit limit of {0}").format(customer.credit_limit)
)
def on_submit(self):
super().on_submit() # ALWAYS call super()
self.update_customer_stats()---
6. Controller Extension via extend_doctype_class [v16+]
# hooks.py
extend_doctype_class = {
"Address": ["myapp.extensions.address.GeocodingMixin"]
}# myapp/extensions/address.py
from frappe.model.document import Document
class GeocodingMixin(Document):
@property
def full_address(self):
return f"{self.address_line1}, {self.city}, {self.country}"
def validate(self):
super().validate() # ALWAYS call super()
self.geocode_if_needed()
def geocode_if_needed(self):
if self.has_value_changed("address_line1") or self.has_value_changed("city"):
coords = self.fetch_coordinates()
if coords:
self.latitude = coords["lat"]
self.longitude = coords["lng"]---
7. doc_events Handler
# hooks.py
doc_events = {
"Sales Invoice": {
"validate": "myapp.events.sales_invoice.validate",
"on_submit": "myapp.events.sales_invoice.on_submit",
}
}# myapp/events/sales_invoice.py
import frappe
from frappe import _
def validate(doc, method=None):
"""Handler signature: ALWAYS use (doc, method=None)."""
if doc.grand_total < 100:
frappe.msgprint(_("Order value below minimum threshold"))
if hasattr(doc, "custom_commission_rate"):
doc.custom_commission = doc.grand_total * (doc.custom_commission_rate / 100)
def on_submit(doc, method=None):
frappe.enqueue(
"myapp.integrations.sync_invoice",
queue="short",
invoice_name=doc.name,
enqueue_after_commit=True
)---
8. Whitelisted Method with Client Call
# Controller
class SalesOrder(Document):
@frappe.whitelist()
def recalculate_totals(self):
"""Callable from JS: frm.call('recalculate_totals')"""
self.calculate_totals()
return {
"total_qty": self.total_qty,
"grand_total": self.grand_total
}
@frappe.whitelist()
def send_to_customer(self, include_terms=True):
"""Callable from JS with arguments."""
frappe.sendmail(
recipients=[self.contact_email],
subject=f"Your Order {self.name}",
message=f"Order {self.name} confirmed"
)
return {"status": "sent", "email": self.contact_email}// Client-side JavaScript
frappe.ui.form.on('Sales Order', {
refresh(frm) {
frm.add_custom_button(__('Recalculate'), function() {
frm.call('recalculate_totals').then(r => {
frm.reload_doc();
frappe.msgprint(__('Totals updated'));
});
});
if (frm.doc.docstatus === 1) {
frm.add_custom_button(__('Send to Customer'), function() {
frm.call('send_to_customer', { include_terms: true })
.then(r => {
frappe.msgprint(__('Sent to {0}', [r.message.email]));
});
});
}
}
});---
9. Virtual DocType Controller
# myapp/module/doctype/external_product/external_product.py
import frappe
import requests
from frappe.model.document import Document
class ExternalProduct(Document):
"""Virtual DocType: Is Virtual = 1 in DocType settings. No database table."""
API_BASE = "https://api.example.com/products"
def load_from_db(self):
"""Called by frappe.get_doc(). Load from external source."""
response = requests.get(f"{self.API_BASE}/{self.name}")
response.raise_for_status()
data = response.json()
super(Document, self).__init__({
"name": data["id"],
"doctype": "External Product",
"product_name": data["name"],
"price": data["unit_price"],
})
def db_insert(self, *args, **kwargs):
"""Called by doc.insert(). Create in external source."""
response = requests.post(self.API_BASE, json={"name": self.product_name})
response.raise_for_status()
self.name = response.json()["id"]
def db_update(self, *args, **kwargs):
"""Called by doc.save(). Update in external source."""
requests.put(f"{self.API_BASE}/{self.name}", json={"name": self.product_name})
@staticmethod
def get_list(args):
"""Return list for List View."""
response = requests.get(ExternalProduct.API_BASE, params={
"limit": args.get("page_length", 20),
"offset": args.get("start", 0),
})
return [frappe._dict(item) for item in response.json().get("items", [])]
@staticmethod
def get_count(args):
"""Return total count for pagination."""
response = requests.get(f"{ExternalProduct.API_BASE}/count")
return response.json().get("count", 0)---
10. Tree DocType Controller
# myapp/module/doctype/department/department.py
import frappe
from frappe import _
from frappe.utils.nestedset import NestedSet
class Department(NestedSet):
"""Hierarchical DocType. Requires: Is Tree = 1 in DocType settings."""
nsm_parent_field = "parent_department"
def validate(self):
if self.parent_department == self.name:
frappe.throw(_("Department cannot be its own parent"))
def on_update(self):
super().on_update() # ALWAYS call super for NestedSet
self.update_employee_count()
def update_employee_count(self):
count = frappe.db.count("Employee", {"department": self.name})
self.db_set("total_employees", count)---
11. Flags for Inter-Hook Communication
class SalesOrder(Document):
def validate(self):
old = self.get_doc_before_save()
if old and old.status != self.status:
self.flags.status_changed = True
self.flags.old_status = old.status
if self.grand_total > 10000:
self.flags.high_value = True
def on_update(self):
if self.flags.get("status_changed"):
frappe.publish_realtime(
"status_change",
{"name": self.name, "old": self.flags.old_status, "new": self.status},
doctype=self.doctype, docname=self.name
)
def on_submit(self):
if self.flags.get("high_value"):
self.notify_finance_team()Flags System Reference
Complete reference for the Frappe flags system used in Document Controllers.
---
Two Levels of Flags
| Level | Access | Scope | Lifetime |
|---|---|---|---|
| Document flags | doc.flags | Single document instance | Current request |
| Request flags | frappe.flags | Global for entire request | Current request |
NEVER rely on flags persisting between requests. Flags are temporary, in-memory only.
---
Document Flags (doc.flags)
Permission Bypass Flags
doc.flags.ignore_permissions = True # Bypass all permission checks
doc.flags.ignore_validate = True # Skip validate() method
doc.flags.ignore_mandatory = True # Skip mandatory field checks
doc.flags.ignore_links = True # Skip link field validation
doc.flags.ignore_version = True # Skip version record creationNotification Flags
doc.flags.notify_update = False # [v15+] Suppress realtime browser updatePassing Flags via insert/save
doc.insert(
ignore_permissions=True,
ignore_links=True,
ignore_if_duplicate=True,
ignore_mandatory=True
)
doc.save(
ignore_permissions=True,
ignore_version=True
)---
Request Flags (frappe.flags)
System State Flags
if frappe.flags.in_import:
return # Skip heavy validation during data import
if frappe.flags.in_install:
return # Skip validation during app installation
if frappe.flags.in_patch:
return # Skip checks during migration/patch
if frappe.flags.in_migrate:
return # Skip checks during bench migrate
if frappe.flags.in_scheduler:
pass # Running in background scheduler jobEmail Control
# Suppress ALL emails for current request
frappe.flags.mute_emails = True
for order in orders:
frappe.get_doc("Sales Order", order).submit() # No notification emails
frappe.flags.mute_emails = False---
Custom Flags for Inter-Hook Communication
Pattern: Status Change Tracking
class SalesInvoice(Document):
def validate(self):
old = self.get_doc_before_save()
if old and old.status != self.status:
self.flags.status_changed = True
self.flags.old_status = old.status
def on_update(self):
if self.flags.get("status_changed"):
self.log_status_change(self.flags.old_status, self.status)Pattern: Recursion Prevention
class Task(Document):
def on_update(self):
if self.flags.get("updating_from_project"):
return # Prevent infinite loop
project = frappe.get_doc("Project", self.project)
project.flags.updating_from_task = True
project.update_percent_complete()
project.save()Pattern: Trigger Source Tracking
class StockEntry(Document):
def on_submit(self):
if self.flags.get("from_purchase_receipt"):
self.add_comment("Info", "Auto-created from Purchase Receipt")
# Called from another controller:
stock_entry.flags.from_purchase_receipt = True
stock_entry.submit()Pattern: Conditional Notifications
class SalesOrder(Document):
def validate(self):
if self.grand_total > 10000:
self.flags.high_value = True
def on_submit(self):
if self.flags.get("high_value"):
self.notify_finance_team()---
Flag Best Practices
ALWAYS check flags safely with get()
# CORRECT - returns None if flag not set
if self.flags.get("high_value"):
pass
# CORRECT - with default value
if self.flags.get("retry_count", 0) > 3:
pass
# WRONG - raises AttributeError if not set
if self.flags.high_value: # Risky!
passNEVER persist flags to database
# WRONG - flags are temporary, not for storage
def validate(self):
self.some_db_field = self.flags.get("temp_value")
# CORRECT - flags communicate between hooks in same request only
def validate(self):
self.flags.temp_value = compute_something()
def on_update(self):
if self.flags.get("temp_value"):
do_something()NEVER depend on flags between requests
# WRONG - flag is gone after request ends
doc.flags.process_later = True
doc.save()
# Next request: doc.flags.process_later is None---
Complete Flag Reference
doc.flags (Document Level)
| Flag | Type | Effect |
|---|---|---|
ignore_permissions | bool | Bypass permission checks |
ignore_validate | bool | Skip validate() method |
ignore_mandatory | bool | Skip mandatory field checks |
ignore_links | bool | Skip link validation |
ignore_version | bool | Skip version record creation |
notify_update | bool | [v15+] Control realtime updates |
frappe.flags (Request Level)
| Flag | Type | Effect |
|---|---|---|
in_import | bool | Data import active |
in_install | bool | App installation active |
in_patch | bool | Patch execution active |
in_migrate | bool | Migration active |
in_scheduler | bool | Background scheduler active |
mute_emails | bool | Suppress all emails |
---
Bulk Operations with Flags
def bulk_update_status(doc_names, new_status):
"""Update documents without validation or emails."""
frappe.flags.mute_emails = True
for name in doc_names:
doc = frappe.get_doc("Sales Order", name)
doc.flags.ignore_permissions = True
doc.flags.ignore_validate = True
doc.status = new_status
doc.save()
frappe.flags.mute_emails = Falsedef import_legacy_data(records):
"""Import with all checks bypassed."""
frappe.flags.in_import = True
for record in records:
doc = frappe.get_doc({"doctype": "Customer", **record})
doc.flags.ignore_permissions = True
doc.flags.ignore_mandatory = True
doc.flags.ignore_links = True
doc.flags.ignore_validate = True
doc.insert()
frappe.flags.in_import = FalseController-Hooks Interaction Reference
How controllers interact with hooks.py for extension, override, and event handling.
---
Three Extension Mechanisms
| Mechanism | hooks.py Key | Scope | Multi-App Safe | Version |
|---|---|---|---|---|
| Override class | override_doctype_class | Full class replacement | No (conflicts) | All |
| Extend class | extend_doctype_class | Add methods/properties | Yes | v16+ |
| Hook events | doc_events | Individual events | Yes | All |
---
override_doctype_class
Replaces the controller class entirely. Only ONE app can override a given DocType.
# hooks.py
override_doctype_class = {
"Sales Order": "myapp.overrides.sales_order.CustomSalesOrder",
"Purchase Invoice": "myapp.overrides.purchase_invoice.CustomPurchaseInvoice"
}# myapp/overrides/sales_order.py
from erpnext.selling.doctype.sales_order.sales_order import SalesOrder
class CustomSalesOrder(SalesOrder):
def validate(self):
super().validate() # ALWAYS call super()
self.custom_validation()
def on_submit(self):
super().on_submit() # ALWAYS call super()
self.custom_submit_logic()Rules:
- ALWAYS import and extend the original class
- ALWAYS call
super()in overridden methods - If two apps override the same DocType, only the last loaded wins
- NEVER use this in v16+ when
extend_doctype_classsuffices
---
extend_doctype_class [v16+]
Adds methods and properties to existing controller via mixins. Multiple apps can extend the same DocType safely.
# hooks.py
extend_doctype_class = {
"Address": [
"myapp.extensions.address.GeocodingMixin",
"myapp.extensions.common.AuditMixin"
],
"Contact": [
"myapp.extensions.common.AuditMixin"
]
}# myapp/extensions/address.py
from frappe.model.document import Document
class GeocodingMixin(Document):
@property
def full_address(self):
return f"{self.address_line1}, {self.city}, {self.country}"
def validate(self):
super().validate()
self.geocode_if_changed()
def geocode_if_changed(self):
if self.has_value_changed("city"):
self.latitude, self.longitude = self.fetch_coords()Rules:
- ALWAYS call
super().method()when overriding lifecycle hooks - Mixin class MUST extend
Document(or the appropriate base) - Multiple mixins are applied in list order
- ALWAYS prefer this over
override_doctype_classin v16+
---
doc_events
Hook into individual document events without replacing the controller.
# hooks.py
doc_events = {
"Sales Order": {
"validate": "myapp.events.so.validate",
"on_submit": "myapp.events.so.on_submit",
"on_cancel": "myapp.events.so.on_cancel",
"after_insert": "myapp.events.so.after_insert",
"on_trash": "myapp.events.so.on_trash",
"on_change": "myapp.events.so.on_change",
},
# Wildcard: ALL DocTypes
"*": {
"after_insert": "myapp.events.audit.log_creation",
"on_update": "myapp.events.audit.log_update",
}
}# myapp/events/so.py
import frappe
from frappe import _
def validate(doc, method=None):
"""ALWAYS use this exact signature: (doc, method=None)"""
if doc.total > 100000:
doc.requires_approval = 1
def on_submit(doc, method=None):
frappe.enqueue(
"myapp.integrations.sync",
queue="short",
doc_name=doc.name,
enqueue_after_commit=True
)Rules:
- Handler signature MUST be
def handler(doc, method=None): - Multiple apps can hook the same event on the same DocType
- Handlers run AFTER the controller method, in app install order
- Use wildcard
"*"sparingly -- it runs for EVERY DocType
---
Execution Order per Event
When an event fires, handlers execute in this order:
1. Controller method (the method on the DocType class) 2. doc_events handlers (from hooks.py, all installed apps, in install order) 3. Server Scripts (if configured for this DocType/event) 4. Webhooks (if configured for this DocType/event)
---
Choosing the Right Mechanism
Do you need to...
Replace the entire controller class?
-> override_doctype_class [all versions]
-> WARNING: Only one app can do this per DocType
Add methods/properties to a class? (v16+)
-> extend_doctype_class
-> Multiple apps can extend safely
Hook into one or two events?
-> doc_events
-> Simplest, most compatible
Add behavior to ALL DocTypes?
-> doc_events with "*" wildcard
Extend a controller in v14/v15?
-> Use doc_events (safest)
-> Or override_doctype_class (if you need full control)---
Other hooks.py Keys That Affect Controllers
export_python_type_annotations [v15+]
# hooks.py
export_python_type_annotations = TrueEnables auto-generation of type annotations in controller files for IDE support.
scheduler_events
# hooks.py
scheduler_events = {
"daily": ["myapp.tasks.daily_cleanup"],
"hourly": ["myapp.tasks.hourly_sync"],
"cron": {
"0 */6 * * *": ["myapp.tasks.six_hourly_task"]
}
}These are NOT controller methods but standalone functions. NEVER put controller logic in scheduler events.
override_whitelisted_methods
# hooks.py
override_whitelisted_methods = {
"frappe.client.get_count": "myapp.overrides.custom_get_count"
}Override globally whitelisted API methods. Use with extreme caution.
---
Common Patterns
Combining doc_events with override
Use doc_events for event hooks and override_doctype_class only when you need new methods or properties:
# hooks.py
override_doctype_class = {
"Sales Order": "myapp.overrides.so.CustomSalesOrder"
}
doc_events = {
"Purchase Order": {
"validate": "myapp.events.po.validate"
}
}Multiple Apps Extending Same DocType [v16+]
# App A hooks.py
extend_doctype_class = {
"Sales Invoice": ["app_a.extensions.TaxMixin"]
}
# App B hooks.py
extend_doctype_class = {
"Sales Invoice": ["app_b.extensions.ShippingMixin"]
}
# Both mixins are applied. App install order determines method resolution.Lifecycle Methods Reference
Complete reference for all Document Controller lifecycle hooks in Frappe v14-v16.
---
Complete Hook Table
| Hook | Runs During | When | Can Modify self? | Version |
|---|---|---|---|---|
before_insert | insert | Before naming, before DB write | Yes | All |
before_naming | insert | Before name generation | Yes (naming params) | All |
autoname | insert | During name generation | Sets self.name | All |
before_validate | insert, save, submit | Before validate() | Yes | All |
validate | insert, save, submit | Main validation | Yes (saved to DB) | All |
before_save | insert, save, submit | After validate, before DB write | Yes (saved to DB) | All |
after_insert | insert | After DB insert, before on_update | Yes (NOT saved) | All |
on_update | insert, save | After DB write | No (use db_set) | All |
on_change | insert, save, submit, cancel, db_set | After any value change | No | All |
before_submit | submit | Before docstatus changes to 1 | Yes (saved to DB) | All |
on_submit | submit | After docstatus = 1 in DB | No (use db_set) | All |
before_cancel | cancel | Before docstatus changes to 2 | Yes | All |
on_cancel | cancel | After docstatus = 2 in DB | No (use db_set) | All |
before_update_after_submit | update_after_submit | Before submitted doc update | Yes | All |
on_update_after_submit | update_after_submit | After submitted doc update | No | All |
on_trash | delete | Before DB delete | Yes | All |
after_delete | delete | After DB delete | N/A | All |
before_rename | rename | Before name change | Yes | All |
after_rename | rename | After name change | No | All |
before_print | Before print format renders | Yes | All | |
before_discard | discard | Before draft discard | Yes | v15+ |
on_discard | discard | After draft discard | No | v15+ |
---
Execution Order Diagrams
INSERT (New Document)
doc.insert()
|
v
1. before_insert
- Last chance to modify fields before naming
- self.name is NOT yet available
|
v
2. before_naming
- Modify naming_series or naming parameters
- Runs BEFORE autoname
|
v
3. autoname
- Generate self.name programmatically
- Overrides DocType Auto Name setting
|
v
4. before_validate
- self.name IS now available
- Pre-validation setup
|
v
5. validate
- MAIN validation and calculations
- Use frappe.throw() to block save
- Changes to self ARE saved
|
v
6. before_save
- Final chance for modifications before DB write
- After all validation has passed
|
v
7. [db_insert - INTERNAL]
- Document written to database
- No custom code possible here
|
v
8. after_insert
- Document exists in DB with name
- Changes to self are NOT saved
- Runs ONLY for new documents (not updates)
|
v
9. on_update
- After successful save
- Changes to self are NOT saved
- Use self.db_set() or frappe.db.set_value()
|
v
10. on_change
- After any value change
- MUST be idempotent (may run multiple times)SAVE (Existing Document)
doc.save()
|
v
1. before_validate
|
v
2. validate
- Changes to self ARE saved
|
v
3. before_save
|
v
4. [db_update - INTERNAL]
|
v
5. on_update
- Changes to self are NOT saved
|
v
6. on_changeSUBMIT (docstatus 0 -> 1)
doc.submit()
|
v
1. before_validate
|
v
2. validate
|
v
3. before_submit
- Last chance to block submit with frappe.throw()
- Changes to self ARE saved
|
v
4. [db_update with docstatus=1 - INTERNAL]
|
v
5. on_submit
- Create ledger entries, stock entries here
- Changes to self are NOT saved
|
v
6. on_update
|
v
7. on_changeCANCEL (docstatus 1 -> 2)
doc.cancel()
|
v
1. before_cancel
- Check for linked submitted documents
- Use frappe.throw() to block cancel
|
v
2. [db_update with docstatus=2 - INTERNAL]
|
v
3. on_cancel
- ALWAYS reverse what on_submit created
- Changes to self are NOT saved
|
v
4. on_changeUPDATE AFTER SUBMIT
doc.save() [when docstatus == 1]
|
v
1. before_update_after_submit
- Only fields with "Allow on Submit" can change
|
v
2. [db_update - INTERNAL]
|
v
3. on_update_after_submit
|
v
4. on_changeDELETE
doc.delete() / frappe.delete_doc()
|
v
1. on_trash
- Clean up related data
- NEVER delete linked submitted documents here
|
v
2. [db_delete - INTERNAL]
|
v
3. after_delete
- Document no longer exists in DBDISCARD [v15+]
doc.discard()
|
v
1. before_discard
- Only for draft documents (docstatus=0)
|
v
2. [db_set docstatus=2 - INTERNAL]
|
v
3. on_discardRENAME
frappe.rename_doc()
|
v
1. before_rename
|
v
2. [rename in DB - INTERNAL]
|
v
3. after_rename---
Hook Method Signatures
class MyDocType(Document):
# Naming hooks
def before_naming(self, *args, **kwargs): ...
def autoname(self): ...
# Insert hooks
def before_insert(self): ...
def after_insert(self): ...
# Validation and save hooks
def before_validate(self): ...
def validate(self): ...
def before_save(self): ...
def on_update(self): ...
def on_change(self): ...
# Submit hooks
def before_submit(self): ...
def on_submit(self): ...
# Cancel hooks
def before_cancel(self): ...
def on_cancel(self): ...
# Update after submit hooks
def before_update_after_submit(self): ...
def on_update_after_submit(self): ...
# Delete hooks
def on_trash(self): ...
def after_delete(self): ...
# Rename hooks
def before_rename(self, old_name, new_name, merge=False): ...
def after_rename(self, old_name, new_name, merge=False): ...
# Print hooks
def before_print(self, print_settings=None): ...
# Discard hooks [v15+]
def before_discard(self): ...
def on_discard(self): ...---
Key Rules
1. validate is the ONLY hook where changes to self are reliably saved to DB 2. on_update, on_submit, on_cancel run AFTER the DB write -- changes to self are NOT saved 3. on_change MUST be idempotent -- it runs after every value change including db_set 4. after_insert runs ONLY for new documents, NEVER for updates 5. before_submit can block submit with frappe.throw() -- use for approval checks 6. ALWAYS implement on_cancel as the reverse of on_submit 7. NEVER call frappe.db.commit() inside any hook
Document Class Methods Reference
Complete reference for all doc.* methods available in Frappe Document Controllers.
---
Data Access Methods
doc.get(fieldname, default=None)
Safely retrieve field value with optional default.
customer = self.get("customer")
status = self.get("status", "Draft")
items = self.get("items", []) # Child table returns listdoc.set(fieldname, value)
Set field value. Works for all field types including child tables.
self.set("status", "Completed")
self.set("items", [{"item_code": "ITEM-001", "qty": 10}]) # Replaces child tabledoc.as_dict(no_nulls=False, no_default_fields=False)
Serialize document to dictionary.
data = doc.as_dict()
data_clean = doc.as_dict(no_nulls=True) # Skip None fields
data_minimal = doc.as_dict(no_default_fields=True) # Skip name, owner, creation, etc.doc.get_valid_dict(sanitize=True, convert_dates_to_str=False)
Return dictionary with only valid fields (filtered by permissions and docfield meta).
valid_data = self.get_valid_dict()
export_data = self.get_valid_dict(convert_dates_to_str=True)doc.has_value_changed(fieldname)
Check if a specific field changed since last save. Available in validate and later hooks.
def validate(self):
if self.has_value_changed("status"):
self.status_changed_on = frappe.utils.now()doc.get_doc_before_save()
Return document as it was before current modifications. Returns None for new documents.
def validate(self):
old = self.get_doc_before_save()
if old is None:
pass # New document
elif old.status != self.status:
self.log_status_change(old.status, self.status)doc.is_new()
Check if document is new (not yet saved to database).
def validate(self):
if self.is_new():
self.status = "Draft"---
Database Operations
doc.insert(ignore_permissions=False, ignore_links=False, ignore_if_duplicate=False, ignore_mandatory=False)
Insert new document with all lifecycle hooks.
doc = frappe.get_doc({"doctype": "Task", "subject": "New Task"})
doc.insert()
doc.insert(ignore_permissions=True, ignore_mandatory=True) # Bypass checksdoc.save(ignore_permissions=False, ignore_version=True)
Save existing document with all lifecycle hooks.
doc.customer = "New Customer"
doc.save()
doc.save(ignore_permissions=True)doc.submit()
Submit document (docstatus 0 -> 1). ONLY for submittable DocTypes.
doc = frappe.get_doc("Sales Order", "SO-00001")
doc.submit() # Triggers before_submit, on_submitdoc.cancel()
Cancel document (docstatus 1 -> 2).
doc.cancel() # Triggers before_cancel, on_canceldoc.delete()
Delete document from database.
doc.delete() # Triggers on_trash, after_deletedoc.reload()
Reload document from database with latest values.
def on_update(self):
self.reload() # Get latest values after DB writedoc.db_set(fieldname, value, notify=True, commit=False, update_modified=True)
Direct database field update. ALWAYS use this instead of modifying self in post-save hooks.
def on_update(self):
self.db_set("processed", 1)
self.db_set("status", "Completed", update_modified=False)
# Or update multiple fields:
self.db_set({"status": "Completed", "processed_at": frappe.utils.now()})---
Low-Level Database Methods (USE WITH CAUTION)
doc.db_insert(args, *kwargs)
Direct database insert. Bypasses ALL hooks and validation.
# ONLY for bulk operations or Virtual DocTypes
doc.db_insert() # No validate, no permissions, no hooksdoc.db_update()
Direct database update. Bypasses ALL hooks and validation.
# ONLY for performance-critical bulk operations
doc.db_update() # No validate, no permissions, no hooksALWAYS prefer insert() and save() over db_insert() and db_update().
---
Child Table Methods
doc.append(fieldname, value=None)
Add row to child table.
row = self.append("items", {
"item_code": "ITEM-001",
"qty": 10,
"rate": 100
})
# row is the new child document objectdoc.extend(fieldname, values)
Add multiple rows to child table.
self.extend("items", [
{"item_code": "ITEM-001", "qty": 10},
{"item_code": "ITEM-002", "qty": 5},
])Iterating child tables
for item in self.get("items"):
print(item.item_code, item.qty)
# Filter
high_value = [i for i in self.items if i.amount > 1000]---
Method Execution
doc.run_method(method_name, args, *kwargs)
Execute controller method AND trigger associated hooks (doc_events, server scripts).
doc.run_method("validate") # Runs validate + all doc_events for validate
doc.run_method("custom_method", value="test")doc.queue_action(action, **kwargs)
Execute controller method asynchronously in background.
def on_submit(self):
self.queue_action("send_emails", emails=email_list)
def send_emails(self, emails):
for email in emails:
frappe.sendmail(recipients=email, message="Order submitted")---
Permission Methods
doc.has_permission(permtype="read", user=None)
Check if user has permission on this document.
if not self.has_permission("write"):
frappe.throw(_("No write permission"))doc.check_permission(permtype="read")
Same as has_permission but throws if no permission.
self.check_permission("submit") # Throws if not permitted---
Communication Methods
doc.add_comment(comment_type, text, comment_email=None, comment_by=None)
Add comment to document timeline.
self.add_comment("Edit", "Document updated by system")
self.add_comment("Info", f"Status changed to {self.status}")Comment types: Comment, Edit, Created, Submitted, Cancelled, Info, Label, Shared, Assigned, Attachment
doc.notify_update()
Publish realtime event that document has changed. Triggers form refresh in browser.
frappe.db.set_value("Sales Order", self.name, "status", "Closed")
self.notify_update() # Browser refreshes automatically---
Utility Methods
doc.get_title()
Return document title (uses title_field configuration).
title = doc.get_title() # e.g., customer name for Sales Orderdoc.get_url()
Return desk URL for this document.
url = doc.get_url() # /app/sales-order/SO-00001doc.add_tag(tag_name)
doc.add_tag("urgent")doc.get_tags()
tags = doc.get_tags() # ["urgent", "reviewed"]---
Method Summary
| Method | Parameters | Returns | Saves to DB |
|---|---|---|---|
get(field, default) | str, any | any | No |
set(field, value) | str, any | None | No |
as_dict() | no_nulls, no_default_fields | dict | No |
is_new() | - | bool | No |
has_value_changed(field) | str | bool | No |
get_doc_before_save() | - | Document/None | No |
insert(**flags) | kwargs | self | Yes |
save(**flags) | kwargs | self | Yes |
submit() | - | self | Yes |
cancel() | - | self | Yes |
delete() | - | None | Yes |
reload() | - | None | No |
db_set(field, value) | str/dict, any | None | Yes |
run_method(method) | str, args | any | No |
queue_action(action) | str, kwargs | None | No |
append(field, value) | str, dict | row | No |
has_permission(perm) | str | bool | No |
add_comment(type, text) | str, str | Comment | Yes |
notify_update() | - | None | No |
Common Controller Patterns Reference
Proven patterns for Document Controllers in Frappe v14-v16.
---
Naming Patterns
Naming Series
Set autoname = "naming_series:" in DocType definition. Users select series from dropdown.
# No controller code needed -- configured in DocType
# Series options defined in DocType: SO-.YYYY.-.#####
# Result: SO-2024-00001, SO-2024-00002Custom Autoname with getseries
from frappe.model.naming import getseries
class Project(Document):
def autoname(self):
prefix = f"PRJ-{self.company_abbr}-"
self.name = getseries(prefix, 5)
# Result: PRJ-ABC-00001Autoname Based on Fields
class EmployeeLeave(Document):
def autoname(self):
self.name = f"{self.employee}-{self.leave_type}-{self.from_date}"Document Naming Rules (No Code)
Configure in Setup > Document Naming Rules. Priority-based rules with conditions. ALWAYS prefer Document Naming Rules over controller autoname for simple patterns.
---
Validation Patterns
Change Detection
def validate(self):
old = self.get_doc_before_save()
if old is None:
return # New document, no changes to detect
if old.status != self.status:
self.validate_status_transition(old.status, self.status)
if old.customer != self.customer:
frappe.throw(_("Cannot change customer after creation"))Using has_value_changed [v15+]
def validate(self):
if self.has_value_changed("status"):
self.status_changed_on = frappe.utils.now()
self.flags.status_changed = TrueProtected Fields Pattern
PROTECTED_AFTER_SUBMIT = ["customer", "company", "currency"]
def before_update_after_submit(self):
old = self.get_doc_before_save()
for field in PROTECTED_AFTER_SUBMIT:
if old.get(field) != self.get(field):
frappe.throw(_("Cannot change {0} after submit").format(field))Child Table Validation
def validate(self):
if not self.items:
frappe.throw(_("At least one item is required"))
seen = set()
for idx, item in enumerate(self.items, 1):
if item.item_code in seen:
frappe.throw(_("Duplicate item {0} in row {1}").format(
item.item_code, idx
))
seen.add(item.item_code)
if item.qty <= 0:
frappe.throw(_("Quantity must be positive in row {0}").format(idx))
item.amount = item.qty * item.rate
self.total = sum(item.amount for item in self.items)---
Submit/Cancel Patterns
Paired Submit and Cancel
ALWAYS implement on_cancel as the exact reverse of on_submit.
def on_submit(self):
self.create_ledger_entries()
self.update_stock()
self.update_party_balance()
def on_cancel(self):
self.reverse_ledger_entries()
self.reverse_stock()
self.reverse_party_balance()Linked Document Check Before Cancel
def before_cancel(self):
linked = frappe.get_all(
"Purchase Invoice Item",
filters={"purchase_order": self.name, "docstatus": 1},
pluck="parent"
)
if linked:
frappe.throw(
_("Cannot cancel: linked to {0}").format(", ".join(set(linked)))
)Approval Gate Before Submit
def before_submit(self):
if self.grand_total > 50000 and not self.approved_by:
frappe.throw(_("Manager approval required for orders over 50,000"))---
Workflow Integration Patterns
Status Transition Validation
VALID_TRANSITIONS = {
"Draft": ["Pending Approval", "Cancelled"],
"Pending Approval": ["Approved", "Rejected"],
"Approved": ["In Progress"],
"In Progress": ["Completed", "On Hold"],
"On Hold": ["In Progress", "Cancelled"],
}
def validate(self):
old = self.get_doc_before_save()
if old and old.status != self.status:
allowed = VALID_TRANSITIONS.get(old.status, [])
if self.status not in allowed:
frappe.throw(
_("Cannot transition from {0} to {1}").format(old.status, self.status)
)---
Communication Patterns
Post-Save Notifications
def on_update(self):
if self.flags.get("status_changed"):
frappe.publish_realtime(
"order_status",
{"name": self.name, "status": self.status},
doctype=self.doctype,
docname=self.name
)Async Email via Enqueue
def on_submit(self):
frappe.enqueue(
"myapp.email.send_order_confirmation",
queue="short",
doc_name=self.name,
enqueue_after_commit=True
)---
Performance Patterns
Batch Database Reads
def validate(self):
# CORRECT: One query for all items
item_codes = [item.item_code for item in self.items]
prices = {
d.item_code: d.price
for d in frappe.get_all("Item Price",
filters={"item_code": ["in", item_codes], "price_list": self.price_list},
fields=["item_code", "price"]
)
}
for item in self.items:
item.rate = prices.get(item.item_code, 0)Cached Document Reads
def validate(self):
# CORRECT: Uses document cache
customer = frappe.get_cached_doc("Customer", self.customer)
self.customer_group = customer.customer_group
self.territory = customer.territoryBackground Processing
def on_submit(self):
# Light operations inline
self.db_set("submitted_at", frappe.utils.now())
# Heavy operations in background
self.queue_action("heavy_processing")
def heavy_processing(self):
"""Runs in background worker."""
self.generate_reports()
self.sync_external_systems()---
Flags Communication Pattern
Pass data between hooks in the same request:
def validate(self):
old = self.get_doc_before_save()
if old and old.status != self.status:
self.flags.status_changed = True
self.flags.old_status = old.status
if self.grand_total > 10000:
self.flags.high_value = True
def on_update(self):
if self.flags.get("status_changed"):
self.add_comment("Edit",
f"Status: {self.flags.old_status} -> {self.status}")
def on_submit(self):
if self.flags.get("high_value"):
self.request_additional_approval()---
Recursion Prevention Pattern
When two DocTypes update each other:
class Task(Document):
def on_update(self):
if self.flags.get("from_project"):
return # Prevent loop
project = frappe.get_doc("Project", self.project)
project.flags.from_task = True
project.update_progress()
project.save()
class Project(Document):
def on_update(self):
if self.flags.get("from_task"):
return # Prevent loop
for task in frappe.get_all("Task", filters={"project": self.name}):
doc = frappe.get_doc("Task", task.name)
doc.flags.from_project = True
doc.expected_end_date = self.expected_end_date
doc.save()---
Whitelisted Method Pattern
Expose controller methods to client-side JavaScript:
class SalesOrder(Document):
@frappe.whitelist()
def apply_discount(self, discount_percent):
"""Called from JS: frm.call('apply_discount', {discount_percent: 10})"""
for item in self.items:
item.discount = discount_percent
item.amount = item.qty * item.rate * (1 - discount_percent / 100)
self.save()
return {"new_total": sum(i.amount for i in self.items)}ALWAYS add @frappe.whitelist() decorator. Without it, client calls return 403 Forbidden.
Controller Class Syntax Reference
Exact syntax for Document Controller classes in Frappe v14-v16.
---
Minimal Controller
# {app}/{module}/doctype/{doctype_snake}/{doctype_snake}.py
import frappe
from frappe.model.document import Document
class MyDocType(Document):
passEVERY DocType MUST have a controller file, even if empty.
---
Standard Controller Template
import frappe
from frappe import _
from frappe.model.document import Document
class SalesOrder(Document):
# ---- NAMING ----
def autoname(self):
self.name = f"SO-{self.company_abbr}-{frappe.utils.now_datetime().year}"
# ---- VALIDATION ----
def before_validate(self):
self.set_defaults()
def validate(self):
self.validate_items()
self.calculate_totals()
def before_save(self):
self.set_status()
# ---- INSERT ONLY ----
def before_insert(self):
self.created_by = frappe.session.user
def after_insert(self):
self.add_comment("Created", f"Created by {frappe.session.user}")
# ---- POST-SAVE ----
def on_update(self):
self.notify_relevant_users()
def on_change(self):
pass # MUST be idempotent
# ---- SUBMIT/CANCEL ----
def before_submit(self):
self.check_approval()
def on_submit(self):
self.create_ledger_entries()
def before_cancel(self):
self.check_linked_docs()
def on_cancel(self):
self.reverse_ledger_entries()
# ---- UPDATE AFTER SUBMIT ----
def before_update_after_submit(self):
self.validate_allowed_changes()
def on_update_after_submit(self):
self.log_amendment()
# ---- DELETE ----
def on_trash(self):
self.cleanup_related_data()
def after_delete(self):
pass
# ---- RENAME ----
def before_rename(self, old_name, new_name, merge=False):
pass
def after_rename(self, old_name, new_name, merge=False):
pass
# ---- PRINT ----
def before_print(self, print_settings=None):
self.print_date = frappe.utils.today()
# ---- WHITELISTED (callable from JS) ----
@frappe.whitelist()
def recalculate(self):
self.calculate_totals()
return {"total": self.total}
# ---- PRIVATE METHODS ----
def validate_items(self):
if not self.items:
frappe.throw(_("Items are required"))
def calculate_totals(self):
self.total = sum(item.amount for item in self.items)---
Type Annotations [v15+]
import frappe
from frappe.model.document import Document
class Person(Document):
# begin: auto-generated types
# This block is auto-generated. Do not modify.
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from frappe.types import DF
first_name: DF.Data
last_name: DF.Data
email: DF.Data
birth_date: DF.Date
company: DF.Link
is_active: DF.Check
notes: DF.TextEditor
items: DF.Table["PersonItem"]
# end: auto-generated types
def validate(self):
if not self.first_name:
frappe.throw(_("First name is required"))Enable in hooks.py: export_python_type_annotations = True
---
Tree DocType Controller
import frappe
from frappe import _
from frappe.utils.nestedset import NestedSet
class Department(NestedSet):
nsm_parent_field = "parent_department"
def validate(self):
if self.parent_department == self.name:
frappe.throw(_("Cannot be own parent"))
def on_update(self):
super().on_update() # ALWAYS call super for NestedSet---
Virtual DocType Controller
import frappe
from frappe.model.document import Document
class ExternalData(Document):
"""DocType with Is Virtual = 1. No database table."""
def load_from_db(self):
"""Called by frappe.get_doc(). Load from external source."""
data = external_api.get(self.name)
super(Document, self).__init__(data)
def db_insert(self, *args, **kwargs):
"""Called by doc.insert()."""
external_api.create(self.as_dict())
def db_update(self, *args, **kwargs):
"""Called by doc.save()."""
external_api.update(self.name, self.as_dict())
@staticmethod
def get_list(args):
"""Return list for List View. MUST be @staticmethod."""
return external_api.list(args.get("filters"), args.get("page_length", 20))
@staticmethod
def get_count(args):
"""Return count for pagination. MUST be @staticmethod."""
return external_api.count(args.get("filters"))
@staticmethod
def get_stats(args):
"""Return stats for sidebar. MUST be @staticmethod."""
return {}---
Controller Override Class
# myapp/overrides/custom_sales_order.py
from erpnext.selling.doctype.sales_order.sales_order import SalesOrder
class CustomSalesOrder(SalesOrder):
def validate(self):
super().validate() # ALWAYS call super first
self.custom_validation()
def on_submit(self):
super().on_submit() # ALWAYS call super
self.custom_post_submit()Register in hooks.py:
override_doctype_class = {
"Sales Order": "myapp.overrides.custom_sales_order.CustomSalesOrder"
}---
Extension Mixin [v16+]
# myapp/extensions/audit_mixin.py
from frappe.model.document import Document
class AuditMixin(Document):
def validate(self):
super().validate() # ALWAYS call super
self.set_audit_fields()
def set_audit_fields(self):
if self.is_new():
self.custom_created_by = frappe.session.user
self.custom_last_modified_by = frappe.session.userRegister in hooks.py:
extend_doctype_class = {
"Sales Order": ["myapp.extensions.audit_mixin.AuditMixin"],
"Purchase Order": ["myapp.extensions.audit_mixin.AuditMixin"]
}---
doc_events Handler Function
# myapp/events/sales_order.py
import frappe
from frappe import _
def validate(doc, method=None):
"""EXACT signature: (doc, method=None). NEVER change this."""
if doc.total > 100000:
doc.requires_approval = 1
def on_submit(doc, method=None):
frappe.enqueue(
"myapp.tasks.sync_order",
queue="short",
doc_name=doc.name,
enqueue_after_commit=True
)Register in hooks.py:
doc_events = {
"Sales Order": {
"validate": "myapp.events.sales_order.validate",
"on_submit": "myapp.events.sales_order.on_submit"
}
}---
Whitelisted Method Syntax
class SalesOrder(Document):
@frappe.whitelist()
def method_name(self, param1, param2="default"):
"""Callable from JS: frm.call('method_name', {param1: 'value'})"""
# self is the document instance
# Return value is available as r.message in JS
return {"result": "value"}Client-side call:
frm.call('method_name', { param1: 'value' }).then(r => {
console.log(r.message.result); // "value"
});---
Import Conventions
# ALWAYS import
import frappe
from frappe import _
# Controller base class
from frappe.model.document import Document
# Tree DocType base
from frappe.utils.nestedset import NestedSet
# Naming utilities
from frappe.model.naming import getseries, make_autoname
# Common utilities
from frappe.utils import (
cint, cstr, flt,
now, now_datetime, today,
getdate, get_datetime,
format_date, format_datetime
)