Skip to content

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.

There are two ways to create a DocType.

Via Desk UI (recommended for development):

  1. Navigate to the search bar, type “DocType”, click “New DocType”.
  2. Fill in fields, set properties, arrange the layout.
  3. 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.

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:

apps/scoopjoy/scoopjoy/franchise_management/doctype/franchise_outlet/franchise_outlet.json
{
"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:

apps/scoopjoy/scoopjoy/franchise_management/doctype/franchise_outlet/franchise_outlet.py
import frappe
from 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)}"

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:

  1. Details — basic information, contact, and location
  2. Operations — hours, warehouse, menu
  3. Financials — rent, royalties, bank details
  4. Notes — free-form notes

The naming_rule and autoname properties control how document names are generated. Here are the most common patterns:

Naming Ruleautoname ValueExample Output
Expression (Custom)FO-.{city}.-.###FO-Mumbai-001
By fieldnamefield:outlet_nameMumbai Central Outlet
By “Naming Series” fieldnaming_series:FO-2025-001
Random / Hashhasha1b2c3d4e5
UUIDUUID01945abc-def0-7…
Promptprompt(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.

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 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.

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:

apps/scoopjoy/scoopjoy/franchise_management/doctype/franchise_agreement/franchise_agreement.json
{
"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 tables (Table fields) reference a separate DocType with istable: 1. Each row in the table is a document of the child DocType.

apps/scoopjoy/scoopjoy/franchise_management/doctype/agreement_term/agreement_term.json
{
"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:

apps/scoopjoy/scoopjoy/franchise_management/doctype/franchise_agreement/franchise_agreement.py
import frappe
from frappe.model.document import Document
from 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:

apps/scoopjoy/scoopjoy/franchise_management/doctype/franchise_menu_item/franchise_menu_item.json
{
"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.

  1. Go to Customize Form.
  2. Select “Sales Invoice” from the DocType dropdown.
  3. Click Add Row in the fields table.
  4. Add your custom field (e.g., “Franchise Code”).
  5. 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.

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:

apps/scoopjoy/scoopjoy/hooks.py
fixtures = [
{
"dt": "Custom Field",
"filters": [
["module", "=", "Franchise Management"]
]
},
{
"dt": "Property Setter",
"filters": [
["module", "=", "Franchise Management"]
]
},
]

Then export the fixtures to JSON:

Terminal window
bench --site icecream.localhost export-fixtures --app scoopjoy

This 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:

apps/scoopjoy/scoopjoy/setup.py
import frappe
from 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:

apps/scoopjoy/scoopjoy/hooks.py
after_install = "scoopjoy.install.after_install"
apps/scoopjoy/scoopjoy/install.py
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 Invoice
make_property_setter(
"Sales Invoice", # doctype
"territory", # fieldname
"reqd", # property
1, # value
"Check", # property type
)
# Change default value for naming series
make_property_setter(
"Sales Invoice",
"naming_series",
"default",
"FC-INV-.YYYY.-",
"Data",
)

Here’s the full workflow for managing customizations via fixtures:

  1. Make your customizations via Customize Form in the Desk.

  2. Define fixture filters in hooks.py (shown above).

  3. Export fixtures to JSON files:

    Terminal window
    bench --site icecream.localhost export-fixtures --app scoopjoy
  4. Verify the exported files:

    Terminal window
    ls apps/scoopjoy/scoopjoy/fixtures/
    # custom_field.json property_setter.json
  5. Commit to Git:

    Terminal window
    cd ~/frappe-bench/apps/scoopjoy
    git add scoopjoy/fixtures/
    git commit -m "Add custom fields for Sales Invoice franchise tracking"
  6. On another site or developer’s machine, after pulling, run migrate — fixtures are imported automatically:

    Terminal window
    bench --site othersite.localhost migrate