> Use when writing Python Document Controllers for ERPNext/Frappe DocTypes. Covers lifecycle hooks (validate, on_update, on_submit), controller override, submittable documents, autoname patterns, UUID naming (v16), validate, on_update, on_submit, autoname, naming series, flags, v14-v16, controller example, lifecycle hook order, when to use validate, Python DocType class.
npx skills add https://github.com/Impertio-Studio/Frappe_Claude_Skill_Package --skill frappe-syntax-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.
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}
| 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.
before_insert -> before_naming -> autoname -> before_validate -> validate
-> before_save -> [db_insert] -> after_insert -> on_update -> on_change
before_validate -> validate -> before_save -> [db_update]
-> on_update -> on_change
before_validate -> validate -> before_submit -> [db_update]
-> on_submit -> on_update -> on_change
before_cancel -> [db_update] -> on_cancel -> on_change
before_update_after_submit -> [db_update]
-> on_update_after_submit -> on_change
on_trash -> [db_delete] -> after_delete
before_discard -> [db_set docstatus=2] -> on_discard
Complete hook reference with parameters: See lifecycle-methods.md
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)
# 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")
# 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 needed
# WRONG - parent validation is skipped entirely
def validate(self):
self.custom_check()
# CORRECT - parent logic preserved
def validate(self):
super().validate()
self.custom_check()
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()
# 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
| 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 |
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.
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
# 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.
# 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.
# 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 = 1
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
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: '[email protected]' })
.then(r => frappe.msgprint(r.message.status));
Rules:
@frappe.whitelist() decorator — without it, the method is NOT callable from clientDocuments 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.
# 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): ...
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.Link
Enable auto-generation in hooks.py: export_python_type_annotations = True
| 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 |
| 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 |
frappe-syntax-serverscripts -- Server Scripts (sandbox alternative)frappe-syntax-hooks -- hooks.py configurationfrappe-impl-controllers -- Implementation workflowsfrappe-core-permissions -- Permission systemInteract with Obsidian vaults using the Obsidian CLI to read, create, search, and manage notes, tasks, properties, and more. Also supports plugin and theme development with commands to reload plugins, run JavaScript, capture errors, take screenshots, and inspect the DOM. Use when the user asks to interact with their Obsidian vault, manage notes, search vault content, perform vault operations from the command line, or develop and debug Obsidian plugins and themes.
Comprehensive project architecture blueprint generator that analyzes codebases to create detailed architectural documentation. Automatically detects technology stacks and architectural patterns, generates visual diagrams, documents implementation patterns, and provides extensible blueprints for maintaining architectural consistency and guiding new development.
Securely inspect and automate microscopy data workflows against OMERO.server with omero-py, BlitzGateway, OMERO CLI, tables, annotations, ROIs, rendering, and documented OMERO.web APIs. Use for scoped OMERO inventory, metadata export, import/export planning, or reviewed write workflows.
Review the changes since a fixed point (commit, branch, tag, or merge-base) along two axes — Standards (does the code follow this repo's documented coding standards?) and Spec (does the code match what the originating issue/PRD asked for?). Runs both reviews in parallel sub-agents and reports them side by side. Use when the user wants to review a branch, a PR, work-in-progress changes, or asks to "review since X".
Master API documentation with OpenAPI 3.1, AI-powered tools, and modern developer experience practices. Create interactive docs, generate SDKs, and build comprehensive developer portals.
Creates comprehensive API changelogs documenting breaking changes, deprecations, and migration strategies for API consumers. Use when managing API versions, communicating breaking changes, or creating upgrade guides.
Master API documentation with OpenAPI 3.1, AI-powered tools, and modern developer experience practices. Create interactive docs, generate SDKs, and build comprehensive developer portals. Use PROACTIVELY for API documentation or developer portal creation.
Analyze fundamental data primitives, type systems, and state management patterns in a codebase. Use when (1) evaluating typing strategies (Pydantic vs TypedDict vs loose dicts), (2) assessing immutability and mutation patterns, (3) understanding serialization approaches, (4) documenting state shape and lifecycle, or (5) comparing data modeling approaches across frameworks.
Take impertio-studio/frappe-syntax-controllers from the repository into ~/.claude/skills for personal
use, or into .claude/skills inside a project.
The agent identifies a skill by the name field in its header. Two skills with the
same name cannot sit side by side — one of them will be ignored.