Skip to content

Python Controllers

Controllers are where you put your business logic. Every DocType can have an associated Python file that defines a class inheriting from frappe.model.document.Document. This class is your controller — it runs on the server whenever documents are created, modified, submitted, or deleted. If you come from Express, this is the equivalent of the route handler plus the model hooks, but Frappe wires it to the DocType for you by naming convention rather than explicit registration.

For a DocType named Franchise Outlet in the ScoopJoy app, the controller file lives alongside the DocType’s JSON definition:

  • Directoryscoopjoy/
    • Directoryscoopjoy/
      • Directoryfranchise_management/
        • Directorydoctype/
          • Directoryfranchise_outlet/
            • franchise_outlet.json the DocType definition
            • franchise_outlet.py the Python controller
            • franchise_outlet.js client-side controller
            • test_franchise_outlet.py unit tests

The basic structure is a class plus methods that Frappe calls automatically:

scoopjoy/scoopjoy/franchise_management/doctype/franchise_outlet/franchise_outlet.py
import frappe
from frappe.model.document import Document
class FranchiseOutlet(Document):
# The class name is the DocType name in PascalCase (spaces removed).
# It inherits from Document, which provides the ORM, hooks, and lifecycle.
def validate(self):
"""Runs before every save. Throw errors here to prevent saving."""
self.validate_city_code()
self.validate_rent()
def validate_city_code(self):
if self.city_code and len(self.city_code) != 3:
frappe.throw("City Code must be exactly 3 characters")
def validate_rent(self):
if self.monthly_rent and self.monthly_rent < 0:
frappe.throw("Monthly Rent cannot be negative")

This is the exact execution order of hooks during each operation. Understanding the order is critical for placing your logic in the right hook — drop validation into on_update and it runs too late to block a bad save.

In Express you’d chain middleware explicitly; in Frappe the lifecycle is fixed and you opt in by defining the named method.

Insert lifecycle
Rendering diagram…

On a plain save of an existing document, naming is skipped and before_save replaces the insert-only hooks:

Save lifecycle
Rendering diagram…

For submittable DocTypes, submit and cancel add their own hooks around the docstatus transition:

Submit and cancel lifecycle
Rendering diagram…

On delete, cleanup runs around the DELETE:

Delete lifecycle
on_trash() # Called before deletion — cleanup linked data
# ↓ DELETE FROM database
after_delete() # Post-deletion cleanup

Use before_submit for final preconditions, on_submit to create GL/stock entries, on_cancel to reverse them, and on_trash to block deletion of linked records.

Inside any hook, self is the Document instance. It gives you full access to the document’s data, child rows, metadata, and runtime flags:

franchise_outlet.py
class FranchiseOutlet(Document):
def validate(self):
# Access scalar fields directly
print(self.outlet_name) # "Scoops & Shakes - Andheri"
print(self.name) # "FO-MUM-0001" (set after autoname)
print(self.status) # "Active"
print(self.monthly_rent) # 85000.0
# Access child table rows (list of Document objects)
for item in self.equipment:
print(item.equipment_name) # "Gelato Display Freezer"
print(item.purchase_cost) # 125000.0
print(item.idx) # Row number (1-based)
print(item.parent) # "FO-MUM-0001"
print(item.parenttype) # "Franchise Outlet"
print(item.parentfield) # "equipment"
# Access metadata
print(self.meta.is_submittable) # 0
print(self.meta.get_field("status").options) # "\nActive\nInactive\n..."
# Check if the document is new (not yet saved)
print(self.is_new()) # True on first insert, False on update
# Check what changed (useful in before_save)
if self.has_value_changed("status"):
frappe.msgprint(f"Status changed to {self.status}")
# Access flags (runtime-only, not persisted)
if self.flags.ignore_validate:
return

Validation — frappe.throw() vs frappe.msgprint()

Section titled “Validation — frappe.throw() vs frappe.msgprint()”

The two ways to surface a problem differ in one crucial respect: one stops the save, the other doesn’t.

franchise_outlet.py
def validate(self):
# frappe.throw() — stops execution, prevents save, shows red error
if not self.manager:
frappe.throw(
msg="Outlet Manager is required for all franchise outlets",
title="Missing Manager",
exc=frappe.ValidationError, # optional, defaults to ValidationError
)
# frappe.msgprint() — shows a message but does NOT stop execution
if not self.pincode:
frappe.msgprint(
msg="Consider adding a pincode for better logistics tracking",
title="Suggestion",
indicator="orange", # blue, green, orange, red
)
  • frappe.throw() raises an exception, so the document is not saved.
  • frappe.msgprint() sends a message to the UI, and the document is saved normally.

