πͺ 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."
π§± 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 Field | Property Setter | |
|---|---|---|
| What it does | Adds a genuinely new column to an existing DocType's table | Overrides a property of an existing field or of the DocType itself β no new column |
| Example | warranty_months (Int) added to Item | Making Sales Order's customer field mandatory earlier than the default, or hiding a field for one print format |
| Typical trigger | The data literally doesn't exist yet anywhere on the DocType | The 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.
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.
# 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 Script | Custom app hook | |
|---|---|---|
| Speed to iterate | Fast β edit and save in the UI, no deploy step | Slower β edit code, restart/migrate |
| Where it lives | Only in that site's database | In the app's source tree, in version control |
| Code review / testing | Hard β no diff, no test harness by default | Normal software practice β PR review, unit tests |
| Portability | Doesn't move between sites unless manually recreated or exported as a fixture | Installs 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 block | What it is |
|---|---|
| Workflow | Bound to exactly one DocType; defines the whole state machine for documents of that type |
| Workflow State | A 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 / Transition | Defines which state a document may move to next, and which button/action triggers that move |
| Role | Which 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:
| State | Allowed Role | Action | Next State |
|---|---|---|---|
| Draft | Employee | Submit for Approval | Pending Approval |
| Pending Approval | Department Manager | Approve | Approved |
| Pending Approval | Department Manager | Reject | Rejected |
| Approved | Finance | Release Payment | Completed |
π¨οΈ Print Format, Report Builder & Web Form
Print Format
A Print Format is a customizable document layout used to render a document β an invoice, an order, a delivery note β as a PDF or printed page. Depending on version, it's built either as an HTML/Jinja template with full control over markup, or through a drag-and-drop builder for simpler layouts. PDF rendering itself is handled by wkhtmltopdf, the same rendering engine listed among Part 1's installation prerequisites β if PDF generation breaks on a server, wkhtmltopdf is usually the first thing to check.
Report Builder
Report Builder is a no-code way to create a saved, filtered, grouped, and sorted view over a single DocType's data β pick columns, add filters, group by a field, save it β without writing a Query Report or Script Report. It's the right tool when what's needed is "a specific slice of this DocType's data, saved for reuse," and the wrong tool once the requirement needs joins across DocTypes or computed columns, at which point a Script Report (Python-backed) is the next rung up β reporting architecture generally is touched on further in later parts of this series as it comes up.
Web Form
A Web Form is a simplified, public-facing form bound to one DocType, used to let portal users β customers, job applicants, and other non-Desk users β create or edit specific records without ever logging into the Desk interface. This ties directly back to Part 2's discussion of user types: a Website User with no Desk access can still submit a support Issue, apply for a job, or view their own Sales Orders entirely through Web Forms and the portal, while every permission rule from the Role Permission Manager still applies underneath.
π§ 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:
| Property | Example expression | Effect |
|---|---|---|
fetch_from | customer.customer_group | Auto-populates a field from the linked Customer's customer_group value the moment a Customer is selected |
depends_on | eval:doc.is_recurring==1 | Shows a "Recurrence Frequency" field only when the is_recurring checkbox is ticked; hidden and typically cleared otherwise |
read_only_depends_on | eval: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
| # | Mistake | Why it bites |
|---|---|---|
| 1 | Putting business-critical validation only in a Client Script | Bypassable via direct API calls β see Client Script above; the check simply never runs |
| 2 | Accumulating dozens of ad-hoc Server Scripts instead of consolidating into a custom app | No 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 |
| 3 | Making too many fields mandatory via Property Setter | Breaks standard ERPNext workflows and reports that assume those fields stay optional β imports, integrations, and built-in reports can fail silently or reject data |
| 4 | Not exporting customizations as fixtures | The customization silently doesn't exist on a freshly provisioned staging or production site β it only lived in one site's database |
| 5 | Building a Workflow for what's really just a permission check | Adds 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.