Skip to content
Skip to the article
In Frappe: 20 articles
Frappe

Customizations API

Override doctypes from other apps with version-controlled JSON and Python controller pairs instead of Customize Form.

Updated
Tags
  • frappe
  • customization
  • doctype
  • python
Reading time
5 min

Override doctypes from other apps (Frappe, ERPNext, etc.) using hand-crafted JSON + Python controller pairs. This is the preferred alternative to the Customize Form UI for version-controlled, app-distributed customizations.

File Structure

Place customization files in <app>/<app>/custom/:

myapp/myapp/custom/
├── item.json          # Custom fields, property setters, links for Item
├── item.py            # Controller override for Item
├── bom.json           # Custom fields for BOM
├── bom.py             # Controller override for BOM
├── bom_item.json      # Custom fields for BOM Item (child table)
├── bom_item.py        # Controller override for BOM Item
└── notification.json  # Custom fields only (no .py needed)

No __init__.py — this is not a Python package, just a directory of paired files.

Naming convention: Files are named after the DocType in snake_case (e.g., BOM Item → bom_item.json/bom_item.py).

JSON Customization File

Each .json file follows this schema:

{
    "custom_fields": [...],
    "custom_perms": [],
    "doctype": "Item",
    "links": [...],
    "property_setters": [...],
    "sync_on_migrate": 1
}

The "sync_on_migrate": 1 flag tells Frappe to automatically apply these customizations during bench migrate. No explicit registration in hooks.py is needed for the JSON files.

Custom Fields

{
    "custom_fields": [
        {
            "dt": "Item",
            "fieldname": "valuation_uom",
            "fieldtype": "Link",
            "options": "UOM",
            "insert_after": "valuation_rate",
            "is_system_generated": 1,
            "label": "Valuation UOM",
            "module": "MyApp",
            "name": "Item-valuation_uom"
        },
        {
            "dt": "BOM",
            "fieldname": "part_group_measurements",
            "fieldtype": "Table",
            "options": "BOM Part Group Measurement",
            "insert_after": "items",
            "is_system_generated": 1,
            "label": "Part Groups",
            "module": "MyApp",
            "name": "BOM-part_group_measurements"
        }
    ]
}

Key properties:

  • dt — Target DocType

  • fieldname — Internal field name

  • fieldtype — Frappe field type (Link, Data, Float, Table, Currency, Int, Percent, etc.)

  • options — For Link/Table: the linked DocType

  • insert_after — Position: placed after this existing field

  • is_system_generated — Mark as 1 to distinguish from user-created custom fields

  • module — Your app's module name

  • name — Unique identifier following pattern {DocType}-{fieldname}

  • columns, in_list_view, read_only — Optional display properties

Property Setters

Modify properties of existing standard fields:

{
    "property_setters": [
        {
            "doc_type": "BOM Item",
            "doctype_or_field": "DocField",
            "field_name": "item_code",
            "is_system_generated": 1,
            "module": "MyApp",
            "name": "BOM Item-item_code-columns",
            "property": "columns",
            "property_type": "Int",
            "value": "1"
        },
        {
            "doc_type": "Item",
            "doctype_or_field": "DocType",
            "is_system_generated": 1,
            "module": "MyApp",
            "name": "Item-main-search_fields",
            "property": "search_fields",
            "property_type": "Data",
            "value": "item_code,item_name,customer_code"
        }
    ]
}

Field-level (doctype_or_field: "DocField"):

  • name pattern: {DocType}-{field_name}-{property}

  • field_name identifies the target field

DocType-level (doctype_or_field: "DocType"):

  • name pattern: {DocType}-main-{property}

  • field_name is omitted

  • Used for global doctype properties like search_fields, show_title_field_in_link

Workaround: JSON values in Property Setters (frappe/frappe#37967)

Frappe requires all value entries in property_setters to be strings. For complex properties like field_order, this means writing the entire array as a single escaped JSON string — unreadable and hard to maintain.

The problem — this causes frappe.exceptions.ValidationError: Value for Set Value cannot be a list at migrate time:

{
    "doc_type": "Purchase Order Item",
    "doctype_or_field": "DocType",
    "name": "Purchase Order Item-field_order",
    "property": "field_order",
    "property_type": "Data",
    "value": ["item_code", "item_name", "qty", "..."]
}

The fix — override the PropertySetter controller to auto-serialize non-string values. Place this in <app>/<app>/custom/property_setter.py:

import json
import frappe
from frappe import _
from frappe.custom.doctype.property_setter.property_setter import PropertySetter as BasePropertySetter


class PropertySetter(BasePropertySetter):
    """Custom Property Setter override to support JSON-value customizations."""

    def validate(self) -> None:
        """Serialize non-string values to JSON before Frappe validates."""
        if not isinstance(self.value, str):
            try:
                self.value = json.dumps(self.value)
            except (TypeError, ValueError):
                frappe.throw(_("Invalid JSON value for Property Setter: {0}").format(self.value))

        super().validate()

Register it in hooks.py:

override_doctype_class = {
    "Property Setter": "myapp.myapp.custom.property_setter.PropertySetter",
}

With this in place, value can be a native JSON array or object in your .json files and will be serialized automatically at migrate time. String values are passed through unchanged, so existing customizations are unaffected.

{
    "links": [
        {
            "custom": 1,
            "group": "Reference",
            "link_doctype": "Sales Quote",
            "link_fieldname": "final_bom",
            "parent": "BOM",
            "parentfield": "links",
            "parenttype": "Customize Form",
            "is_child_table": 0,
            "table_fieldname": null,
            "parent_doctype": null
        }
    ]
}

This adds a "Sales Quote" link in the BOM form's sidebar, showing all Sales Quotes where final_bom references the current BOM.

Python Controller Override

Pair a .py file with the .json to override the controller class:

from erpnext.stock.doctype.item.item import Item as BaseItem
from frappe.types import DF

class Item(BaseItem):
    """Custom Item override with additional field types."""

    # Custom fields from item.json — annotated for type checking
    valuation_uom: DF.Link | None

See Type Annotations & Controller Overrides for comprehensive typing patterns.

Registration in hooks.py

The JSON files sync automatically via sync_on_migrate: 1. The Python overrides must be registered:

override_doctype_class = {
    "Item": "myapp.myapp.custom.item.Item",
    "BOM": "myapp.myapp.custom.bom.BOM",
    "BOM Item": "myapp.myapp.custom.bom_item.BOMItem",
    "Customer": "myapp.myapp.custom.customer.Customer",
}

Key: Standard DocType name → Value: Dotted Python path to override class.

Frappe replaces the standard class with the override whenever that DocType is loaded via frappe.get_doc().

When to Use Each Component

NeedMechanism
Add fields to someone else's DocType.json with custom_fields
Change properties of existing fields.json with property_setters
Add sidebar links.json with links
Custom permissions.json with custom_perms
Override methods (validate, save, etc.).py controller + override_doctype_class
Hook into document events without overridingdoc_events in hooks.py
Event hooks that apply to all DocTypesdoc_events with "*" key

Multiple Approaches Compared

ApproachVersion ControlledTeam FriendlySurvives Migrate
Customize Form UINo (stored in DB)No (conflicts)Fragile
custom/*.json with sync_on_migrateYesYesYes
FixturesYesYesRequires explicit import

The custom/*.json approach is strongly preferred because it:

  • Lives in version control alongside the override code

  • Auto-syncs on every bench migrate

  • Pairs naturally with Python controller overrides

  • Uses the same schema as Frappe's internal customization storage

This article is in the public domain (CC0 1.0), code samples included. Use it however helps you.