Practical example: full controller for Franchise Outlet

Section titled “Practical example: full controller for Franchise Outlet”

Putting the hooks together, here is a realistic controller. Note autoname for custom naming, a validate that delegates to focused helper methods, after_insert to spin up a linked Warehouse, on_update to react to a manager change, and on_trash to guard deletion.

franchise_outlet.py
import frappe
from frappe import _
from frappe.model.document import Document
class FranchiseOutlet(Document):
def autoname(self):
"""Custom naming: FO-{CITY_CODE}-{serial}."""
# This overrides the autoname property in the JSON definition
if not self.city_code:
frappe.throw(_("City Code is required for naming"))
self.city_code = self.city_code.upper().strip()
def validate(self):
"""Main validation — runs before every save."""
self.validate_city_code()
self.validate_opening_date()
self.validate_equipment()
self.calculate_total_equipment_cost()
def validate_city_code(self):
if len(self.city_code) != 3:
frappe.throw(
_("City Code must be exactly 3 characters. Got: {0}").format(self.city_code)
)
def validate_opening_date(self):
if self.opening_date and self.status == "Active":
from frappe.utils import getdate, today
if getdate(self.opening_date) > getdate(today()):
frappe.msgprint(
_("Opening date is in the future. Status will remain as-is."),
indicator="orange",
)
def validate_equipment(self):
seen = set()
for row in self.equipment:
if row.equipment_name in seen:
frappe.throw(
_("Row {0}: Duplicate equipment '{1}'. Merge rows and update quantity instead.").format(
row.idx, row.equipment_name
)
)
seen.add(row.equipment_name)
if row.quantity < 1:
frappe.throw(_("Row {0}: Quantity must be at least 1").format(row.idx))
def calculate_total_equipment_cost(self):
"""Compute total — stored as a virtual-like pattern."""
self.total_equipment_cost = sum(
(row.purchase_cost or 0) * (row.quantity or 1)
for row in self.equipment
)
def after_insert(self):
"""Create a default warehouse for the new outlet."""
if not self.default_warehouse:
warehouse = create_outlet_warehouse(self)
# Use db.set_value to avoid re-triggering hooks
frappe.db.set_value(
"Franchise Outlet", self.name, "default_warehouse", warehouse.name
)
def on_update(self):
"""Called after every save (insert or update)."""
if self.has_value_changed("manager"):
frappe.msgprint(
_("Outlet manager changed to {0}").format(self.manager_name),
alert=True,
)
def on_trash(self):
"""Prevent deletion of active outlets."""
if self.status == "Active":
frappe.throw(_("Cannot delete an Active outlet. Set status to Closed first."))
def create_outlet_warehouse(outlet):
"""Create a Warehouse for a new Franchise Outlet."""
warehouse = frappe.new_doc("Warehouse")
warehouse.warehouse_name = f"{outlet.outlet_name} - Store"
warehouse.company = frappe.defaults.get_defaults().get("company")
warehouse.parent_warehouse = "Stores - SJ" # parent warehouse group
warehouse.franchise_outlet = outlet.name
warehouse.insert(ignore_permissions=True)
frappe.msgprint(
_("Warehouse '{0}' created for this outlet.").format(warehouse.name),
alert=True,
)
return warehouse

The @frappe.whitelist() decorator exposes a Python function as an HTTP endpoint at /api/method/<dotted.path>. This is Frappe’s equivalent of declaring an Express route — except the dotted module path is the URL, so there is no router to maintain.

franchise_outlet.py
@frappe.whitelist()
def calculate_royalty(outlet_name, month, year):
"""
Calculate franchise royalty fees for an outlet. Callable from client JS
or external systems via POST /api/method/scoopjoy.franchise_management.
doctype.franchise_outlet.franchise_outlet.calculate_royalty
"""
outlet = frappe.get_doc("Franchise Outlet", outlet_name)
# Get total sales for the month
total_sales = frappe.db.sql("""
SELECT IFNULL(SUM(grand_total), 0) as total
FROM `tabSales Invoice`
WHERE franchise_outlet = %s
AND MONTH(posting_date) = %s
AND YEAR(posting_date) = %s
AND docstatus = 1
""", (outlet_name, month, year), as_dict=True)[0].total
# Royalty tiers (Indian franchise model)
if total_sales <= 500000: # Up to 5 lakh
royalty_rate = 0.05
elif total_sales <= 1500000: # 5-15 lakh
royalty_rate = 0.07
else: # Above 15 lakh
royalty_rate = 0.10
return {
"outlet_name": outlet.outlet_name,
"month": month,
"year": year,
"total_sales": total_sales,
"royalty_rate": royalty_rate * 100,
"royalty_amount": total_sales * royalty_rate,
}

