Frappe Customizations — Writer
Scope: Customisations are needed when an app must change the schema of a DocType that belongs to a different app. They work by creating Custom Field and Property Setter documents that Frappe syncs on bench migrate. This skill handles writing those definitions into the correct JSON file.
Workflow
- Collect the spec — interview if anything is missing (see checklist below)
- Locate the target file
apps/{app}/{app}/{module_scrubbed}/custom/{dt_scrubbed}.jsonInfermodulefrom existingcustom/files in the app, or ask the user. - Write a minimal spec to a temp file (
/tmp/spec.json) — only the fields that matter - Run the skill script to merge the spec into the target file:
# Run from the bench root; script location is discovered automatically BENCH_DIR=$(pwd) PYTHON="$BENCH_DIR/env/bin/python" SCRIPT=$(find "$BENCH_DIR" -path "*/frappe-customizations-writer/scripts/add_customization.py" 2>/dev/null | head -1) "$PYTHON" "$SCRIPT" \ apps/{app}/{app}/{module_scrubbed}/custom/{dt_scrubbed}.json \ /tmp/spec.json - Verify the output file looks correct; delete
/tmp/spec.json
The script creates the target file if it does not exist. No patch is ever needed — the JSON re-applies on every bench migrate.
Interview Checklist
Ask if not already provided:
- Which DocType is being customised? Which app is adding the customisation?
- Which module in that app? (for the file path)
- For each Custom Field:
fieldname,fieldtype,label,insert_after- Optional:
reqd,hidden,read_only,bold,in_list_view,in_standard_filter,options,depends_on,mandatory_depends_on,default,description,fetch_from,fetch_if_empty
- Optional:
- For each Property Setter:
field_name(or omit /nullfor DocType-level),property,value,property_type
Layout fields (Section Break, Column Break, Tab Break) — confirm explicitly before including.
Spec JSON Formats
Write only the fields that matter. The script fills in all remaining fields with correct defaults.
Envelope (multiple entries, preferred):
{
"doctype": "Contact",
"custom_fields": [
{
"dt": "Contact",
"fieldname": "is_billing_contact",
"fieldtype": "Check",
"label": "Is Billing Contact",
"insert_after": "is_primary_contact"
}
],
"property_setters": [
{
"doc_type": "Contact",
"field_name": "phone",
"property": "reqd",
"value": "1",
"property_type": "Check"
}
]
}
Single Custom Field:
{
"dt": "Address",
"fieldname": "tax_category",
"fieldtype": "Link",
"label": "Tax Category",
"insert_after": "fax",
"options": "Tax Category"
}
Single Property Setter:
{
"doc_type": "Address",
"field_name": "fax",
"property": "hidden",
"value": "1",
"property_type": "Check"
}
Script: add_customization.py
Bundled inside this skill folder at scripts/add_customization.py. Its exact location in the file system varies — discover it at runtime:
SCRIPT=$(find "$BENCH_DIR" -path "*/frappe-customizations-writer/scripts/add_customization.py" 2>/dev/null | head -1)
The script is not bundled inside individual apps — it lives alongside this SKILL.md, wherever the skill is installed.
What it does:
- Loads
<target.json>if present, or creates a new file with the correct top-level structure - Normalises each entry to the full Frappe export format — all schema fields, computed
name, timestamps - Sets
moduleon every Custom Field and Property Setter — inferred automatically from the target path (apps/{app}/{app}/{module_dir}/custom/…) and converterd to title-case (e.g.selling→Selling). Override by adding"module": "My Module"to the spec envelope. - Deduplicates: Custom Fields by
fieldname; Property Setters by(doc_type, field_name, property) - Saves with
indent=1, sort_keys=True— identical tofrappe.as_jsonoutput
Example (erpnext app, Contacts module, Contact DocType):
# Run from bench root
BENCH_DIR=$(pwd)
PYTHON="$BENCH_DIR/env/bin/python"
SCRIPT=$(find "$BENCH_DIR" -path "*/frappe-customizations-writer/scripts/add_customization.py" 2>/dev/null | head -1)
"$PYTHON" "$SCRIPT" \
apps/erpnext/erpnext/contacts/custom/contact.json \
/tmp/spec.json
Patches and Hooks — When sync_on_migrate Is Not Enough
Preferred approach:
sync_on_migrateJSON files. They re-apply on everybench migrate, require no code, and are idempotent by default. Only reach for patches or hooks when you need logic that a JSON declaration cannot express.
When to use each mechanism
| Situation | Recommended mechanism |
|---|---|
| Standard field / property setter addition | sync_on_migrate JSON (default) |
| Backfill data into a new custom field | Patch (call sync_customizations first, then write data) |
| Apply a customization conditionally | after_migrate hook |
| Dynamically adjust properties on every migration | after_migrate hook |
| One-time setup only on the very first install | after_install hook |
Critical: Patches do not run on fresh installs — they are skipped entirely when an app is installed for the first time (the site is treated as already up to date). Similarly,
after_installruns only once, on the firstbench install-appfor that app, and never again. Neither mechanism is reliable for ensuring a customization is always present. Usesync_on_migrateJSON orafter_migratefor anything that must exist on every site, whether newly installed or migrated.
Timing reference
bench migrate
└─ pre_model_sync patches
└─ schema updates (ALTER TABLE …)
└─ post_model_sync patches
└─ post_schema_updates ← sync_customizations() runs here
└─ after_migrate hooks ← custom field columns are available here
Calling sync_customizations from a patch
sync_customizations() runs in post_schema_updates — after all patches. DB columns for sync_on_migrate custom fields do not exist yet when any patch runs.
If a patch needs to read or write a column backed by a custom field, call sync_customizations manually at the top before touching those columns:
# myapp/patches/2025/2025_06_01__backfill_my_field.py
import frappe
from frappe.modules.utils import sync_customizations
def execute():
# Pull the custom field columns into the DB before writing to them
sync_customizations(app="myapp")
frappe.db.sql("UPDATE `tabEmployee` SET custom_my_field = 1 WHERE ...")
sync_customizations(app=…) is idempotent — safe to call multiple times. Pass the specific app name to limit scope and avoid re-syncing unrelated apps.
Applying customizations programmatically in a patch
Use Frappe helpers when conditional logic is needed:
# myapp/patches/2025/2025_06_01__conditional_field.py
import frappe
from frappe.custom.doctype.custom_field.custom_field import create_custom_field
from frappe.custom.doctype.property_setter.property_setter import make_property_setter
def execute():
# Only add the field when a feature flag is active
if frappe.db.get_single_value("System Settings", "enable_my_feature"):
create_custom_field("Contact", {
"fieldname": "custom_my_field",
"fieldtype": "Data",
"label": "My Field",
"insert_after": "phone",
})
# Or modify a property conditionally
make_property_setter("Sales Invoice", "customer_name", "reqd", "1", "Check")
These helpers write to the DB immediately; no explicit
frappe.db.commit()is needed.
after_install hook — one-time first-install logic
Warning:
after_installruns only once, on the very firstbench install-app. It does not run on subsequentbench migratecalls, and it does not run for sites that already have the app installed. Any customization created here will be absent on pre-existing sites until they run a patch or migrate hook that re-creates it. Useafter_installonly for logic that truly must happen once (e.g. seeding default records), never for schema customizations that need to survive on existing sites.
# myapp/hooks.py
after_install = "myapp.setup.install.after_install"
# myapp/setup/install.py
import frappe
from frappe.modules.utils import sync_customizations
def after_install():
# sync_on_migrate files are applied automatically at install.
# Only add logic here that cannot live in JSON AND is truly install-only.
_seed_default_records()
after_migrate hook — the safe home for imperative customizations
after_migrate runs on every bench migrate — including fresh installs. It is therefore the correct place for any imperative logic that must be present on all sites:
# myapp/hooks.py
after_migrate = ["myapp.setup.migrate.after_migrate"]
# myapp/setup/migrate.py
from frappe.modules.utils import sync_customizations
def after_migrate():
# Runs on every bench migrate, including fresh installs.
# sync_customizations has already fired, so all sync_on_migrate columns exist.
_apply_dynamic_property_setters()
after_migratefires aftersync_customizations, so allsync_on_migratecolumns are guaranteed to exist.
Prefer after_migrate over after_install for any customization that must be present on every site, not just newly installed ones.
Reference
Full JSON schema, Custom Field defaults, Property Setter defaults, file path convention, ownership detection → REFERENCE.md