Skip to content

v15 Server Only Stable Controllers & Lifecycle Events

Every DocType in Frappe Framework v15 is backed by a Python class inheriting from frappe.model.document.Document. This class acts as the business logic Controller.


1. Controller Class Structure

The Python file is located in the DocType directory: apps/<app_name>/<app_name>/<module>/doctype/<doctype_name>/<doctype_name>.py.

python
import frappe
from frappe import _
from frappe.model.document import Document

class CustomTask(Document):
    def validate(self):
        """Executes before saving or submitting."""
        if self.end_date and self.start_date and self.end_date < self.start_date:
            frappe.throw(_("End Date cannot be before Start Date"))

    def on_update(self):
        """Executes after database save."""
        self.sync_task_status_with_project()

    def sync_task_status_with_project(self):
        if self.project:
            frappe.db.set_value("Project", self.project, "last_updated", frappe.utils.now())

2. Complete Lifecycle Events Reference Matrix

Frappe provides 15+ controller lifecycle methods executed in exact chronological order:

Method NameTrigger PointAllowed OperationsOperations to AVOID
before_insertImmediately prior to first DB insertDefault field calculationsDatabase frappe.db.sql queries on unsaved name
before_namingPrior to primary key name resolutionAltering autoname parametersFetching self.name (not yet assigned)
autonameGenerating primary key self.nameCustom name string assignmentModifying docfield values
before_validatePrior to mandatory field checksData sanitization & trimmingThrowing hard validation exceptions
validateSave & Submit validation stepCalculations & throwing frappe.throw()Modifying sibling database records
before_saveImmediately prior to DB INSERT/UPDATEFinalizing doc attributesHeavy background network operations
after_insertRight after initial DB row insertionChild record generationModifying self attributes without db_set
on_updateRight after DB save commitTriggering real-time sockets & cache invalidationsCalling self.save() (causes infinite recursion!)
before_submitPrior to submission workflow checkValidation of submittable fieldsCancelling linked records
on_submitRight after submission (docstatus: 1)Stock movement, ledger entries, postingEditing non-submittable fields
before_cancelPrior to cancellation (docstatus: 2)Verification of linked vouchersDeleting primary records
on_cancelRight after cancellationReversing GL & Stock ledger entriesCalling doc.submit()
before_update_after_submitPrior to editing submittable doc fieldsValidating editable submittable fieldsChanging core document fields
on_update_after_submitAfter editing submittable doc fieldsAudit loggingRe-submitting document
before_trashPrior to document deletion processBlocking deletion of referenced recordsModifying document fields
after_deleteRight after database row deletionCleaning external file attachmentsDereferencing self in database
on_changeTriggers on save/submit/cancel state changeDispatching state notification webhooksCalling self.save()

Complete Lifecycle Code Example

python
import frappe
from frappe import _
from frappe.model.document import Document

class CustomTask(Document):
    def before_insert(self):
        """1. Executed before initial insert into database."""
        self.status = "Open"
        print("[LIFECYCLE] 1. before_insert: Set default status to Open")

    def validate(self):
        """2. Executed on every save/submit validation."""
        if not self.subject:
            frappe.throw(_("Subject is mandatory!"))
        print("[LIFECYCLE] 2. validate: Passed subject validation")

    def before_save(self):
        """3. Executed right before saving to DB."""
        self.subject = self.subject.strip()
        print("[LIFECYCLE] 3. before_save: Trimmed subject string")

    def on_update(self):
        """4. Executed right after DB save commit."""
        print(f"[LIFECYCLE] 4. on_update: Saved document {self.name}")

    def before_trash(self):
        """5. Executed before deletion starts."""
        if self.status == "Closed":
            frappe.throw(_("Cannot delete Closed tasks!"))
        print(f"[LIFECYCLE] 5. before_trash: Validated task deletion")

    def after_delete(self):
        """6. Executed after database record removal."""
        print(f"[LIFECYCLE] 6. after_delete: Cleaned up task {self.name}")

Expected Log Output on Save

text
[LIFECYCLE] 1. before_insert: Set default status to Open
[LIFECYCLE] 2. validate: Passed subject validation
[LIFECYCLE] 3. before_save: Trimmed subject string
[LIFECYCLE] 4. on_update: Saved document TASK-2026-00001

3. Controller Execution Best Practices & Anti-Patterns

❌ Never Call self.save() Inside on_update()

Calling self.save() inside on_update() triggers on_update() again, creating an infinite recursive loop resulting in Python RecursionError or server memory exhaustion.

python
# BAD (Infinite Recursion)
class Task(Document):
    def on_update(self):
        self.status = "Updated"
        self.save()  # ❌ NEVER DO THIS!

# GOOD
class Task(Document):
    def validate(self):
        self.status = "Updated"  # Set before save!

❌ Avoid External API Calls in validate()

HTTP requests to external APIs (e.g. Stripe, Slack) inside validate() will block web server threads, drastically degrading site responsiveness.

python
# GOOD: Offload heavy network calls to background jobs
class Task(Document):
    def on_update(self):
        if self.status == "Closed":
            frappe.enqueue(
                "my_app.tasks.notify_slack",
                task_name=self.name,
                queue="short"
            )

Frappe Framework v15 Complete Technical Reference & Handbook.