Calling this from the client side uses frappe.call with the same dotted path:

franchise_outlet.js
frappe.call({
method: 'scoopjoy.franchise_management.doctype.franchise_outlet.franchise_outlet.calculate_royalty',
args: {
outlet_name: frm.doc.name,
month: frappe.datetime.str_to_obj(frappe.datetime.now_date()).getMonth() + 1,
year: frappe.datetime.str_to_obj(frappe.datetime.now_date()).getFullYear(),
},
callback: function(r) {
if (r.message) {
frappe.msgprint(
`Royalty for ${r.message.outlet_name}: ₹${r.message.royalty_amount.toLocaleString('en-IN')}`
);
}
}
});

You can also whitelist methods directly on the controller class. These are called with the document’s name passed automatically:

franchise_outlet.py
class FranchiseOutlet(Document):
@frappe.whitelist()
def get_equipment_summary(self):
"""Whitelisted instance method — called via frappe.call on the doc."""
summary = {}
for row in self.equipment:
category = row.equipment_type or "Other"
summary.setdefault(category, {"count": 0, "cost": 0})
summary[category]["count"] += row.quantity
summary[category]["cost"] += (row.purchase_cost or 0) * row.quantity
return summary

Call it from the client with frm.call, which passes the doc name for you:

franchise_outlet.js
frm.call('get_equipment_summary').then(r => {
console.log(r.message);
});

Pass allow_guest=True to expose an unauthenticated public endpoint — useful for a store-locator page:

franchise_outlet.py
@frappe.whitelist(allow_guest=True)
def get_outlet_locations():
"""Public API — no authentication required."""
return frappe.get_all(
"Franchise Outlet",
filters={"status": "Active"},
fields=["outlet_name", "city", "state", "geolocation"],
)

Long-running operations should never block the web request. Use frappe.enqueue() to offload work to background workers (powered by Python RQ and Redis) — the same idea as pushing a job onto a BullMQ queue in Node.js.

franchise_outlet.py
@frappe.whitelist()
def bulk_update_compliance_status():
"""
Enqueue a background job to check compliance for all outlets.
This might take minutes — checking licenses, FSSAI, GST, etc.
"""
frappe.enqueue(
method="scoopjoy.franchise_management.doctype.franchise_outlet.franchise_outlet._run_compliance_check",
queue="long", # short (5min), default (5min), long (25min)
timeout=1200, # 20 minutes max
job_name="Franchise Compliance Check",
enqueue_after_commit=True, # wait until current transaction commits
)
frappe.msgprint(
"Compliance check started in the background. You will be notified when complete.",
alert=True,
)
def _run_compliance_check():
"""
Background job function. Runs in a separate worker process.
Must NOT be decorated with @frappe.whitelist() — it's not an API endpoint.
"""
outlets = frappe.get_all(
"Franchise Outlet",
filters={"status": "Active"},
fields=["name", "outlet_name"],
)
failed = []
for outlet in outlets:
try:
is_compliant = check_outlet_compliance(outlet.name)
frappe.db.set_value(
"Franchise Outlet", outlet.name,
"compliance_status",
"Compliant" if is_compliant else "Non-Compliant",
)
except Exception:
failed.append(outlet.outlet_name)
frappe.log_error(title=f"Compliance check failed: {outlet.name}")
# Commit changes (background jobs do NOT auto-commit)
frappe.db.commit()
# Notify the user who triggered the job
frappe.publish_realtime(
event="msgprint",
message=f"Compliance check complete. {len(outlets) - len(failed)} passed, {len(failed)} failed.",
user=frappe.session.user,
)
def check_outlet_compliance(outlet_name):
"""Check if outlet has valid FSSAI, GST, and trade license."""
licenses = frappe.get_all(
"Outlet License",
filters={"outlet": outlet_name, "status": "Valid"},
fields=["license_type"],
)
required = {"FSSAI", "GST", "Trade License"}
held = {lic.license_type for lic in licenses}
return required.issubset(held)
QueueDefault TimeoutUse Case
short300s (5 min)Quick tasks: sending one email, updating a few records
default300s (5 min)Standard background work
long1500s (25 min)Heavy processing: bulk updates, report generation, data import

To enqueue a method on a specific document, use frappe.enqueue_doc():

