frappe-doctype-controller
Frappe DocType Controller
See frappe-doctype-schema for the .json schema side.
See frappe-doctype-form-view for the .js form controller.
See frappe-doctype-list-view for the _list.js list view controller.
See frappe-doctype-tests for writing DocType controller Python tests.
Full per-hook reference and pitfalls table: REFERENCE.md.
Quick start
import frappe
from frappe.model.document import Document
class MyDocType(Document):
def before_validate(self):
# Auto-fill fields before validation fires
if not self.status:
self.status = "Draft"
def validate(self):
if self.amount < 0:
frappe.throw(_("Amount cannot be negative"))
def on_update(self):
if self.is_new():
return # new-doc logic lives in after_insert
prev = self.get_doc_before_save()
if prev and self.has_value_changed("status"):
notify_status_change(self, prev.status)
Operation Flows
User-overridable hooks only, in call order. Internal framework steps marked [internal].
save()→insert()redirect: Callingdoc.save()on a new document (one with__islocalset or noname) silently redirects todoc.insert(). Both paths produce the same INSERT flow below. This means you can always calldoc.save()regardless of whether a document is new or existing.
INSERT — doc.insert() or doc.save() on a new document
before_insert
before_naming ← runs before any naming rule; set fields that naming depends on
autoname ← set self.name to assign a fully custom name (skipped if name already set)
before_validate ← preferred place for auto-filling fields
validate
before_save
[db_insert]
after_insert ← insert-only side effects (emails, child creation)
on_update ← fires on every write, including insert
on_change ← fires on every write operation
SAVE — doc.save()
before_validate
validate
before_save
[db_update]
on_update
on_change
SUBMIT — doc.submit()
before_validate
validate ← runs on submit too; guard with self._action if needed
before_submit
[db_update, docstatus=1]
on_update
on_submit
on_change
CANCEL — doc.cancel()
before_cancel ← before_validate is NOT called here
[db_update, docstatus=2]
on_cancel
on_change
UPDATE AFTER SUBMIT — save on a submitted doc
before_validate ← called, but validate is NOT
before_update_after_submit
[db_update]
on_update_after_submit
on_change
DELETE — frappe.delete_doc()
on_trash ← last chance to block or clean up before deletion
on_change
[db_delete]
after_delete ← post-delete cleanup (external systems, logs)
LOAD — form opened in browser
[load_from_db]
onload ← send extra data to client via self.set_onload(key, val)
RENAME — doc.rename()
[db_rename]
after_rename(old, new, merge)
Document API
Standard way to create and manipulate documents from Python code.
# Create and insert a new document
doc = frappe.new_doc("My DocType")
doc.field_one = "value"
doc.insert() # full insert flow + hooks
doc.insert(ignore_permissions=True) # skip permission check
doc.insert(ignore_mandatory=True) # skip mandatory field check
doc.insert(ignore_links=True) # skip Link field validation
doc.insert(ignore_if_duplicate=True) # silently skip if duplicate name
# Load and save an existing document
doc = frappe.get_doc("My DocType", name)
doc.field_one = "new value"
doc.save() # full save flow + hooks
doc.save(ignore_permissions=True)
# Submit / Cancel
doc.submit() # sets docstatus=1, runs submit flow
doc.cancel() # sets docstatus=2, runs cancel flow
# Delete
doc.delete() # triggers on_trash → after_delete
frappe.delete_doc("My DocType", name) # same, by name
frappe.delete_doc("My DocType", name, ignore_permissions=True)
frappe.delete_doc("My DocType", name, force=True) # skip back-link checks
# Rename
doc.rename("New Name") # triggers after_rename
See REFERENCE.md — Document API for the full parameter list of each method.
Bypassing Hooks
Sometimes you need to write a value to the DB without running the full save cycle (e.g., from within on_update itself, or in a migration script).
# doc.db_set — preferred bypass method
# Skips validate/before_save/on_update; still fires before_change and on_change
doc.db_set("status", "Approved")
doc.db_set({"status": "Approved", "approved_by": frappe.session.user})
doc.db_set("status", "Approved", update_modified=False) # don't touch modified timestamp
doc.db_set("status", "Approved", notify=True) # push realtime update to browser
doc.db_set("status", "Approved", commit=True) # immediate DB commit
# frappe.db.set_value — fully raw, zero hooks
# Use only in patches, migrations, or bulk updates where you explicitly don't want any hooks
frappe.db.set_value("My DocType", name, "status", "Approved")
frappe.db.set_value("My DocType", name, {"status": "Approved", "approved_by": user})
| Method | Hooks fired | Updates modified |
Use when |
|---|---|---|---|
doc.save() |
Full save cycle | Yes | Normal programmatic saves |
doc.db_set() |
before_change, on_change |
Yes (default) | Update one field from inside a hook or background job |
frappe.db.set_value() |
None | Yes (default) | Bulk updates, patches, migrations |
Warning: Never call
doc.save()from insideafter_insertordb_setfrom insidevalidate— both cause recursion or inconsistent state. Use the appropriate hook instead.
Context Guards
Gate logic to avoid double-fires and wrong-operation bugs:
self.is_new() # True only during insert, before first db_insert
self.flags.in_insert # True throughout the full insert operation
self._action # "save" | "submit" | "cancel" | "update_after_submit"
self.docstatus # 0=Draft 1=Submitted 2=Cancelled
self.flags.in_delete # True while being deleted
Common patterns:
def on_update(self):
if self.is_new():
return # handled in after_insert
def validate(self):
if self._action == "submit":
# stricter checks only on submission
...
Detecting Field Changes
get_doc_before_save() returns the pre-save DB state (None for new docs).
def on_update(self):
prev = self.get_doc_before_save()
if prev and self.has_value_changed("status"):
# self.status changed since last save
...
has_value_changed(fieldname) is a convenience wrapper for the same comparison.
Raising Errors
# Hard stop — aborts save, shows error dialog
frappe.throw(_("Error message"), exc=frappe.ValidationError)
# Soft — shows in UI but does not abort
frappe.msgprint(_("Warning: ..."), alert=True)
- Use
frappe.throw()insidevalidateandbefore_*hooks. - Never use bare
raise— it bypasses Frappe's error formatting. - Wrap user-facing strings in
_()for translation.
More from kehwar/skills
setup-workflow-skills
Sets up an `## Agent orientation` block in AGENTS.md/CLAUDE.md so the engineering skills know this repo uses Beads for issue tracking. Run before first use of `to-tasks`, `to-prd`, `tdd`, `improve-codebase-architecture`, or `zoom-out`.
12to-prd
Turn the current conversation context into a PRD and publish it to Beads Issue Tracker. Use when user wants to create a PRD from the current context.
11tdd
Test-driven development with red-green-refactor loop. Use when user wants to build features or fix bugs using TDD, mentions "red-green-refactor", wants integration tests, or asks for test-first development. Tracks progress in Beads Issue Tracker.
11write-a-skill
Create new agent skills with proper structure, progressive disclosure, and bundled resources. Use when user wants to create, write, or build a new skill.
9to-tasks
Break a plan, spec, or PRD into independently-grabbable tasks/issues on Beads Issue Tracker using tracer-bullet vertical slices. Use when user wants to convert a plan into tasks, create implementation tickets, or break down work into tasks.
9grill-me
Interview the user relentlessly about a plan or design until reaching shared understanding, resolving each branch of the decision tree. Use when user wants to stress-test a plan, get grilled on their design, or mentions "grill me".
8