Frappe Framework Core Reference
Usage
Use this skill when:
- Writing or reviewing DocType controllers, whitelisted API methods, or hooks.py wiring
- Database access decisions (
get_listvsget_all, query builder vs raw SQL) - Permission enforcement, background jobs, caching, desk client scripts
- Structuring a custom app that extends other apps' DocTypes
- Debugging Jinja/print-format errors → see references/jinja-and-print-formats.md
- Validating a hand-written DocType JSON before migrate → run
scripts/validate_doctype_json.py <file>(bundled in this skill; exit 4 = errors)
Document Lifecycle Hooks
class MyDocType(Document):
def before_insert(self): ... # before first save
def after_insert(self): ... # after first save
def validate(self): ... # before save (insert/update)
def before_save / on_update(self): ...
def before_submit / on_submit(self): ...
def before_cancel / on_cancel(self): ...
def on_trash(self): ... # before delete
API Development
Two surfaces:
| Shape | URL | Use for |
|---|---|---|
| REST | /api/resource/<DocType> (v1) or /api/v2/document/<DocType> (v15+) |
Standard CRUD |
| RPC | /api/method/<dotted.path> |
Custom server logic |
| Webhook | Webhook DocType (configured in UI) | Notify external systems |
Whitelisted methods
@frappe.whitelist()
def get_balance(customer):
frappe.has_permission("Customer", "read", doc=customer, throw=True)
return frappe.db.get_value("Customer", customer, "outstanding_amount")
@frappe.whitelist(methods=["POST"])
def create_payment(customer, amount): ...
@frappe.whitelist(allow_guest=True)
def public_status(): return {"status": "ok"}
Decorator options: methods=[...] (restrict HTTP verbs), allow_guest=True (never on state changes), xss_safe=True (skip XSS escape).
Response shapes: RPC returns {"message": <value>}, REST returns {"data": ...}. frappe.throw() returns HTTP 417 with the message in _server_messages.
REST CRUD
Always send Accept: application/json (without it, Frappe may return HTML). List query params: fields, filters, or_filters, order_by, limit_start, limit_page_length (or limit alias on v15+). Filter operators: =, !=, >, <, >=, <=, like, not like, in, not in, is set, is not set, between.
filters = [
["status", "in", ["Open", "Pending"]],
["date", "between", ["2024-01-01", "2024-12-31"]],
]
Authentication
headers = {"Authorization": "token api_key:api_secret", "Accept": "application/json"}
Generate keys: User → Settings → API Access → Generate Keys (shown once). Create a dedicated API user per integration; never use Administrator's key.
File upload
requests.post(f"{base_url}/api/method/upload_file",
files={"file": ("doc.pdf", open("doc.pdf", "rb"), "application/pdf")},
data={"doctype": "Customer", "docname": "CUST-001", "is_private": 1},
headers={"Authorization": "token ..."})
# Do NOT set Content-Type — requests adds the multipart boundary itself.
Client-side calls (Desk JavaScript)
frappe.xcall("method", args)— preferred, async/awaitfrappe.call({ method, args, freeze, freeze_message })— promise/callbackfrm.call("server_method")— sends current form values to a doc method- Gotcha:
frm.callwrites form values back; if the server method writes a Password field, the round-trip clobbers it. Usefrappe.xcallinstead.
- Gotcha:
Webhooks send X-Frappe-Webhook-Signature = base64(HMAC-SHA256(payload, secret)). Always set a secret and verify on the receiving side.
Log every outbound request via Integration Request
ALL outbound HTTP calls to third parties (payment gateways, signing providers, e-invoicing access points, custom webhooks) SHOULD be logged via the Integration Request DocType. Same for inbound webhook receipts. This gives you: audit trail, retry path, debugging UI in /app/integration-request, and proof-of-attempt for compliance.
from frappe.integrations.utils import create_request_log
# 1. Create the log BEFORE making the call
req = create_request_log(
data=payload, # dict — auto-serialized to JSON
service_name="MyProvider", # shows up as the column in the list view
request_headers=headers,
url=endpoint,
request_description="Submit invoice",
is_remote_request=1, # marks this as outbound (vs. notification)
reference_doctype="Sales Invoice",
reference_docname=invoice.name,
)
# 2. Make the call
try:
response = requests.post(endpoint, json=payload, headers=headers, timeout=30)
response.raise_for_status()
req.handle_success(response.json()) # status="Completed", output=<response JSON>
except Exception as e:
req.handle_failure({"error": str(e)}) # status="Failed", error=<error JSON>
raise
Statuses: Queued → Authorized → Completed / Failed / Cancelled. Use req.update_status(params, status) to merge new params into data and bump status.
reference_doctype+reference_docnameare mandatory — they connect the log to the business record (so you can find "what did we send to the payment gateway for SI-00001?").- Auto-cleanup: rows older than 30 days are deleted by the scheduled
clear_old_logsjob. Don't depend on Integration Request as long-term storage; copy critical fields onto the reference doc if you need them retained. - Retry pattern: a retry button on the reference doc re-reads
req.data, re-POSTs, and creates a fresh Integration Request (don't mutate the failed one — keep the audit trail). - Inbound webhooks: log them too — store the raw payload + signature verification result. If the provider replays, you have proof.
Database Operations
doc = frappe.get_doc("DocType", name)
docs = frappe.get_all("DocType", filters={"status": "Open"}, fields=["name"])
value = frappe.db.get_value("DocType", name, "field")
frappe.db.set_value("DocType", name, "field", value)
doc.db_set("field", value)
frappe.db.exists("DocType", name)
# Tuple filter operators
frappe.db.get_value("DocType", {"status": ("!=", "Cancelled")}, ["name"], as_dict=True)
frappe.get_all("DocType", filters={"date": ["between", [start, end]]})
get_list vs get_all
| Method | User Permissions | Permission Query Hook | Use for |
|---|---|---|---|
frappe.get_list() |
Applied | Applied | User-facing queries |
frappe.get_all() |
Ignored | Ignored | System/background queries |
Use get_list() for anything returned to a user. get_all() bypasses ALL permission filtering — fine for jobs, dangerous in whitelisted endpoints.
Query Builder (frappe.qb)
Preferred over frappe.db.sql() for new code — parameterized by default.
Task = frappe.qb.DocType("Task")
Customer = frappe.qb.DocType("Customer")
from frappe.query_builder.functions import Count, Sum
(frappe.qb.from_(Task)
.inner_join(Customer).on(Task.customer == Customer.name)
.select(Task.status, Count(Task.name).as_("n"))
.where((Task.status == "Open") | (Task.priority == "High")) # OR uses `|`, not `or`
.groupby(Task.status)
).run(as_dict=True)
# Inspect SQL without executing
query.get_sql() # SQL string
query.walk() # (SQL, params)
SQL safety
frappe.db.sql("SELECT ... WHERE name = %(name)s", {"name": user_input}) # safe
where = f"`tabCustomer`.owner = {frappe.db.escape(user)}" # escape dynamic fragments
Never f-string user input into raw SQL.
Client Scripts (Desk)
frappe.ui.form.on("DocType", {
refresh(frm) {
frm.add_custom_button(__("Action"), () => {
frappe.call({ method: "app.module.api", args: { name: frm.doc.name },
freeze: true, callback: r => frm.reload_doc() });
}, __("Actions"));
},
field_name(frm) {
frm.set_value("other_field", frm.doc.field_name * 2);
}
});
// Child table events
frappe.ui.form.on("Child DocType", {
qty(frm, cdt, cdn) {
const row = locals[cdt][cdn];
frappe.model.set_value(cdt, cdn, "amount", row.qty * row.rate);
}
});
Client-script gotchas
onloaddoesn't always fire on hard reload — lazy-fetch insiderefreshinstead.add_custom_buttonis NOT idempotent —frm.remove_custom_button(label, group)first if the handler can fire multiple times (refresh + field-change both trigger).frappe.callignores theerror:callback — that key is silently dropped. Usealways:for success-or-failure cleanup.__()inside JS template literals is invisible tobench get-untranslated— extract to a const first:const label = __("Label")then`<th>${label}</th>`.frm.set_value(...).then(...)— only returns a Promise on v15+; v14 returnsundefinedand.then()silently never runs.
hooks.py
after_install = "myapp.install.after_install"
after_migrate = "myapp.install.after_migrate"
doctype_js = { "Sales Invoice": "public/js/sales_invoice.js" }
doc_events = { "Sales Invoice": { "validate": "myapp.mymodule.custom.sales_invoice.validate" } }
scheduler_events = {
"hourly": ["myapp.tasks.hourly_task"],
"cron": { "0 9 * * *": ["myapp.tasks.morning_task"] },
}
override_doctype_class = { "Sales Invoice": "myapp.overrides.CustomSalesInvoice" }
Permissions
Five layers: Role → User Permission → Perm Level → has_permission hook → Data Masking (v16+).
Check permission
frappe.has_permission("Sales Order", "write", doc=doc, throw=True)
doc.has_permission("write") # bool
doc.check_permission("write") # raises frappe.PermissionError
# Debug evaluation steps
frappe.has_permission("Sales Order", "read", debug=True)
print(frappe.local.permission_debug_log)
has_permission hook — deny-only
Can only deny; returning True does NOT grant. Return None to let standard checks proceed.
# hooks.py
has_permission = { "Sales Order": "myapp.permissions.check_order" }
def check_order(doc, ptype, user):
if ptype == "write" and doc.docstatus == 2:
if "Sales Manager" not in frappe.get_roles(user):
return False
return None
permission_query_conditions — row-level filter
Affects frappe.get_list() only. Returns a SQL WHERE fragment. Always frappe.db.escape() user input.
permission_query_conditions = { "Customer": "myapp.permissions.customer_query" }
def customer_query(user):
user = user or frappe.session.user
if "Sales Manager" in frappe.get_roles(user):
return ""
return f"`tabCustomer`.owner = {frappe.db.escape(user)}"
Bypass — last resort
doc.flags.ignore_permissions = True # always comment WHY
Built-in roles: Guest (anonymous), All (any auth user), Administrator (always passes).
Background Jobs & Scheduler
scheduler_events vs frappe.enqueue
scheduler_events (hooks.py) |
frappe.enqueue() |
|
|---|---|---|
| Triggered by | Time/interval | Code |
| Arguments | NONE (parameterless) | Any serializable |
| Queue control | *_long event suffix |
queue= parameter |
Run bench migrate after editing scheduler_events in hooks.py.
Scheduler event keys
| Key | Queue | For |
|---|---|---|
all |
short (NEVER >60s) | Every tick |
hourly / daily / weekly / monthly |
short | Tasks <5 min |
hourly_long / daily_long / … |
long | Tasks 5-25 min |
cron: { "0 9 * * *": [...] } |
short | Custom schedule |
frappe.enqueue pattern
from frappe.utils.background_jobs import is_job_enqueued
@frappe.whitelist()
def process(doctype, filters):
job_id = f"process_{doctype}_{frappe.session.user}"
if is_job_enqueued(job_id):
return {"message": "Already in progress"}
frappe.enqueue("myapp.tasks.process_batch",
queue="long", # ALWAYS explicit
timeout=1800,
job_id=job_id,
enqueue_after_commit=True, # wait until current txn commits
doctype=doctype, filters=filters)
Queue defaults: short 300s · default 300s · long 1500s.
bench --site <site> execute myapp.tasks.daily_cleanup # run direct
bench --site <site> show-pending-jobs
bench --site <site> doctor
Caching
# Document cache — DO NOT mutate the returned doc (shared reference)
settings = frappe.get_cached_doc("System Settings")
frappe.clear_document_cache("Item", "ITEM-001")
# Redis (auto-prefixed with site name)
frappe.cache.set_value("key", data, expires_in_sec=300)
frappe.cache.delete_keys("item_price*")
frappe.cache.hset("user|perms", "u@x.com", perms) # also hget/hdel/hgetall
# Function memoization
from frappe.utils.caching import redis_cache
@redis_cache(ttl=300)
def get_rate(from_ccy, to_ccy): ...
get_rate.clear_cache()
# Rules: args must be hashable (no dicts/lists); never decorate side-effecting fns.
# Per-request memo — plain dict, lives one HTTP request
if "user_settings" not in frappe.local.cache:
frappe.local.cache["user_settings"] = frappe.get_doc("User Settings", frappe.session.user)
Print Formats & Jinja Templates
Two production traps live here — the frappe.utils.X sandbox namespace trap (silent None resolution in Jinja) and the Print Format JSON-to-DB sync-skip. Full reference with fixes: references/jinja-and-print-formats.md
Custom App Development Patterns
Recommended structure
myapp/
├── myapp/
│ ├── hooks.py
│ ├── install.py # custom fields, master data
│ ├── mymodule/
│ │ ├── setup.py # alternate: module-local custom fields
│ │ ├── custom/ # hook handlers for OTHER apps' DocTypes
│ │ │ └── sales_invoice.py
│ │ └── doctype/ # this app's own DocTypes
│ ├── shared/ # cross-module utilities
│ └── public/js/
│ ├── sales_invoice.js # client scripts for existing DocTypes
│ └── customer.js
└── pyproject.toml
install.py for custom fields
Define fields in a single get_custom_fields() dict, wire into BOTH after_install and after_migrate for idempotency:
from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
def after_install():
create_custom_fields(get_custom_fields())
setup_master_data()
def after_migrate():
create_custom_fields(get_custom_fields())
def get_custom_fields():
return {
"Customer": [
{"fieldname": "my_field", "label": "My Field", "fieldtype": "Data",
"insert_after": "customer_name"},
],
"Sales Invoice": [...],
}
For multi-module apps, prefer module-level setup.py with one make_custom_fields() per module, then list them all in after_migrate:
after_migrate = [
"myapp.module1.setup.make_custom_fields",
"myapp.module2.setup.make_custom_fields",
]
Ownership rule: an app only creates, reads, and writes its OWN custom fields. Never manage (or delete) custom fields that belong to another app — that's how two apps end up fighting over a field on every migrate.
custom/ folder for doc events
One file per DocType you're hooking. Handler functions take (doc, method=None). Register paths in hooks.py doc_events.
# mymodule/custom/sales_invoice.py
def validate(doc, method=None): ...
def on_submit(doc, method=None): ...
Client scripts for existing DocTypes
- One file per DocType, named after it in lowercase:
public/js/customer.js,public/js/sales_invoice.js. Never put two doctypes'frappe.ui.form.on(...)in one file — it kills grep-by-doctype-name. - Register via
doctype_jsinhooks.py.
Sharing client scripts across DocTypes
doctype_js accepts a list of files per doctype — Frappe concatenates them into the form's __js only when that form opens (smaller blast radius than app_include_js):
doctype_js = {
"Customer": ["public/js/shared_helpers.js", "public/js/customer.js"],
"Supplier": ["public/js/shared_helpers.js", "public/js/supplier.js"],
}
List helpers first (they register a namespace), doctype-specific file last (it uses the namespace).
| Approach | Loads on | Use when |
|---|---|---|
app_include_js = "myapp.bundle.js" |
Every desk page | Helpers used app-wide (ERPNext pattern) |
doctype_js: [helpers, doctype] |
Only that form | Helpers shared by a small set of related doctypes |
Best Practices Summary
- Custom fields:
install.pyget_custom_fields(), wired into BOTHafter_installandafter_migrate. - Hook handlers: in
custom/folder, one file per DocType. - Client scripts: one file per DocType in
public/js/, named after the DocType. Register viadoctype_js. - Shared client scripts:
doctype_js: ["helpers.js", "doctype.js"](per-doctype). Useapp_include_jsonly for truly cross-cutting helpers. - Shared server code:
shared/folder. - Permissions:
frappe.has_permission(..., throw=True)in whitelisted methods.get_list(notget_all) for user-facing data. - Validations live server-side: enforce rules in the controller (
validate) or field metadata (reqd, options, depends_on) — client-side checks are UX hints only, never the enforcement layer. - i18n:
_()/__()on all user-facing strings; extract from JS template literals first. - Logging:
frappe.log_error(), neverprint(). - Errors:
frappe.throw()for user errors; try/except +log_error()for system errors. - Queries:
frappe.qboverfrappe.db.sql(). Parameterize /frappe.db.escape()when raw SQL is unavoidable. - Background jobs: always explicit
queue=;enqueue_after_commit=Truewhen the job depends on data the request is writing. - Caching: never mutate a
get_cached_doc()result (shared ref); invalidate viadoc_eventsor.clear_cache(). - Outbound HTTP: every third-party call (and inbound webhook receipt) goes through
frappe.integrations.utils.create_request_log→handle_success/handle_failure. Setreference_doctype+reference_docnameso the log links back to the business record. - No raw-SQL workarounds: when something seems impossible through the ORM, there is almost always a proper Frappe API for it — find it before reaching for
frappe.db.sqlwrites. - No throwaway scripts inside
apps/: usebench executeorbench consolefor one-off diagnostics; ad-hoc.pyfiles in app folders end up committed.
Common Commands
bench --site <site> run-tests --app app_name
bench --site <site> migrate
bench --site <site> console
bench --site <site> clear-cache
bench build --app app_name
bench --site <site> execute myapp.install.after_install
Utilities
from frappe.utils import (
nowdate, nowtime, now_datetime, getdate, get_datetime,
flt, cint, cstr, fmt_money, get_link_to_form,
)