# Enqueue a specific method on a specific document
frappe.enqueue_doc(
"Franchise Outlet", # doctype
"FO-MUM-0001", # document name
"generate_monthly_report", # method name on the controller class
queue="long",
month=3,
year=2026,
)

The frappe.db namespace is your lower-level data access layer — use it when you don’t need a full document load and its hooks. (For the full ORM surface — get_doc, get_all, filter operators, raw SQL — see DocTypes — Data Modeling.)

franchise_outlet.py
# Check existence
exists = frappe.db.exists("Franchise Outlet", "FO-MUM-0001")
exists = frappe.db.exists("Franchise Outlet", {"outlet_name": "Scoops & Shakes - Andheri"})
# Get single value
rent = frappe.db.get_value("Franchise Outlet", "FO-MUM-0001", "monthly_rent")
# Get multiple values as dict
vals = frappe.db.get_value(
"Franchise Outlet", "FO-MUM-0001",
["outlet_name", "monthly_rent", "status"],
as_dict=True,
)
# Set value directly (bypasses controller hooks)
frappe.db.set_value("Franchise Outlet", "FO-MUM-0001", "status", "Closed")
# Count documents
count = frappe.db.count("Franchise Outlet", {"status": "Active", "city": "Mumbai"})
# Raw SQL with parameterized queries
results = frappe.db.sql(
"SELECT city, COUNT(*) as cnt FROM `tabFranchise Outlet` WHERE status=%s GROUP BY city",
("Active",),
as_dict=True,
)

This is the single biggest gotcha when moving logic between a controller and a background job: who commits the transaction.

franchise_outlet.py
# IN CONTROLLERS: Do NOT call frappe.db.commit().
# Frappe auto-commits at the end of the request lifecycle.
class FranchiseOutlet(Document):
def on_update(self):
# This is fine — no explicit commit needed
frappe.db.set_value("Warehouse", self.default_warehouse, "disabled", 0)
# IN BACKGROUND JOBS: You MUST call frappe.db.commit().
# Background workers do not auto-commit.
def _run_compliance_check():
for outlet in outlets:
frappe.db.set_value(...)
frappe.db.commit() # Required! Otherwise changes are lost.
# IN WHITELISTED API METHODS: Usually not needed.
# Frappe commits after the API method returns. Only commit if you
# need mid-function durability between batches.
@frappe.whitelist()
def long_running_api():
for item in batch_1:
process(item)
frappe.db.commit() # Commit batch 1 before processing batch 2
for item in batch_2:
process(item)
# Auto-committed at the end

run_method() invokes a hook by name on a document — and crucially, it also fires any doc_events other apps registered for that method:

# Run a method on a document dynamically
outlet = frappe.get_doc("Franchise Outlet", "FO-MUM-0001")
outlet.run_method("validate") # Runs the validate hook
# This also triggers hooks.py doc_events for this method, so if another
# app has hooked into Franchise Outlet's validate, it will also run.

Flags are runtime-only attributes that are not persisted to the database. Use them to pass context between hooks or to conditionally skip logic — for example, relaxing a rule during a bulk import:

franchise_outlet.py
class FranchiseOutlet(Document):
def validate(self):
if not self.flags.skip_rent_validation:
self.validate_rent()
def validate_rent(self):
if self.monthly_rent and self.monthly_rent < 10000:
frappe.throw("Monthly rent seems too low. Verify the amount.")
# Usage: skip rent validation during bulk import
doc = frappe.get_doc("Franchise Outlet", "FO-MUM-0001")
doc.flags.skip_rent_validation = True
doc.monthly_rent = 5000 # normally would fail validation
doc.save()

Common built-in flags:

FlagPurpose
flags.ignore_permissionsSkip permission checks on save/submit/cancel
flags.ignore_validateSkip the validate hook entirely
flags.ignore_mandatorySkip mandatory field checks
flags.ignore_linksSkip Link field validation
flags.in_migrateSet during bench migrate

Wrap secondary work in a try/except and log the traceback instead of crashing the primary operation. frappe.log_error() writes to the Error Log DocType:

franchise_outlet.py
def after_insert(self):
try:
warehouse = create_outlet_warehouse(self)
frappe.db.set_value(
"Franchise Outlet", self.name, "default_warehouse", warehouse.name
)
except Exception:
# Log the full traceback to the Error Log DocType. The message
# parameter is optional — if omitted, the current traceback is
# captured automatically via frappe.get_traceback().
frappe.log_error(title=f"Failed to create warehouse for {self.name}")
# Don't re-raise — the outlet was created successfully;
# warehouse creation is secondary.
frappe.msgprint(
"Outlet created but warehouse could not be auto-created. "
"Please create one manually.",
indicator="orange",
)

