Dev Library ยท Reference Collection

The Dev Reference Library



Last update: 2026-06-04

Frappe Framework Reference"

DocTypes, controllers, hooks, the ORM, REST API, background jobs, and bench โ€” everything the docs underexplain

๐Ÿ”—Project Structure

    # Create a new bench
    bench init my-bench --frappe-branch version-15
    cd my-bench

    # Install an app
    bench get-app https://github.com/your/app
    bench get-app --branch version-15 erpnext   # specific branch

    # Create a new app from scratch
    bench new-app my_app

    # Create a site
    bench new-site mysite.localhost --install-app my_app

    # A bench directory looks like this:
    my-bench/
    โ”œโ”€โ”€ apps/
    โ”‚   โ”œโ”€โ”€ frappe/          # core framework
    โ”‚   โ””โ”€โ”€ my_app/          # your app
    โ”‚       โ”œโ”€โ”€ my_app/
    โ”‚       โ”‚   โ”œโ”€โ”€ hooks.py             # app-wide configuration
    โ”‚       โ”‚   โ”œโ”€โ”€ modules.txt          # list of modules
    โ”‚       โ”‚   โ””โ”€โ”€ my_module/
    โ”‚       โ”‚       โ”œโ”€โ”€ doctype/
    โ”‚       โ”‚       โ”‚   โ””โ”€โ”€ my_doctype/
    โ”‚       โ”‚       โ”‚       โ”œโ”€โ”€ my_doctype.json      # schema
    โ”‚       โ”‚       โ”‚       โ”œโ”€โ”€ my_doctype.py        # controller
    โ”‚       โ”‚       โ”‚       โ””โ”€โ”€ my_doctype_list.js   # list view config
    โ”‚       โ”‚       โ””โ”€โ”€ ...
    โ”œโ”€โ”€ sites/
    โ”‚   โ””โ”€โ”€ mysite.localhost/
    โ”‚       โ”œโ”€โ”€ site_config.json    # DB credentials, site settings
    โ”‚       โ””โ”€โ”€ private/files/
    โ””โ”€โ”€ Procfile

๐Ÿ”—Bench CLI

    # Development
    bench start                        # start all processes (web, worker, scheduler)
    bench --site mysite console        # interactive Python REPL with frappe loaded
    bench --site mysite mariadb        # open MariaDB shell for the site
    bench --site mysite migrate        # run pending migrations (after schema changes)
    bench --site mysite clear-cache    # clear redis cache
    bench --site mysite clear-website-cache

    # App & site management
    bench --site mysite install-app my_app
    bench --site mysite uninstall-app my_app
    bench --site mysite backup
    bench --site mysite restore path/to/backup.sql.gz

    # Building assets
    bench build                        # build JS/CSS bundles for all apps
    bench build --app my_app           # build only your app
    bench watch                        # watch and rebuild on change (dev)

    # Running scripts
    bench --site mysite execute my_app.utils.some_function
    bench --site mysite run-script path/to/script.py

    # Production
    bench setup production <user>      # configure nginx + supervisor
    bench restart                      # restart web + workers
    bench update                       # pull all apps + migrate + build

๐Ÿ”—DocType Schema

A DocType is defined by its .json file. You rarely edit it by hand โ€” use the Desk UI โ€” but understanding the structure is essential.

    {
      "name": "Project",
      "module": "My Module",
      "doctype": "DocType",
      "is_submittable": 0,
      "track_changes": 1,
      "fields": [
        {
          "fieldname": "title",
          "fieldtype": "Data",
          "label": "Title",
          "reqd": 1,
          "in_list_view": 1
        },
        {
          "fieldname": "status",
          "fieldtype": "Select",
          "label": "Status",
          "options": "Open\nIn Progress\nClosed",
          "default": "Open"
        },
        {
          "fieldname": "due_date",
          "fieldtype": "Date",
          "label": "Due Date"
        },
        {
          "fieldname": "tasks_section",
          "fieldtype": "Section Break",
          "label": "Tasks"
        },
        {
          "fieldname": "tasks",
          "fieldtype": "Table",
          "label": "Tasks",
          "options": "Project Task"   
        }
      ],
      "permissions": [
        { "role": "System Manager", "read": 1, "write": 1, "create": 1, "delete": 1 }
      ]
    }

๐Ÿ”—Field Types

