Custom DocTypes & Forms
DocTypes are the atoms of Frappe. Every piece of data — a customer, an invoice, a franchise outlet — is a DocType instance. In this chapter we build complete DocTypes for ScoopJoy’s franchise management system, demonstrating every important field type, layout technique, and customization pattern.
Creating DocTypes: UI vs. code
Section titled “Creating DocTypes: UI vs. code”There are two ways to create a DocType.
Via Desk UI (recommended for development):
- Navigate to the search bar, type “DocType”, click “New DocType”.
- Fill in fields, set properties, arrange the layout.
- Save — Frappe generates the JSON definition and Python controller on disk (in developer mode).
Via code (for automated/scripted creation): directly create the JSON file and
Python controller in the correct directory, then run bench migrate to sync to the
database.
In practice you use the Desk UI during development (it’s faster and visual), and the
resulting files are what you commit to Git. When another developer pulls your code
and runs bench migrate, the DocType is created on their site from those files.
DocType definition: Franchise Outlet
Section titled “DocType definition: Franchise Outlet”Let’s create our first DocType: Franchise Outlet, representing a physical ScoopJoy location.
Navigate to the URL bar and type icecream.localhost:8000/app/doctype/new-doctype-1,
or from the search bar type “New DocType” and hit enter. Set these properties:
- Name: Franchise Outlet
- Module: Franchise Management
- Naming Rule: Expression (Custom)
- Auto Name:
FO-.{city}.-.### - Is Submittable: No (this is a master document)
After adding all fields and saving in the Desk, Frappe generates these files. The JSON definition declares the field order (which drives the form layout), every field with its type, the naming rule, and the permission model:
{ "actions": [], "allow_rename": 1, "doctype": "DocType", "engine": "InnoDB", "field_order": [ "details_tab", "outlet_name", "franchise_code", "column_break_1", "status", "owner_name", "contact_section", "email", "phone", "column_break_2", "website", "location_section", "address_line_1", "address_line_2", "column_break_3", "city", "state", "country", "pincode", "geo_location", "operations_tab", "opening_date", "seating_capacity", "column_break_4", "warehouse", "cost_center", "operating_hours_section", "opening_time", "closing_time", "column_break_5", "is_24_hours", "menu_section", "menu_items", "financials_tab", "monthly_rent", "security_deposit", "column_break_6", "royalty_percentage", "marketing_fee_percentage", "bank_details_section", "bank_name", "bank_account_number", "column_break_7", "ifsc_code", "branch_name", "notes_tab", "notes" ], "fields": [ { "fieldname": "details_tab", "fieldtype": "Tab Break", "label": "Details" }, { "fieldname": "outlet_name", "fieldtype": "Data", "in_list_view": 1, "label": "Outlet Name", "reqd": 1 }, { "fieldname": "franchise_code", "fieldtype": "Data", "label": "Franchise Code", "read_only": 1, "unique": 1 }, { "fieldname": "column_break_1", "fieldtype": "Column Break" }, { "default": "Active", "fieldname": "status", "fieldtype": "Select", "in_list_view": 1, "in_standard_filter": 1, "label": "Status", "options": "Active\nInactive\nUnder Renovation\nClosed" }, { "fieldname": "owner_name", "fieldtype": "Link", "label": "Franchise Owner", "options": "Customer" }, { "fieldname": "contact_section", "fieldtype": "Section Break", "label": "Contact Information" }, { "fieldname": "email", "fieldtype": "Data", "label": "Email", "options": "Email" }, { "fieldname": "phone", "fieldtype": "Data", "label": "Phone", "options": "Phone" }, { "fieldname": "column_break_2", "fieldtype": "Column Break" }, { "fieldname": "website", "fieldtype": "Data", "label": "Website", "options": "URL" }, { "fieldname": "location_section", "fieldtype": "Section Break", "label": "Location" }, { "fieldname": "address_line_1", "fieldtype": "Data", "label": "Address Line 1" }, { "fieldname": "address_line_2", "fieldtype": "Data", "label": "Address Line 2" }, { "fieldname": "column_break_3", "fieldtype": "Column Break" }, { "fieldname": "city", "fieldtype": "Data", "label": "City", "reqd": 1 }, { "fieldname": "state", "fieldtype": "Data", "label": "State" }, { "fieldname": "country", "fieldtype": "Link", "label": "Country", "options": "Country", "default": "India" }, { "fieldname": "pincode", "fieldtype": "Data", "label": "PIN Code" }, { "fieldname": "geo_location", "fieldtype": "Geolocation", "label": "Geo Location" }, { "fieldname": "operations_tab", "fieldtype": "Tab Break", "label": "Operations" }, { "fieldname": "opening_date", "fieldtype": "Date", "label": "Opening Date" }, { "fieldname": "seating_capacity", "fieldtype": "Int", "label": "Seating Capacity" }, { "fieldname": "column_break_4", "fieldtype": "Column Break" }, { "fieldname": "warehouse", "fieldtype": "Link", "label": "Default Warehouse", "options": "Warehouse" }, { "fieldname": "cost_center", "fieldtype": "Link", "label": "Cost Center", "options": "Cost Center" }, { "fieldname": "operating_hours_section", "fieldtype": "Section Break", "label": "Operating Hours" }, { "fieldname": "opening_time", "fieldtype": "Time", "label": "Opening Time" }, { "fieldname": "closing_time", "fieldtype": "Time", "label": "Closing Time" }, { "fieldname": "column_break_5", "fieldtype": "Column Break" }, { "fieldname": "is_24_hours", "fieldtype": "Check", "label": "Open 24 Hours", "default": 0 }, { "fieldname": "menu_section", "fieldtype": "Section Break", "label": "Menu" }, { "fieldname": "menu_items", "fieldtype": "Table MultiSelect", "label": "Menu Items", "options": "Franchise Menu Item" }, { "fieldname": "financials_tab", "fieldtype": "Tab Break", "label": "Financials" }, { "fieldname": "monthly_rent", "fieldtype": "Currency", "label": "Monthly Rent" }, { "fieldname": "security_deposit", "fieldtype": "Currency", "label": "Security Deposit" }, { "fieldname": "column_break_6", "fieldtype": "Column Break" }, { "fieldname": "royalty_percentage", "fieldtype": "Percent", "label": "Royalty %", "default": "5" }, { "fieldname": "marketing_fee_percentage", "fieldtype": "Percent", "label": "Marketing Fee %", "default": "2" }, { "fieldname": "bank_details_section", "fieldtype": "Section Break", "label": "Bank Details" }, { "fieldname": "bank_name", "fieldtype": "Data", "label": "Bank Name" }, { "fieldname": "bank_account_number", "fieldtype": "Data", "label": "Account Number" }, { "fieldname": "column_break_7", "fieldtype": "Column Break" }, { "fieldname": "ifsc_code", "fieldtype": "Data", "label": "IFSC Code" }, { "fieldname": "branch_name", "fieldtype": "Data", "label": "Branch Name" }, { "fieldname": "notes_tab", "fieldtype": "Tab Break", "label": "Notes" }, { "fieldname": "notes", "fieldtype": "Text Editor", "label": "Notes" } ], "index_web_pages_for_search": 1, "module": "Franchise Management", "name": "Franchise Outlet", "naming_rule": "Expression (Custom)", "autoname": "FO-.{city}.-.###", "permissions": [ { "create": 1, "delete": 1, "email": 1, "export": 1, "print": 1, "read": 1, "report": 1, "role": "System Manager", "share": 1, "write": 1 } ], "sort_field": "creation", "sort_order": "DESC", "track_changes": 1}The matching Python controller holds the business logic — validation and the generated franchise code:
import frappefrom frappe.model.document import Document
class FranchiseOutlet(Document): def before_save(self): if not self.franchise_code: self.franchise_code = self.generate_franchise_code()
def validate(self): self.validate_operating_hours() self.validate_royalty_percentage()
def validate_operating_hours(self): if self.is_24_hours: self.opening_time = None self.closing_time = None return
if self.opening_time and self.closing_time: if self.opening_time >= self.closing_time: frappe.throw("Closing time must be after opening time")
def validate_royalty_percentage(self): if self.royalty_percentage and self.royalty_percentage > 50: frappe.throw("Royalty percentage cannot exceed 50%")
def generate_franchise_code(self): """Generate a unique franchise code like FC-MUM-001""" city_code = (self.city or "XXX")[:3].upper() count = frappe.db.count( "Franchise Outlet", filters={"city": self.city} ) return f"FC-{city_code}-{str(count + 1).zfill(3)}"Form layout: tabs, sections, and columns
Section titled “Form layout: tabs, sections, and columns”Frappe v16 uses a tab-based form layout. The layout hierarchy is:
Tab Break → Section Break → Column Break → Fields- Tab Break: creates a new tab at the top of the form. The first Tab Break defines the first tab.
- Section Break: creates a horizontal section within a tab. Sections can have labels and can be collapsible.
- Column Break: splits a section into multiple columns (typically 2).
In our Franchise Outlet DocType we defined four tabs:
- Details — basic information, contact, and location
- Operations — hours, warehouse, menu
- Financials — rent, royalties, bank details
- Notes — free-form notes
Naming conventions
Section titled “Naming conventions”The naming_rule and autoname properties control how document names are
generated. Here are the most common patterns:
| Naming Rule | autoname Value | Example Output |
|---|---|---|
| Expression (Custom) | FO-.{city}.-.### | FO-Mumbai-001 |
| By fieldname | field:outlet_name | Mumbai Central Outlet |
| By “Naming Series” field | naming_series: | FO-2025-001 |
| Random / Hash | hash | a1b2c3d4e5 |
| UUID | UUID | 01945abc-def0-7… |
| Prompt | prompt | (user enters manually) |
For v16, UUID naming uses UUID7 which is time-sortable, making it excellent for distributed systems and better for database indexing than random UUIDs.
{ "naming_rule": "Expression (Custom)", "autoname": "FO-.{city}.-.###"}The ### portion auto-increments. {city} pulls from the document’s city field.
You can also use {field_name} for any field.
fetch_from: auto-populating fields
Section titled “fetch_from: auto-populating fields”When you link to another document, you often want to pull in related data
automatically. The fetch_from property does this:
{ "fieldname": "owner_email", "fieldtype": "Data", "label": "Owner Email", "fetch_from": "owner_name.email_id", "read_only": 1}When the user selects a Customer in the owner_name Link field, the owner_email
field automatically populates with that customer’s email_id. The read_only flag
prevents manual editing since the value comes from the linked document.
Link field filters
Section titled “Link field filters”Link fields can be filtered to show only relevant options:
{ "fieldname": "warehouse", "fieldtype": "Link", "label": "Default Warehouse", "options": "Warehouse", "link_filters": "[[\"Warehouse\",\"company\",\"=\",\"ScoopJoy Foods\"]]"}For dynamic filters (based on other fields in the form), you set them in the client script instead — we’ll cover that in Chapter 19.
Conditional field properties
Section titled “Conditional field properties”Frappe supports formula-based field properties that dynamically show, hide, or require fields:
{ "fieldname": "opening_time", "fieldtype": "Time", "label": "Opening Time", "mandatory_depends_on": "eval:!doc.is_24_hours", "read_only_depends_on": "eval:doc.is_24_hours", "hidden_depends_on": ""}mandatory_depends_on: field becomes required when the expression evaluates to true.read_only_depends_on: field becomes read-only when the expression evaluates to true.hidden_depends_on: field is hidden when the expression evaluates to true.
The eval: prefix tells Frappe to evaluate the expression as JavaScript. doc
refers to the current document, so eval:!doc.is_24_hours is true whenever the
“Open 24 Hours” checkbox is unticked.
Creating a submittable DocType: Franchise Agreement
Section titled “Creating a submittable DocType: Franchise Agreement”Submittable DocTypes have a workflow: Draft → Submitted → Cancelled. Once submitted, they can’t be edited (only amended). This is ideal for documents with legal or financial significance.
Here are the key fields of the Franchise Agreement DocType — note is_submittable
and the amended_from field at the end:
{ "doctype": "DocType", "name": "Franchise Agreement", "module": "Franchise Management", "naming_rule": "Expression (Custom)", "autoname": "FA-.YYYY.-.#####", "is_submittable": 1, "fields": [ { "fieldname": "details_tab", "fieldtype": "Tab Break", "label": "Details" }, { "fieldname": "franchise_outlet", "fieldtype": "Link", "label": "Franchise Outlet", "options": "Franchise Outlet", "reqd": 1, "in_list_view": 1, "in_standard_filter": 1 }, { "fieldname": "outlet_name", "fieldtype": "Data", "label": "Outlet Name", "fetch_from": "franchise_outlet.outlet_name", "read_only": 1 }, { "fieldname": "column_break_details", "fieldtype": "Column Break" }, { "fieldname": "agreement_date", "fieldtype": "Date", "label": "Agreement Date", "reqd": 1, "default": "Today" }, { "fieldname": "expiry_date", "fieldtype": "Date", "label": "Expiry Date", "reqd": 1 }, { "fieldname": "royalty_rate", "fieldtype": "Percent", "label": "Royalty Rate (%)", "reqd": 1, "default": "5", "fetch_from": "franchise_outlet.royalty_percentage" }, { "fieldname": "financial_section", "fieldtype": "Section Break", "label": "Financial Terms" }, { "fieldname": "franchise_fee", "fieldtype": "Currency", "label": "Initial Franchise Fee", "reqd": 1 }, { "fieldname": "minimum_guarantee", "fieldtype": "Currency", "label": "Monthly Minimum Guarantee" }, { "fieldname": "column_break_financial", "fieldtype": "Column Break" }, { "fieldname": "payment_frequency", "fieldtype": "Select", "label": "Payment Frequency", "options": "Monthly\nQuarterly\nAnnually", "default": "Monthly" }, { "fieldname": "terms_tab", "fieldtype": "Tab Break", "label": "Terms" }, { "fieldname": "agreement_terms", "fieldtype": "Table", "label": "Agreement Terms", "options": "Agreement Term", "reqd": 1 }, { "fieldname": "amended_from", "fieldtype": "Link", "label": "Amended From", "no_copy": 1, "options": "Franchise Agreement", "print_hide": 1, "read_only": 1 } ], "permissions": [ { "create": 1, "delete": 1, "email": 1, "export": 1, "print": 1, "read": 1, "report": 1, "role": "System Manager", "share": 1, "submit": 1, "cancel": 1, "amend": 1, "write": 1 } ], "sort_field": "creation", "sort_order": "DESC", "track_changes": 1}Child table: Agreement Term
Section titled “Child table: Agreement Term”Child tables (Table fields) reference a separate DocType with istable: 1. Each row
in the table is a document of the child DocType.
{ "doctype": "DocType", "name": "Agreement Term", "module": "Franchise Management", "istable": 1, "fields": [ { "fieldname": "term_title", "fieldtype": "Data", "in_list_view": 1, "label": "Term Title", "reqd": 1, "columns": 3 }, { "fieldname": "term_description", "fieldtype": "Small Text", "in_list_view": 1, "label": "Description", "reqd": 1, "columns": 5 }, { "fieldname": "is_mandatory", "fieldtype": "Check", "in_list_view": 1, "label": "Mandatory", "default": 1, "columns": 2 }, { "fieldname": "penalty_amount", "fieldtype": "Currency", "in_list_view": 1, "label": "Violation Penalty", "columns": 2 } ], "sort_field": "creation", "sort_order": "DESC", "track_changes": 0}The columns property controls the relative width of each column in the child
table’s list view. They should roughly sum to 10–12.
The agreement controller validates dates and terms, and reacts to the submit/cancel lifecycle by syncing the linked outlet’s status:
import frappefrom frappe.model.document import Documentfrom frappe.utils import getdate, add_years
class FranchiseAgreement(Document): def validate(self): self.validate_dates() self.validate_terms()
def validate_dates(self): if getdate(self.expiry_date) <= getdate(self.agreement_date): frappe.throw("Expiry Date must be after Agreement Date")
def validate_terms(self): if not self.agreement_terms: frappe.throw("At least one Agreement Term is required")
mandatory_count = sum( 1 for term in self.agreement_terms if term.is_mandatory ) if mandatory_count == 0: frappe.throw("At least one term must be marked as Mandatory")
def on_submit(self): self.update_outlet_status()
def on_cancel(self): self.revert_outlet_status()
def update_outlet_status(self): outlet = frappe.get_doc("Franchise Outlet", self.franchise_outlet) outlet.db_set("status", "Active")
def revert_outlet_status(self): # Revert only if no other active agreements remain active_agreements = frappe.db.count( "Franchise Agreement", filters={ "franchise_outlet": self.franchise_outlet, "docstatus": 1, "name": ["!=", self.name], }, ) if active_agreements == 0: outlet = frappe.get_doc("Franchise Outlet", self.franchise_outlet) outlet.db_set("status", "Inactive")Table MultiSelect: many-to-many relationships
Section titled “Table MultiSelect: many-to-many relationships”The Table MultiSelect fieldtype creates a many-to-many relationship. In our
Franchise Outlet we used it for menu items. First, create the link DocType:
{ "doctype": "DocType", "name": "Franchise Menu Item", "module": "Franchise Management", "istable": 1, "fields": [ { "fieldname": "item", "fieldtype": "Link", "in_list_view": 1, "label": "Item", "options": "Item", "reqd": 1 } ]}In the parent DocType (Franchise Outlet), the field references this:
{ "fieldname": "menu_items", "fieldtype": "Table MultiSelect", "label": "Menu Items", "options": "Franchise Menu Item"}This renders as a tag-like selector where users can pick multiple Items without a full child table interface.
Custom fields: extending existing DocTypes
Section titled “Custom fields: extending existing DocTypes”The real power of custom apps is extending ERPNext’s built-in DocTypes without modifying their source code. There are three approaches.
Approach 1: via Desk UI (Customize Form)
Section titled “Approach 1: via Desk UI (Customize Form)”- Go to Customize Form.
- Select “Sales Invoice” from the DocType dropdown.
- Click Add Row in the fields table.
- Add your custom field (e.g., “Franchise Code”).
- Save.
This works, but the customization is stored in the database — not in your app’s code. It won’t transfer to other sites automatically.
Approach 2: via fixtures in hooks.py (recommended)
Section titled “Approach 2: via fixtures in hooks.py (recommended)”This is the version-controlled approach. Define your custom fields, export them as fixtures, and they’ll be applied when your app is installed.
First add the custom fields via Customize Form (as above), then configure fixture
filters in hooks.py:
fixtures = [ { "dt": "Custom Field", "filters": [ ["module", "=", "Franchise Management"] ] }, { "dt": "Property Setter", "filters": [ ["module", "=", "Franchise Management"] ] },]Then export the fixtures to JSON:
bench --site icecream.localhost export-fixtures --app scoopjoyThis creates JSON files under your app’s fixtures/ directory:
Directoryapps/scoopjoy/scoopjoy/
Directoryfixtures/
- custom_field.json
- property_setter.json
These files are committed to Git. When someone installs your app, bench migrate
imports these fixtures.
Approach 3: via the Custom Field API (programmatic)
Section titled “Approach 3: via the Custom Field API (programmatic)”For cases where you need to create custom fields during installation or patches:
import frappefrom frappe.custom.doctype.custom_field.custom_field import create_custom_fields
def setup_custom_fields(): custom_fields = { "Sales Invoice": [ { "fieldname": "franchise_outlet", "fieldtype": "Link", "label": "Franchise Outlet", "options": "Franchise Outlet", "insert_after": "customer_name", "in_standard_filter": 1, }, { "fieldname": "franchise_code", "fieldtype": "Data", "label": "Franchise Code", "fetch_from": "franchise_outlet.franchise_code", "insert_after": "franchise_outlet", "read_only": 1, }, { "fieldname": "outlet_territory", "fieldtype": "Data", "label": "Outlet Territory", "fetch_from": "franchise_outlet.city", "insert_after": "franchise_code", "read_only": 1, }, ], "Sales Order": [ { "fieldname": "franchise_outlet", "fieldtype": "Link", "label": "Franchise Outlet", "options": "Franchise Outlet", "insert_after": "customer_name", }, ], } create_custom_fields(custom_fields)Call this from an after_install hook or a patch:
after_install = "scoopjoy.install.after_install"def after_install(): from scoopjoy.setup import setup_custom_fields setup_custom_fields()Property Setters: modifying existing field properties
Section titled “Property Setters: modifying existing field properties”Property Setters change properties of existing fields without replacing them — for example, making the Customer field mandatory on Sales Invoice, or changing a field’s default value:
from frappe.custom.doctype.property_setter.property_setter import make_property_setter
# Make territory mandatory on Sales Invoicemake_property_setter( "Sales Invoice", # doctype "territory", # fieldname "reqd", # property 1, # value "Check", # property type)
# Change default value for naming seriesmake_property_setter( "Sales Invoice", "naming_series", "default", "FC-INV-.YYYY.-", "Data",)Complete fixture export workflow
Section titled “Complete fixture export workflow”Here’s the full workflow for managing customizations via fixtures:
-
Make your customizations via Customize Form in the Desk.
-
Define fixture filters in
hooks.py(shown above). -
Export fixtures to JSON files:
Terminal window bench --site icecream.localhost export-fixtures --app scoopjoy -
Verify the exported files:
Terminal window ls apps/scoopjoy/scoopjoy/fixtures/# custom_field.json property_setter.json -
Commit to Git:
Terminal window cd ~/frappe-bench/apps/scoopjoygit add scoopjoy/fixtures/git commit -m "Add custom fields for Sales Invoice franchise tracking" -
On another site or developer’s machine, after pulling, run migrate — fixtures are imported automatically:
Terminal window bench --site othersite.localhost migrate