About Experience Engineering Projects Infrastructure Blog Contact
ERPNext & Frappe 🧩 9-Part Course · Part 6: Customization

Customizing ERPNext: Fields, Scripts, Workflows & Print Formats

Published: Aug 30, 2026

πŸͺœ The Customization Ladder

Part 1's Customization Decision Tree introduced the core principle in brief: always reach for the lowest-effort tool that solves the problem, and only climb to the next rung when the current one genuinely can't do the job. This article is that principle worked out in full β€” every rung on the ladder, with concrete examples, tradeoffs, and the exact conditions under which you should stop customizing no-code and start writing a custom app (covered fully in Part 7).

The expanded decision tree below adds the branches Part 1 compressed: distinguishing a genuinely new field from a tweak to an existing one, separating UI-only behavior from server-side enforcement, and treating "new business object" as its own rung above "workflow."

Need a simple new field?
Yes β†’ Custom Field / Property Setter
↓ No
Need to change field behavior/visibility without a new field?
Yes β†’ Property Setter alone
↓ No
Need UI-only behavior (show/hide, auto-calculate, confirm dialogs)?
Yes β†’ Client Script
↓ No
Need server-side validation or data mutation on save/submit?
Yes β†’ Server Script (or a custom-app hook if it needs version control)
↓ No
Need a multi-step, role-gated approval process?
Yes β†’ Workflow
↓ No
Need a new business object (own table, list view, permissions)?
Yes β†’ Custom DocType (inside a Custom App)
↓ No
Need deep, reusable, or complex integration logic β†’ build a full Custom App with hooks (see Part 7)

🧱 Custom Field & Property Setter

These are the two lowest, most reversible rungs, and it's easy to conflate them because both are usually created through the same screen β€” "Customize Form" β€” but they do fundamentally different things:

Custom FieldProperty Setter
What it doesAdds a genuinely new column to an existing DocType's tableOverrides a property of an existing field or of the DocType itself β€” no new column
Examplewarranty_months (Int) added to ItemMaking Sales Order's customer field mandatory earlier than the default, or hiding a field for one print format
Typical triggerThe data literally doesn't exist yet anywhere on the DocTypeThe data exists; only its label, visibility, mandatory-ness, or read-only state needs to change

Both are typically created via "Customize Form" in the UI without touching code, and both can be exported as fixtures if they're built alongside a custom app β€” so they travel with version control instead of only existing in one site's database. Part 7 covers fixture export as part of custom app structure in depth; the short version is that a customization made only through the UI on a production site, with no fixture export, effectively doesn't exist anywhere else β€” see the common mistakes section below.

πŸ–±οΈ Client Script

A Client Script runs entirely in the browser, in JavaScript, reacting to form-level events: onload (when the form first renders), refresh (whenever the form redraws), validate (right before a client-side save attempt), and per-field change events named after the fieldname itself β€” e.g. an event named item_code fires when that field changes, including inside a child table row.

Illustrative syntax only. The exact API surface (event names, arguments, available helper methods) has shifted slightly across Frappe versions. Treat the snippet below as a pattern to recognize, not something to paste verbatim β€” verify against your installed version's documentation before shipping it.
frappe.ui.form.on('Sales Order', {
    item_code(frm, cdt, cdn) {
        // fires when item_code changes on a child table row
        let row = locals[cdt][cdn];
        if (row.item_code) {
            // e.g. auto-set a value pulled from the selected item
            frappe.model.set_value(cdt, cdn, 'uom', 'Nos');
        }
    },
    validate(frm) {
        if (!frm.doc.customer) {
            frappe.msgprint('Please select a customer first.');
            frappe.validated = false;
        }
    }
});

What a Client Script cannot be trusted for: security. A Client Script can hide a field, disable a button, or block a save inside the Desk UI β€” but a determined user (or a script) can still call the REST API directly and submit data that never passes through that browser code at all. The client is not a trust boundary. Anything that must always be true β€” a mandatory field, a maximum discount, a status transition rule β€” needs the equivalent check written server-side, in a Server Script or a custom-app hook. See Part 8's REST API deep dive for exactly why an API call bypasses Client Script logic entirely β€” it never loads the form or its JavaScript at all.

🐍 Server Script

A Server Script runs in Python on the server, entered through the UI without needing a custom app to exist first. It hooks into document lifecycle events β€” Before Save, After Save, Before Submit, After Submit, Before Cancel, and others β€” or can run as a scheduled job or an API-callable script independent of any one document.

Illustrative pseudocode only. Server Script's available API (which methods and modules are exposed to it, what's sandboxed) varies by version and by how restrictive the Server Script framework is configured to be on a given site. Verify against your version before relying on this pattern operationally.
# Server Script on Sales Invoice, event: Before Submit
if doc.grand_total > get_customer_credit_limit(doc.customer):
    frappe.throw(
        f"Grand total {doc.grand_total} exceeds the credit limit "
        f"for customer {doc.customer}."
    )

