Frappe document lifecycle hooks and server-side Python logic.
Controllers add server-side logic to DocTypes via Python classes.
apps/<app>/<app>/<module>/doctype/<doctype_name>/<doctype_name>.py
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).
Called in this order:
before_insertbefore_naming (before name is set)autoname (custom naming logic — self.name is set after this)before_validatevalidatebefore_saveafter_inserton_updateafter_saveon_changebefore_validatevalidatebefore_saveon_updateafter_saveon_changebefore_validatevalidatebefore_savebefore_submiton_submiton_updateafter_saveon_changebefore_cancelon_cancelon_changeon_trashafter_deletedef before_validate(self):
if not self.currency:
self.currency = frappe.defaults.get_global_default("currency")
frappe.throw("Error message") # general error
frappe.throw("Message", frappe.ValidationError) # with exception type
frappe.session.user # email of logged-in user
def before_save(self):
self.full_name = f"{self.first_name} {self.last_name}"
def on_submit(self):
frappe.get_doc(
doctype="Notification Log",
subject=f"Expense {self.name} approved"
).insert(ignore_permissions=True)
# Set a flag to skip validation in specific cases
doc.flags.ignore_validate = True
doc.save()
frappe.db.set_value for fields with validation logic. It bypasses validate(), 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()
frappe.db.commit() in controller methods or request handlers. See the Transactions section in database reference.# 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)