Controllers
Controllers add server-side logic to DocTypes via Python classes.
File location
apps/<app>/<app>/<module>/doctype/<doctype_name>/<doctype_name>.py
Basic controller
import frappe
from frappe.model.document import Document
class Expense(Document):
def validate(self):
if self.amount <= 0:
frappe.throw("Amount must be positive")
def before_save(self):
self.total = sum(item.amount for item in self.items)
The class name is the DocType name with spaces removed (e.g. "Expense Category" → ExpenseCategory).
Document lifecycle hooks
Called in this order:
On insert (new document)
before_insertbefore_naming(before name is set)autoname(custom naming logic —self.nameis set after this)before_validatevalidatebefore_save- (db insert)
after_inserton_updateafter_saveon_change
On update (existing document)
before_validatevalidatebefore_save- (db update)
on_updateafter_saveon_change
On submit (submittable DocTypes)
before_validatevalidatebefore_savebefore_submiton_submiton_updateafter_saveon_change
On cancel
before_cancelon_cancelon_change
On delete
on_trashafter_delete
Common patterns
Set defaults before validation
def before_validate(self):
if not self.currency:
self.currency = frappe.defaults.get_global_default("currency")
Throw validation errors
frappe.throw("Error message") # general error
frappe.throw("Message", frappe.ValidationError) # with exception type
Access current user
frappe.session.user # email of logged-in user
Set field values
def before_save(self):
self.full_name = f"{self.first_name} {self.last_name}"
Interact with other DocTypes
def on_submit(self):
frappe.get_doc(
doctype="Notification Log",
subject=f"Expense {self.name} approved"
).insert(ignore_permissions=True)
Access flags
# Set a flag to skip validation in specific cases
doc.flags.ignore_validate = True
doc.save()
Anti-patterns
- Don't use
frappe.db.set_valuefor fields with validation logic. It bypassesvalidate(),before_save(), and all lifecycle hooks. Never use it for status fields or state transitions. Use it only for simple counters, timestamps, or cached values.# BAD — skips controller validation frappe.db.set_value("Expense", name, "status", "Approved") # GOOD doc = frappe.get_doc("Expense", name) doc.status = "Approved" doc.save() - Don't call
frappe.db.commit()in controller methods or request handlers. See the Transactions section in database reference. - Put permission checks inside controller methods, not in API wrapper helpers. This ensures enforcement regardless of call path (API, desk, background job).
# BAD — check in api.py wrapper def _get_manager_doc(name): if "Expense Manager" not in frappe.get_roles(): ... # GOOD — check in the controller method itself class Expense(Document): @frappe.whitelist() def approve(self): if "Expense Manager" not in frappe.get_roles(): frappe.throw("Not allowed", frappe.PermissionError) - Be consistent with permission checks across all controller methods. If some methods on a DocType check for a role explicitly, all mutating methods should do the same — don't rely on implicit DocType perms for some and explicit checks for others.