The implementation guide sorts customisation into configuration, extension and modification, and says they age differently. This chapter shows what each one is inside Frappe.
Answer up to three questions below, and the module names the lightest option that does the job, with what an upgrade will cost.
Try it
What do you need to change?
Answer one question at a time. The lightest choice that does the job comes up, with what an upgrade will cost.
What do you need to change?
Frappe v16.52.0 and ERPNext v16.50.0; v15 marked. The cost words are our reading, not a measurement.
Compared
What each choice costs at upgrade
Our reading from where each one lives, not a measured cost.
| Choice | Where it lives | In Git by default | Survives bench update | What can break | You re-test |
|---|---|---|---|---|---|
| Custom Field or Property SetterLow | Database rows (Custom Field, Property Setter) | No, until you export them to an app | Yes | An upstream field with your fieldname; Custom Permissions are replaced on sync | When a DocType you changed gains or renames fields |
| Client Script or Server ScriptLow to medium | Database rows (Client Script, Server Script) | No, until you export them | Yes | The names the script calls change; Server Scripts need server_script_enabled, and their document-event scripts do not run during install or migrate | Every major upgrade, by hand: nothing tests it |
| Custom app using hooksMedium | Your app’s Git repository | Yes | Yes: it is its own repository | A hook’s contract changes (v16: has_permission returning None denies; doc_events handlers cannot commit) | Every upgrade, with your tests |
| Custom app extending a class (v16)Medium | Your app’s Git repository | Yes | Yes | A method you add to, or call through super(), changes upstream | Every upgrade, with your tests |
| Custom app replacing a classHigh | Your app’s Git repository | Yes | The app does; upstream fixes to that controller do not reach it | Silently: the original class changes and yours does not follow; the last app in the list wins | Every release that touches that controller |
| Fork of Frappe or ERPNextHighest | Your fork’s Git repository | Yes | Only if you merge each tag; bench update --reset overwrites local edits | Every merge | Every release |
Custom fields and property setters: rows, not code
Frappe builds a DocType's definition when it loads it: the app's JSON, then the Custom Field rows for that DocType, then the Property Setter rows that override its properties. Nothing in the app's files changes, which is why bench update does not touch them.
They live in the database, not in Git, until you export them to your app. A Custom Field whose fieldname you leave blank gets the prefix custom_. One whose fieldname you type keeps it as typed, and can collide with a field a later release adds. An exported file with sync_on_migrate is re-applied at every migrate, and Custom Permissions are deleted and re-inserted, not merged.
Scripts: quick, and outside Git
A Client Script runs in the browser. A Server Script runs in a restricted Python sandbox, only if server_script_enabled is set in common_site_config.json, and its document-event scripts do not run during install or migrate. Both are database rows, so nothing tests them when you upgrade. They suit a one-site rule. Move the script into an app the day it needs a test.
A custom app: code in your own repository
A separate Frappe app, in its own Git repository, loaded like any other. Hooks (doc_events, scheduler_events, fixtures, the permission hooks) let it react without touching Frappe. It survives bench update because it is a separate repository. It breaks where a hook's contract changes: on v16, has_permission returning None denies, and doc_events handlers cannot commit. Our post lists the rest.
Replacing a class is a fork of one file
override_doctype_class swaps the controller for your subclass. Upstream changes to that controller never reach you unless you merge them by hand. If two apps override one DocType the last one wins, and bench migrate prints a warning.
v16 adds extend_doctype_class. Your mixin sits in front of the controller, and several apps can stack as long as each calls super(). ERPNext made that move itself: Address is an override in v15.122.0 and an extension in v16.50.0.
A fork: you own every merge
bench update backs up, pulls every app, installs requirements, builds, runs patches and restarts. Its --reset flag hard-resets the branches over local edits. A fork means merging every tag yourself, and support windows are finite: v14 ended on 31 January 2026, v15 is planned to end in 2027 and v16 in 2029, so every major version is another merge.
A fork suits a fix you are sending upstream. Pin the commit, and drop the fork when the fix merges. It is a poor fit for a change you mean to keep, because that merge never ends.
How do you choose?
Ask in order: can it be a row, a script, a hook, a mixin? The first yes is the cheapest. The module below the questions lists what each costs at upgrade.
Where this comes from
Read at Frappe v16.52.0 and ERPNext v16.50.0. “(v15)” marks Frappe v15.123.0 and “(ERPNext v15)” marks ERPNext v15.122.0. Each link opens that file at that tag, on that line.
model/meta.py:165–180 · model/meta.py:410–468 · custom_field/custom_field.py:145–160 · modules/utils.py:125–144 · modules/utils.py:205–210 · frappe/migrate.py:187–188 · utils/fixtures.py:16–29 · utils/fixtures.py:67–78 · frappe/migrate.py:177–181 · utils/safe_exec.py:48 · utils/safe_exec.py:86–88 · utils/safe_exec.py:15–17 · server_script/server_script.json:36 · model/base_document.py:116–127 · model/base_document.py:190–208 · model/base_document.py:89 (v15) · commands/update.py:9–12 (bench) · commands/update.py:39–43 (bench) · frappe/permissions.py:494–498 · model/document.py:1641–1648
Official page: Customizing DocTypes, read 14 October 2026. Method: Over-customisation, in the implementation guide.