FieldtypeStoresNotes
DataShort stringSingle line, max 140 chars by default
TextLong stringMultiline, no formatting
Long TextVery long stringFor large content
Small TextMedium stringTextarea
Text EditorHTMLRich text via Quill
Markdown EditorMarkdownRendered as HTML
IntInteger
FloatFloat
CurrencyDecimalRespects currency precision setting
PercentFloatDisplayed as %
Check0 or 1Renders as checkbox
DateDate
DatetimeDatetime
TimeTime
SelectStringOptions defined as newline-separated values
LinkString (foreign key)References another DocType
Dynamic LinkStringFK to a DocType named in another field
TableChild rowsReferences a Child DocType
AttachFile URLSingle file attachment
Attach ImageImage URLImage attachment with preview
JSONJSON stringStored as text, parsed on access
Section Breakโ€”Layout only, groups fields visually
Column Breakโ€”Layout only, starts a new column
Tab Breakโ€”Layout only, starts a new tab

๐Ÿ”—Controller (Python)

The controller is a Python class that inherits from Document. Frappe calls lifecycle hooks automatically โ€” you never instantiate or call these yourself.

    import frappe
    from frappe.model.document import Document

    class Project(Document):

        # โ”€โ”€ Lifecycle hooks (called in this order on save) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

        def before_insert(self):
            # Runs before the document is inserted for the first time.
            # Good for setting computed defaults.
            if not self.code:
                self.code = frappe.generate_hash(length=8)

        def after_insert(self):
            # Runs after the first INSERT is committed.
            frappe.publish_realtime("project_created", {"name": self.name})

        def validate(self):
            # Runs before every save (insert + update).
            # Raise frappe.ValidationError to abort.
            if self.due_date and self.due_date < frappe.utils.today():
                frappe.throw("Due date cannot be in the past", frappe.ValidationError)

        def before_save(self):
            # Runs after validate, before the DB write.
            self.last_modified_by = frappe.session.user

        def on_update(self):
            # Runs after every save is committed.
            # Safe to trigger side-effects here.
            self.notify_assignees()

        def before_submit(self):
            # Only called on submittable DocTypes when status โ†’ Submitted.
            if not self.tasks:
                frappe.throw("Cannot submit a project with no tasks")

        def on_submit(self):
            self.db_set("status", "In Progress")  # direct DB update, no re-save

        def before_cancel(self):
            pass

        def on_cancel(self):
            self.db_set("status", "Cancelled")

        def on_trash(self):
            # Runs before the document is deleted.
            frappe.db.delete("Comment", {"reference_name": self.name})

        # โ”€โ”€ Helper methods โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

        def notify_assignees(self):
            for task in self.tasks:
                if task.assigned_to:
                    frappe.sendmail(
                        recipients=[task.assigned_to],
                        subject=f"Task updated in {self.title}",
                        message=f"Task {task.title} was updated."
                    )

Never call self.save() inside validate() or on_update(). It causes infinite recursion. Use self.db_set("field", value) for direct column updates that bypass the lifecycle, or set self.field = value before the current save completes.

๐Ÿ”—The ORM: frappe.db

    import frappe

    # โ”€โ”€ Single value โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

    # get a field value from a document
    status = frappe.db.get_value("Project", "PROJ-0001", "status")

    # get multiple fields at once (returns a dict)
    data = frappe.db.get_value("Project", "PROJ-0001", ["status", "due_date"], as_dict=True)

    # โ”€โ”€ Lists โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

    # get all documents matching filters
    projects = frappe.db.get_all(
        "Project",
        filters={"status": "Open"},
        fields=["name", "title", "due_date"],
        order_by="due_date asc",
        limit=50
    )
    # returns a list of dicts: [{"name": "PROJ-001", "title": "...", ...}, ...]

    # get_list: same as get_all but respects user permissions
    projects = frappe.db.get_list("Project", filters={"status": "Open"}, fields=["name", "title"])

    # get_all with OR filters
    projects = frappe.db.get_all(
        "Project",
        filters=[["status", "in", ["Open", "In Progress"]]],
        fields=["name", "title"]
    )

    # โ”€โ”€ Load a full document โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

    doc = frappe.get_doc("Project", "PROJ-0001")
    doc.title = "Updated Title"
    doc.save()

    # create a new document
    doc = frappe.get_doc({
        "doctype": "Project",
        "title": "New Project",
        "status": "Open"
    })
    doc.insert()
    frappe.db.commit()   # always commit after insert/update outside a request context

    # โ”€โ”€ Direct DB writes โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

    # update a single field without loading the full document
    frappe.db.set_value("Project", "PROJ-0001", "status", "Closed")

    # update multiple fields
    frappe.db.set_value("Project", "PROJ-0001", {
        "status": "Closed",
        "due_date": "2025-12-31"
    })

    # raw SQL (escape your inputs โ€” never use .format() with user input)
    results = frappe.db.sql("""
        SELECT name, title FROM `tabProject`
        WHERE status = %(status)s AND due_date < %(today)s
    """, {"status": "Open", "today": frappe.utils.today()}, as_dict=True)

    # โ”€โ”€ Existence checks โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

    frappe.db.exists("Project", "PROJ-0001")          # returns name or None
    frappe.db.exists("Project", {"status": "Open"})   # with filters
    frappe.db.count("Project", {"status": "Open"})    # count matching rows

