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 make a DocType in the UI, a folder shows up on disk, and somehow you now have a form, a REST endpoint, permissions, and a database table. What you did and what happened feel miles apart, and that’s where most of my early confusion came from.
These are the pieces I wish someone had drawn for me in my first month.
A DocType is three things
It’s a row in tabDocType that describes the fields and permissions. It’s a real table, like tabSales Order, with columns built from those fields. And it’s a Python class in sales_order.py that extends Document.
When you call frappe.get_doc("Sales Order", name), Frappe looks up the schema, finds the controller from the module and DocType name, and hands you an instance of that class with the row loaded in. There’s no registry you have to maintain. The naming convention is the wiring.
That’s why a field you add in the UI shows up as doc.some_field with no code change. It’s also why renaming a DocType is heavier than it looks: it renames a table, rewrites the schema row, and changes where the controller gets loaded from.
save() runs in a fixed order
doc.save() isn’t a DB write with some hooks stuck on. It’s a fixed sequence, and almost every “why didn’t my code run?” comes down to where you are 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()
validate runs before the write; on_update and on_submit run after. Submitting isn’t a separate table, it’s the docstatus field: 0 draft, 1 submitted, 2 cancelled. Once submitted, you can only edit fields marked “allow on submit”, and the framework enforces that, not your controller.
The part that matters most: anything that skips save() skips all of this.
frappe.db is a different layer
frappe.db.set_value("Sales Order", name, "status", "Closed") writes straight to the table. No controller, no validate, no on_update, no version entry. Great when you need it. A landmine when you don’t.
A lot of the “hook didn’t fire” tickets I’ve seen come down to this. Something upstream used frappe.db.set_value because it was quicker, and everything downstream assumed a save happened. When I’m reading code I don’t know, figuring out which path it uses is one of the first things I check.
Queries split the same way. frappe.qb is the query builder, frappe.db.sql is the escape hatch, and neither checks permissions for you.
hooks.py is how apps extend each other
An app plugs into other apps through hooks.py, which is read at boot and merged across every installed app. The one I use most is doc_events:
doc_events = {
"Sales Order": {
"on_submit": "my_app.overrides.sales_order.on_submit",
},
}
That dotted path gets imported and called with the document, in addition to the controller’s own method. If two apps hook the same event, they run in install order, so don’t build anything that depends on who goes first.
The same file wires scheduled jobs, permission query conditions, class overrides, and the assets the desk loads. Honestly, reading an app’s hooks.py first tells you more about what it does than reading its DocTypes.
The desk uses the same API you do
The form in the desk is JavaScript reading the same metadata your Python reads. frappe.ui.form.on("Sales Order", {...}) attaches client scripts, and any method with @frappe.whitelist() is callable at /api/method/<dotted.path>, with permission checks.
So the desk has no secret backdoor. It’s the first user of the same API you’d call from anywhere else, which is why almost anything the UI does, a script can do too.
Where caching bites
Metadata is cached in Redis, so frappe.get_meta doesn’t hit the database every time. That’s a big part of why Frappe is fast, and also why stale schema confusion is so common. If code can’t see a field that does exist on the DocType, it’s usually a cache that wasn’t cleared or a bench migrate that didn’t run on that site.
Per request state lives on frappe.local; that’s where frappe.session, frappe.db, and the site config hang. frappe.flags is how code quietly tells later hooks that something unusual is going on, so grep for it before assuming a hook always behaves the same way.
None of this is magic once you see the shape. It’s a handful of conventions used everywhere, which is why Frappe feels huge and reads small.