Customizations API
Override doctypes from other apps with version-controlled JSON and Python controller pairs instead of Customize Form.
On this page
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 DocTypefieldname— Internal field namefieldtype— Frappe field type (Link,Data,Float,Table,Currency,Int,Percent, etc.)options— For Link/Table: the linked DocTypeinsert_after— Position: placed after this existing fieldis_system_generated— Mark as1to distinguish from user-created custom fieldsmodule— Your app's module namename— 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"):
namepattern:{DocType}-{field_name}-{property}field_nameidentifies the target field
DocType-level (doctype_or_field: "DocType"):
namepattern:{DocType}-main-{property}field_nameis omittedUsed 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 (Doctype Form Connections)
{
"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 | NoneSee 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
| Need | Mechanism |
|---|---|
| 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 overriding | doc_events in hooks.py |
| Event hooks that apply to all DocTypes | doc_events with "*" key |
Multiple Approaches Compared
| Approach | Version Controlled | Team Friendly | Survives Migrate |
|---|---|---|---|
| Customize Form UI | No (stored in DB) | No (conflicts) | Fragile |
custom/*.json with sync_on_migrate | Yes | Yes | Yes |
| Fixtures | Yes | Yes | Requires explicit import |
The custom/*.json approach is strongly preferred because it:
Lives in version control alongside the override code
Auto-syncs on every
bench migratePairs 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.