Table names in raw SQL are always `tab{DocType}`. A DocType named Sales Order lives in `tabSales Order`. Frappe adds the prefix automatically in ORM methods but not in raw frappe.db.sql() โ€” you must write it yourself.

๐Ÿ”—hooks.py

hooks.py is the central wiring file for your app. It controls how your app integrates with Frappe's event system.

    # my_app/hooks.py

    app_name = "my_app"
    app_title = "My App"
    app_publisher = "Your Name"
    app_description = "What this app does"
    app_version = "0.0.1"
    app_email = "you@example.com"
    app_license = "MIT"

    # โ”€โ”€ Document events โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
    # Hook into any DocType's lifecycle from outside its controller.
    # Useful for cross-app logic without modifying the original controller.

    doc_events = {
        "User": {
            "after_insert": "my_app.handlers.user.on_user_created",
            "on_update": "my_app.handlers.user.on_user_updated",
        },
        "*": {
            # runs on every DocType
            "on_submit": "my_app.handlers.audit.log_submission",
        }
    }

    # โ”€โ”€ Scheduled tasks โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

    scheduler_events = {
        "all":            ["my_app.tasks.every_5_minutes"],     # ~every 5 min
        "hourly":         ["my_app.tasks.hourly_sync"],
        "daily":          ["my_app.tasks.daily_cleanup"],
        "weekly":         ["my_app.tasks.weekly_report"],
        "monthly":        ["my_app.tasks.monthly_invoice"],
        "cron": {
            "0 9 * * 1-5": ["my_app.tasks.weekday_morning"],    # Mon-Fri 9am
        }
    }

    # โ”€โ”€ Fixtures โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
    # Records exported with `bench export-fixtures` and imported on migrate.

    fixtures = [
        "Custom Field",
        "Property Setter",
        {"dt": "Role", "filters": [["role_name", "like", "My App%"]]},
    ]

    # โ”€โ”€ Other common hooks โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

    # Extend an existing DocType with extra fields (no forking required)
    # Define the fields in a Custom Field fixture instead.

    # Override a controller method from another app
    override_doctype_class = {
        "Sales Invoice": "my_app.overrides.CustomSalesInvoice"
    }

    # Add JS/CSS to the desk globally
    app_include_js = ["assets/my_app/js/global.js"]
    app_include_css = ["assets/my_app/css/global.css"]

    # Add JS to a specific form
    doctype_js = {
        "Project": "public/js/project.js"
    }

๐Ÿ”—REST API

Every DocType automatically gets a REST API. No extra code needed.

    # Authentication: get a token
    POST /api/method/login
    { "usr": "admin@example.com", "pwd": "password" }
    # โ†’ sets a session cookie, or use token auth below

    # Token auth (generate in User > API Access)
    Authorization: token api_key:api_secret

    # โ”€โ”€ CRUD โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

    # List documents
    GET /api/resource/Project
    GET /api/resource/Project?filters=[["status","=","Open"]]&fields=["name","title"]&limit=20

    # Get a single document
    GET /api/resource/Project/PROJ-0001

    # Create
    POST /api/resource/Project
    { "title": "New Project", "status": "Open" }

    # Update (partial โ€” only sends changed fields)
    PUT /api/resource/Project/PROJ-0001
    { "status": "Closed" }

    # Delete
    DELETE /api/resource/Project/PROJ-0001
    # โ”€โ”€ Whitelisted methods โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
    # Expose a Python function as an API endpoint.

    import frappe

    @frappe.whitelist()
    def get_project_summary(project_name):
        # frappe.session.user is available โ€” you know who is calling
        doc = frappe.get_doc("Project", project_name)
        return {
            "title": doc.title,
            "task_count": len(doc.tasks),
            "open_tasks": sum(1 for t in doc.tasks if t.status == "Open")
        }

    # Call it from JS or curl:
    # POST /api/method/my_app.api.get_project_summary
    # { "project_name": "PROJ-0001" }

    @frappe.whitelist(allow_guest=True)
    def public_endpoint():
        # allow_guest=True โ†’ no login required
        return {"status": "ok"}

@frappe.whitelist() does not check permissions. Anyone authenticated can call it. Add explicit frappe.has_permission() checks inside the function if the data is sensitive.

