Frappe is the Python full-stack that ERPNext, HRMS, CRM, Helpdesk, LMS, Builder, Drive, Wiki, and the rest of the suite all sit on. Its central idea: declare a "doctype" in JSON, and the framework synthesises the database table, the form, the list view, the REST endpoint, the permissions, and the audit trail โ without you writing a controller, a migration, or a route.
The Frappe Framework is the Python/JavaScript engine the Frappe team built โ initially to power ERPNext, then opened up so anyone could build their own apps the same way. Think Django plus admin plus a dynamic form engine plus a permissions system plus a job queue plus a REST/RPC API, all glued together by a single concept: the doctype.
A doctype is a JSON file that declares fields, links, child tables, and permissions. The framework reads that metadata at runtime and synthesises everything around it โ the database schema, the list view, the form view, the API endpoints, the role checks, the audit log. There is no hand-written ORM mapping, no separate API layer, no separate admin scaffolding. One source of truth, many surfaces generated from it.
Because every object in the system is described by metadata, an agent can introspect a Frappe site and discover its data model at runtime. frappe.get_meta("Sales Order") returns the full field list, types, options, and links. That makes Frappe one of the friendliest backends for an AI agent to operate against โ it can ask the system what exists before doing anything destructive to it.
A doctype is the framework's atom. Once you understand what a doctype declaration produces, every other Frappe concept slots into place.
# customer_visit.json โ a complete custom doctype { "doctype": "DocType", "name": "Customer Visit", "module": "CRM", "naming_rule": "Expression", "autoname": "format:CV-{YYYY}-{#####}", "track_changes": 1, "is_submittable": 1, "fields": [ { "fieldname": "customer", "fieldtype": "Link", "options": "Customer", "reqd": 1 }, { "fieldname": "visit_date", "fieldtype": "Date", "reqd": 1 }, { "fieldname": "sales_rep", "fieldtype": "Link", "options": "User" }, { "fieldname": "notes", "fieldtype": "Long Text" }, { "fieldname": "items", "fieldtype": "Table", "options": "Visit Item" } ], "permissions": [ { "role": "Sales User", "read": 1, "write": 1, "create": 1, "submit": 1 }, { "role": "Sales Manager", "read": 1, "write": 1, "create": 1, "submit": 1, "cancel": 1 } ] }
What you get from that one file. A MariaDB/Postgres table called tabCustomer Visit with the right columns and indices. A list view at /app/customer-visit. A form view with link picker, date picker, and inline child-table editor for the items grid. Auto-incrementing IDs in the format CV-2026-00001. Submit/cancel semantics with docstatus 0/1/2. Role-checked REST and RPC endpoints. A change history. All of it wired up; none of it written by hand.
| Field type | What it stores | Notes |
|---|---|---|
Data | Short text | The default โ varchar(140) |
Long Text / Text Editor | Multi-line | Editor type renders TinyMCE in the form |
Link | FK to another doctype | Stores the linked doctype's name |
Dynamic Link | FK with target chosen at runtime | Pair with a Link field to a "DocType" doctype |
Table | Child rows | The child must itself be a doctype with istable: 1 |
Select | Enum | Options as newline-separated string |
Currency / Float / Int | Numerics | Currency respects company default |
Date / Datetime / Time | Time values | ISO 8601 in the API |
Attach / Attach Image | File reference | Stores the path; file lives in /files |
JSON | Arbitrary JSON | Use sparingly โ defeats the introspection story |
Every doctype is a REST resource. Every server-side Python function decorated @frappe.whitelist() is reachable via RPC. There's no separate API layer to build โ the framework exposes both surfaces directly from the same code.
# REST โ CRUD on any doctype GET /api/resource/Customer Visit GET /api/resource/Customer Visit/CV-2026-00001 POST /api/resource/Customer Visit PUT /api/resource/Customer Visit/CV-2026-00001 DELETE /api/resource/Customer Visit/CV-2026-00001 # Authentication โ API Key + Secret is the production answer Authorization: token api_key:api_secret # RPC โ call any whitelisted server function POST /api/method/frappe.client.get_count { "doctype": "Customer Visit", "filters": [["docstatus", "=", 1]] }
Filters are JSON arrays. Each filter is [field, operator, value]. Multiple filters become an array of arrays. The whole thing is server-side โ the database does the work, not your script.
# List with filters, fields, ordering, pagination GET /api/resource/Customer Visit ?filters=[["docstatus","=",1],["visit_date",">=","2026-01-01"]] &fields=["name","customer","visit_date","sales_rep"] &order_by=visit_date desc &limit_page_length=100 &limit_start=0
Whitelisted methods. Any Python function decorated with @frappe.whitelist() becomes callable via /api/method/<dotted.path>. This is the escape hatch when REST CRUD isn't enough โ bulk operations, computed reports, document transformations, custom workflows.
# custom_app/api.py import frappe @frappe.whitelist() def upcoming_visits(sales_rep: str, days: int = 7): return frappe.get_list( "Customer Visit", filters={ "sales_rep": sales_rep, "visit_date": ["between", [ frappe.utils.today(), frappe.utils.add_days(frappe.utils.today(), days) ]] }, fields=["name", "customer", "visit_date", "notes"], order_by="visit_date asc", ) # Now reachable as: # GET /api/method/[email protected]&days=14
Frappe's hook system is how you attach behaviour to the document lifecycle. Define a function, point a hook at it in hooks.py, and it runs at the right moment in the right transaction.
# custom_app/hooks.py โ wire events to handlers doc_events = { "Customer Visit": { "validate": "custom_app.events.validate_visit", "on_submit": "custom_app.events.create_followup_task", "on_cancel": "custom_app.events.notify_manager", }, "Sales Order": { "on_submit": "custom_app.events.fan_out_to_warehouse", } } scheduler_events = { "daily": ["custom_app.tasks.send_morning_digest"], "hourly": ["custom_app.tasks.sync_external_inventory"], "cron": { "*/15 * * * *": ["custom_app.tasks.poll_webhooks"] } }
| Hook | Fires when |
|---|---|
validate | Before insert or update โ raise to abort |
before_save / after_save | Around the save transaction |
before_insert / after_insert | Around the first save only |
on_submit | On the transition from docstatus 0 to 1 |
on_cancel | On the transition from docstatus 1 to 2 |
on_trash | Before delete |
on_update_after_submit | Updates to a submitted document |
Anything wrapped in frappe.enqueue("custom_app.tasks.heavy_thing", queue="long") drops onto Frappe's RQ-backed queue and runs out-of-band. The framework ships with three default queues โ short, default, and long โ and a scheduler that triggers cron and interval jobs declared in hooks.py. There's no Celery to set up, no separate broker config โ Bench wires Redis for you on install.
Bench is the Python CLI that installs, runs, and operates Frappe sites. One bench can host many sites, each with its own database and its own subset of installed apps. It's the answer to "how do I actually deploy this thing."
# Initialise a new bench (a directory containing apps and sites) $ bench init frappe-bench --frappe-branch version-15 $ cd frappe-bench # Get an app from a Git repo $ bench get-app erpnext --branch version-15 $ bench get-app https://github.com/frappe/hrms # Create a new site (separate DB, separate users, shares the bench's apps) $ bench new-site site1.local --admin-password admin # Install apps into a site $ bench --site site1.local install-app erpnext $ bench --site site1.local install-app hrms # Run the development server (web + workers + scheduler) $ bench start # Create a custom app of your own $ bench new-app custom_app $ bench --site site1.local install-app custom_app # Migrate after schema changes $ bench --site site1.local migrate # Production deploy with supervisor + nginx $ sudo bench setup production frappe
One bench, many sites. The unit of multitenancy in Frappe is the site, not the bench. A bench is a code directory; a site is a database plus a set of installed apps plus a config file. Run ten sites on one bench, or scale horizontally with Frappe Cloud doing the bench/site management for you.
Six failure modes worth internalising before you put a Frappe site in front of agents or customers.
0 draft, 1 submitted, 2 cancelled. Most queries need ["docstatus","=",1] or you'll surface drafts that aren't really there. This catches everyone exactly once.
Frappe's API key rate limit defaults to about 5 req/sec per user. Scheduled syncs that don't batch will hit it constantly. Either raise the limit in site_config.json or batch your reads.
name is the ID, not the display label
Every document has a name field โ that's the primary key, not the human label. Look up by name, not by customer_name or item_name, or you'll silently miss records.
A list call returns the parent rows only. To pull child-table rows in one shot, use dotted fields like fields=["items.item_code","items.qty"] โ or fetch the parent via GET, which returns children embedded.
Adding a field via the UI creates a Custom Field document in that site's database. To version-control it, use bench export-fixtures and ship the JSON as part of your custom app โ otherwise it'll vanish on a fresh install.
Frappe and the official apps are AGPL-licensed. If you host a Frappe-derived application as a service, you owe users access to the modified source. Read the licence before you build a SaaS on top โ most teams are fine with it, but "fine without reading it" is not.
Frappe is excellent for a specific shape of work and miserable for the wrong one. The honest call.
Frappe is the substrate under multiple business-domain leaves โ ERPNext today, HRMS, CRM, Helpdesk, and LMS as the tree fills out. Learning the framework once unlocks the entire ecosystem.
The compounding play is this: every Frappe app is built on the same primitives, so an integration pattern that works on ERPNext works on HRMS, CRM, Helpdesk, and any custom app you build yourself. The auth shape is the same. The filter syntax is the same. The hook system is the same. The doctype introspection is the same. An AI agent that knows how to operate against one Frappe site knows how to operate against all of them.
That's why tech/frappe/framework sits in the technology branch, not the business branch โ it's not an ERP, it's the engine. The ERP (ERPNext) is one app on top of it. Understand the engine and the apps stop being thirteen different things to learn; they become thirteen instances of one pattern.
Frappe is the substrate for an entire branch of business apps. Everything in tech/frappe/* requires it; everything Frappe-built in biz/* ultimately depends on it.
The official documentation is unusually good. The source repo is the most trustworthy reference for hook names and field types โ read it when the docs are quiet.