Notes on Frappe internals
A working tour of how the framework wires models, hooks, and the desk.
Frappe is easy to use and hard to picture. You define a DocType in the UI, a folder appears on disk, and somehow a form, a REST endpoint, a permission model, and a database table all exist. That gap between what you did and what happened is where most of the confusion lives.
These are the pieces I wish someone had drawn for me in the first month.
A DocType is three things at once
It’s a row in the tabDocType table describing fields and permissions. It’s a physical table, tabSales Order, whose columns come from those field definitions. And it’s a Python class in sales_order.py that inherits from Document.
When you call frappe.get_doc("Sales Order", name), the framework looks up the schema, finds the controller module by convention from the DocType’s module and name, and hands you an instance of that class with the row loaded into attributes. There’s no registry you maintain. The naming convention is the wiring.
This is also why a field you added in the UI is immediately available as doc.some_field with no code change, and why renaming a DocType is a heavier operation than it looks; it moves a table, rewrites the schema row, and changes the path the controller is loaded from.
The lifecycle is a fixed sequence
doc.save() isn’t a database call with hooks bolted on. It’s a defined sequence, and nearly every mystery about “why didn’t my code run” is a question about where you sit in it.
class SalesOrder(Document):
def validate(self):
# runs on every save, insert or update
self.set_missing_values()
def on_update(self):
# runs after the row is written
self.update_linked_delivery_notes()
def on_submit(self):
# runs once, when docstatus goes 0 -> 1
self.make_gl_entries()
Validation runs before the write; on_update and on_submit run after it. Submission is a state change on docstatus, not a separate table: 0 is draft, 1 submitted, 2 cancelled. A submitted document can’t be edited except through explicitly allowed fields, which is enforced in the framework, not in your controller.
The corollary matters more than the sequence itself: anything that bypasses save() bypasses all of it.
frappe.db is a different layer
frappe.db.set_value("Sales Order", name, "status", "Closed") writes to the table. It does not load the controller, run validation, fire on_update, or create a version record. That’s a feature when you need it and a landmine when you don’t.
Half the “the hook didn’t fire” tickets I’ve looked at come down to something upstream reaching for frappe.db.set_value because it was faster, and everything downstream assuming a save happened. When you’re reading unfamiliar code, the distinction between the ORM path and the frappe.db path is the first thing worth establishing.
Query building has the same split. frappe.qb is the builder, frappe.db.sql is the escape hatch, and both sit under the permission model rather than inside it; a raw query does not check permissions for you.
hooks.py is the extension surface
An app extends other apps through hooks.py, which is read at boot and merged across every installed app. doc_events is the one you reach for most:
doc_events = {
"Sales Order": {
"on_submit": "my_app.overrides.sales_order.on_submit",
},
}
That dotted string is imported and called with the document. It runs in addition to the controller’s own method, and the order across apps is the order apps are installed, which is worth remembering before you make two apps hook the same event and depend on which one wins.
The same file wires scheduled jobs, permission query conditions, override classes, and the assets the desk loads. Reading an unfamiliar app’s hooks.py first will tell you more about what it does than reading its DocTypes.
The desk is a client of the same API
The form you see in the desk is JavaScript rendering the same metadata your Python reads. frappe.ui.form.on("Sales Order", {...}) attaches client scripts; a method decorated with @frappe.whitelist() is callable from that client at /api/method/<dotted.path>, subject to permissions.
So the desk isn’t a special case with private access. It’s the first consumer of the API you’d use from anywhere else, which is why almost anything the UI can do is reachable from a script.
Where caching bites
Metadata is cached aggressively in Redis; frappe.get_meta doesn’t hit the database every call. That’s what makes the framework fast and what makes stale-schema confusion so common. If code is reading a field that exists in the DocType but not in your process’s idea of it, you’re usually looking at a cache that hasn’t been cleared, or a bench migrate that hasn’t run on that site.
Per-request state lives on frappe.local, which is where frappe.session, frappe.db, and the current site’s config hang. frappe.flags is the informal channel code uses to tell downstream hooks that something unusual is happening; grep for it before assuming a hook always behaves the same way.
None of this is deep magic once you’ve seen the shape of it. It’s a small number of conventions applied consistently, which is why the framework feels large and reads small.