๐Ÿ”—Permissions

    import frappe

    # Check if current user can access a document
    frappe.has_permission("Project", doc="PROJ-0001", throw=True)
    # throw=True raises PermissionError automatically; throw=False returns bool

    # Check a specific permission type
    frappe.has_permission("Project", ptype="write", doc="PROJ-0001", throw=True)
    # ptype options: read, write, create, delete, submit, cancel, amend

    # Get current user
    frappe.session.user          # "admin@example.com"
    frappe.session.user == "Guest"  # True for unauthenticated requests

    # Check role
    frappe.get_roles(frappe.session.user)           # ["System Manager", "My Role", ...]
    "System Manager" in frappe.get_roles()          # True/False

    # Programmatic permission bypass (use with care โ€” only in trusted server scripts)
    frappe.set_user("Administrator")
    # ... do privileged work ...
    frappe.set_user(original_user)

๐Ÿ”—Background Jobs

Long-running tasks should never block a web request. Enqueue them.

    import frappe

    # Enqueue a function to run in a background worker
    frappe.enqueue(
        "my_app.tasks.send_bulk_email",     # dotted path to function
        queue="long",                        # default, short, long
        timeout=600,                         # seconds before job is killed
        job_name="bulk_email_job",           # optional, for deduplication
        # kwargs passed to the function:
        project_name="PROJ-0001",
        notify_all=True
    )

    # The function itself (runs in a worker process)
    def send_bulk_email(project_name, notify_all=False):
        doc = frappe.get_doc("Project", project_name)
        recipients = get_recipients(doc, all=notify_all)
        for r in recipients:
            frappe.sendmail(recipients=[r], subject="...", message="...")
        frappe.db.commit()   # always commit in background jobs

    # Enqueue with deduplication (won't enqueue if job_name already queued)
    frappe.enqueue(
        "my_app.tasks.sync",
        job_name="sync_job",
        deduplicate=True
    )

๐Ÿ”—Jinja Templates

Frappe uses Jinja2 for print formats, email templates, and web pages.

    # Render a Jinja string from Python
    html = frappe.render_template(
        "<p>Hello {{ doc.title }}</p>",
        {"doc": doc}
    )

    # Render a template file (relative to app root)
    html = frappe.render_template("my_app/templates/email/welcome.html", context)
    {# โ”€โ”€ Frappe-specific Jinja globals available in all templates โ”€โ”€ #}

    {# Current user #}
    {{ frappe.session.user }}

    {# Translate a string #}
    {{ _("Save") }}

    {# Format a date #}
    {{ frappe.format_date(doc.due_date) }}

    {# Format a currency value #}
    {{ frappe.format_value(doc.amount, {"fieldtype": "Currency"}) }}

    {# Link to a document #}
    <a href="/app/project/{{ doc.name }}">{{ doc.title }}</a>

    {# Conditional #}
    {% if doc.status == "Open" %}
      <span class="badge">Open</span>
    {% endif %}

    {# Loop over child table #}
    {% for task in doc.tasks %}
      <li>{{ task.title }} โ€” {{ task.assigned_to }}</li>
    {% endfor %}

๐Ÿ”—Migrations and Patches

    # patches.txt: list of patch modules to run on migrate (one per line, never remove old ones)
    # my_app/patches.txt
    my_app.patches.v1_0.set_default_status
    my_app.patches.v1_1.backfill_project_codes

    # A patch file
    # my_app/patches/v1_1/backfill_project_codes.py
    import frappe

    def execute():
        # Runs once per site on `bench migrate`
        projects = frappe.db.get_all("Project", filters={"code": ""}, fields=["name"])
        for p in projects:
            frappe.db.set_value("Project", p.name, "code", frappe.generate_hash(length=8))
        frappe.db.commit()

Patches run exactly once per site. Frappe tracks executed patches in the tabPatch Log table. If a patch fails halfway, fix it and run bench --site mysite migrate again โ€” it will retry only the failed patch.

๐Ÿ”—Useful frappe Utilities

    import frappe
    from frappe.utils import (
        today, now, nowdate, nowdatetime,
        add_days, add_months, date_diff,
        flt, cint, cstr,
        get_url, get_site_name
    )

    # Dates
    today()                          # "2025-06-04"  (string)
    nowdatetime()                    # "2025-06-04 14:32:00"
    add_days(today(), 7)             # one week from now
    date_diff("2025-12-31", today()) # days between two dates

    # Type coercion (safe โ€” never raises)
    flt("3.14")    # 3.14  (float)
    cint("42")     # 42    (int), cint(None) โ†’ 0
    cstr(None)     # ""    (str)

    # Messaging (only works inside a web request)
    frappe.msgprint("Something happened")           # toast on the desk
    frappe.throw("Validation failed")               # raises ValidationError + shows message
    frappe.log_error("something broke", title="My App Error")  # writes to Error Log

    # Caching
    frappe.cache.set_value("my_key", {"data": 1}, expires_in_sec=300)
    frappe.cache.get_value("my_key")

    # Generating names
    frappe.generate_hash(length=10)
    frappe.get_id()                   # UUID