View logged errors at Desk > Error Log or via the API at GET /api/resource/Error Log. Old entries are automatically cleaned up by a scheduled job.

Frappe v16 introduces the extend_doctype_class hook, the recommended approach for multi-app scenarios. The older override_doctype_class hook still works but only allows one app to override a given DocType, causing last-writer-wins conflicts when multiple apps target the same DocType. Multiple apps using extend_doctype_class can safely extend the same DocType without conflicts.

scoopjoy/hooks.py
extend_doctype_class = {
"Sales Invoice": "scoopjoy.overrides.sales_invoice.CustomSalesInvoice",
}
scoopjoy/overrides/sales_invoice.py
import frappe
from frappe import _
class CustomSalesInvoice:
"""
Mixin that extends Sales Invoice. Methods here are added to the
SalesInvoice class. You can override existing methods or add new ones.
"""
def validate(self):
# Call the original validate first
super().validate()
# Then add your custom validation
self.validate_franchise_outlet()
def validate_franchise_outlet(self):
"""Ensure franchise outlet is set for franchise sales."""
if self.franchise_outlet and not frappe.db.exists("Franchise Outlet", self.franchise_outlet):
frappe.throw(_("Franchise Outlet {0} does not exist").format(self.franchise_outlet))

This is safer than the older override_doctype_class hook because:

  • Multiple apps can extend the same DocType without last-writer-wins conflicts.
  • No risk of one app overwriting another app’s customizations.
  • super() chains correctly through all extensions.

For simpler cases where you just want to hook into a standard DocType’s lifecycle without extending its class, register doc_events — this is closest to Express middleware: a function (doc, method) that runs on a named event.

scoopjoy/hooks.py
doc_events = {
"Sales Invoice": {
"validate": "scoopjoy.overrides.sales_invoice_hooks.validate_franchise_fields",
"on_submit": "scoopjoy.overrides.sales_invoice_hooks.create_franchise_royalty_entry",
},
"Customer": {
"after_insert": "scoopjoy.overrides.customer_hooks.link_to_franchise_outlet",
},
# Wildcard — runs for ALL DocTypes
"*": {
"on_update": "scoopjoy.overrides.common.log_all_changes",
},
}
scoopjoy/overrides/sales_invoice_hooks.py
import frappe
from frappe import _
def validate_franchise_fields(doc, method):
"""
Hook function for Sales Invoice validate.
`doc` is the Document instance, `method` is the hook name ("validate").
"""
if doc.franchise_outlet:
outlet = frappe.get_doc("Franchise Outlet", doc.franchise_outlet)
if outlet.status != "Active":
frappe.throw(
_("Cannot create invoice for inactive outlet: {0}").format(outlet.outlet_name)
)
def create_franchise_royalty_entry(doc, method):
"""On submit, create a royalty accrual entry."""
if not doc.franchise_outlet:
return
royalty_rate = get_royalty_rate(doc.franchise_outlet)
if royalty_rate:
je = frappe.new_doc("Journal Entry")
je.posting_date = doc.posting_date
je.franchise_outlet = doc.franchise_outlet
je.append("accounts", {
"account": "Franchise Royalty Income - SJ",
"credit_in_account_currency": doc.grand_total * royalty_rate,
})
je.append("accounts", {
"account": "Franchise Royalty Receivable - SJ",
"debit_in_account_currency": doc.grand_total * royalty_rate,
})
je.insert(ignore_permissions=True)
je.submit()
NeedHook / MethodNotes
Set the document nameautoname()Runs only on first insert
Validate data before savevalidate()Use frappe.throw() to block save
Compute derived valuesbefore_save()Set fields that depend on other fields
Create linked documents after insertafter_insert()Warehouse, default settings, etc.
React to any save (insert or update)on_update()Notifications, cache invalidation
Lock data on submissionbefore_submit()Final checks before locking
Create accounting/stock entrieson_submit()GL entries, stock ledger entries
Reverse entries on cancellationon_cancel()Undo what on_submit did
Cleanup before deletionon_trash()Prevent deletion of linked records
Expose an API endpoint@frappe.whitelist()Module-level or class method
Run heavy work in backgroundfrappe.enqueue()Use for bulk operations, reports
Extend standard DocType behaviorextend_doctype_class (v16)hooks.py — safe multi-app extension
Hook into standard DocType eventsdoc_eventshooks.py — simpler than class extension