The tradeoff against writing the same check in a custom app's doc_events hook (see Part 1's Building a Custom Frappe App) is real and worth naming explicitly:

Server ScriptCustom app hook
Speed to iterateFast β€” edit and save in the UI, no deploy stepSlower β€” edit code, restart/migrate
Where it livesOnly in that site's databaseIn the app's source tree, in version control
Code review / testingHard β€” no diff, no test harness by defaultNormal software practice β€” PR review, unit tests
PortabilityDoesn't move between sites unless manually recreated or exported as a fixtureInstalls anywhere the app is installed

A reasonable rule of thumb: prototype logic as a Server Script, and once it proves out and the surrounding customizations start accumulating (multiple scripts, multiple DocTypes, real business criticality), migrate it into a proper custom app β€” the full process is Part 7.

πŸ”„ Workflow Engine, In Depth

Part 1's Workflow Engine section introduced the concept briefly; here are the four building blocks that make it up, in detail:

Building blockWhat it is
WorkflowBound to exactly one DocType; defines the whole state machine for documents of that type
Workflow StateA named status such as Draft, Pending Approval, Approved, Rejected β€” layered on top of the core docstatus 0/1/2 concept from Part 4, not a replacement for it. A document can be docstatus=0 (not submitted) while its workflow_state is "Pending Approval," for instance
Workflow Action / TransitionDefines which state a document may move to next, and which button/action triggers that move
RoleWhich Role is permitted to perform a given transition β€” the gatekeeper on each arrow in the state diagram

Walked through as one concrete approval chain β€” a purchase request that needs manager approval and a separate finance release step:

StateAllowed RoleActionNext State
DraftEmployeeSubmit for ApprovalPending Approval
Pending ApprovalDepartment ManagerApproveApproved
Pending ApprovalDepartment ManagerRejectRejected
ApprovedFinanceRelease PaymentCompleted
DraftEmployee
β†’
Pending ApprovalDepartment Manager
β†’
ApprovedFinance
β†’
Completed
πŸ’‘ Workflow permissions layer on top of base permissions, they don't replace them. A user still needs ordinary read/write access to the DocType from the Role Permission Manager (Part 2) in addition to holding the role allowed to perform the current-state transition. Being "Department Manager" doesn't grant access to Purchase Request at all if the base Role Permission for Purchase Request never gave that role read/write in the first place β€” the workflow only decides which of the actions the user is already otherwise allowed to attempt they can trigger from this state.

πŸ”§ Advanced Field Concepts, Practically Applied

Part 4 introduced fetch_from, read_only, and depends_on alongside Link vs Dynamic Link fields as schema-level concepts. Here's what they look like as actual customization decisions:

PropertyExample expressionEffect
fetch_fromcustomer.customer_groupAuto-populates a field from the linked Customer's customer_group value the moment a Customer is selected
depends_oneval:doc.is_recurring==1Shows a "Recurrence Frequency" field only when the is_recurring checkbox is ticked; hidden and typically cleared otherwise
read_only_depends_oneval:doc.status=="Approved"Locks a field to read-only once the document reaches a given status, without needing a separate workflow or Property Setter per state

All three are set the same way a Custom Field or Property Setter is created β€” through "Customize Form" β€” which is why they belong in this article rather than Part 4: they're schema concepts made real through the customization tools, not new theory.

⚠️ Common Customization Mistakes

#MistakeWhy it bites
1Putting business-critical validation only in a Client ScriptBypassable via direct API calls β€” see Client Script above; the check simply never runs
2Accumulating dozens of ad-hoc Server Scripts instead of consolidating into a custom appNo version control, no code review, no test coverage, and no easy way to see what logic exists at all once it grows past a handful of scripts
3Making too many fields mandatory via Property SetterBreaks standard ERPNext workflows and reports that assume those fields stay optional β€” imports, integrations, and built-in reports can fail silently or reject data
4Not exporting customizations as fixturesThe customization silently doesn't exist on a freshly provisioned staging or production site β€” it only lived in one site's database
5Building a Workflow for what's really just a permission checkAdds unnecessary state overhead (extra states, transitions, and a workflow_state field to maintain) when a Role Permission or User Permission from Part 2 would do the same job directly

πŸ—ΊοΈ What's Next

This article expanded Part 1's brief Customization Decision Tree and Workflow Engine sections into the full no-code-to-code customization ladder. Two directions from here:

  • If no-code customization has stopped being enough β€” you need new DocTypes, real version control, or logic too complex for a Server Script β€” the next step is Part 7: Custom Frappe App Development.
  • If any of the schema concepts here (Link fields, child tables, fetch_from, naming conventions) felt unfamiliar, Part 4: DocTypes & Database Relationships covers the underlying data model in full.
↑