An ERPNext v16 custom app not building after a v15→v16 upgrade is almost always one of six things, in this order: the base image is on Python 3.11 when v16 needs 3.14+, Node is on 22 when v16 needs 24+, your has_permission hook returns None instead of True, your hooks.py tries to commit inside a document event, a frappe.db.get_value() comparison is failing because v16 casts to native types, or you're calling one of the functions v16 removed outright. Every one of these breaks silently on v15 and hard on v16 — this post walks the fix ladder for each.
Every v16 upgrade ticket we take starts the same way: a working custom app, an apt-get-clean bench, and a bench install-app that dies at the first import. The good news is that the failure modes are finite. I lead the ERPNext and Frappe upgrade practice at MithTech, a Bangalore-based engineering team of 35 that designs, customises and operates business-critical software for complex organisations. This is the ladder we walk before we touch a single line of the client's app code.
Why does a custom Frappe app fail to build on v16?
Answer
An ERPNext v16 custom app not building is almost always caused by an environment mismatch or a small number of API changes upstream in frappe. Python 3.14 and Node 24 are hard prerequisites, has_permission hook semantics changed, hooks.py document events lost their ability to commit, and a handful of functions were removed outright. Each of these produces a distinct error signature — none of them requires you to rewrite your app.
Skimmable summary: the failure is in the environment or in six specific APIs, not in your business logic.
The official Migrating to version 16 guide on the Frappe wiki is the source of truth for the API changes below. The ladder walks the causes in the order they are most likely to fire during bench install-app or the first request after a bench migrate.
Is your Python or Node too old for v16?
Skimmable summary: two version bumps that fail loudly but not helpfully.
v16 requires Python 3.14+ and Node.js 24+. If either is below, the failure looks unrelated:
- Python 3.11 or 3.12: the first
import frappeproduces aSyntaxErroron modern pattern-match ortypestatement syntax used inside frappe. The stack trace points into frappe internals, not your app. - Node 22 or below:
bench build(or theassets-rebuildcontainer in Docker) throws aReferenceErroror an ESM parse error deep inside the Vite / esbuild toolchain, again pointing at frappe assets, not yours.
Check both first:
python3 --version # want 3.14 or newer
node --version # want 24 or newer
On Ubuntu 22.04 or 24.04 you'll need to install Python 3.14 from deadsnakes and Node 24 from NodeSource — the distribution packages are still on 3.10/3.12 and Node 20 as of writing. Rebuild the bench virtualenv against the new Python before you touch anything else:
bench setup env --python /usr/bin/python3.14
Which hooks.py changes silently break a custom app on v16?
Skimmable summary: two hook contracts changed in ways that fail without any error message.
has_permission must return True explicitly. Every custom permission hook of the shape below is now wrong on v16:
# v15: worked because None was treated as "allow"
def has_permission(doc, ptype, user):
if some_condition(doc, user):
return True
# implicit None → v15: allow, v16: silently deny
On v16 the return contract is strict: return True to allow, False to deny, None denies. Any hook that returned nothing on the fall-through path is now a silent access denial for every user who hits that path. Grep your custom app for def has_permission and audit each one.
Document hooks cannot commit transactions. If your hooks.py maps a doc_events handler that called frappe.db.commit() inside on_submit, on_update, after_insert, or after_save, the commit is now a no-op. The pattern:
# hooks.py
doc_events = {
"Sales Invoice": {
"on_submit": "your_app.utils.push_to_external"
}
}
# your_app/utils.py — this used to work on v15
def push_to_external(doc, method):
call_external_api(doc)
frappe.db.commit() # ← no-op on v16
If the downstream side effect needs to survive an interim rollback, move it into a background job:
def push_to_external(doc, method):
frappe.enqueue("your_app.utils._do_push", doc_name=doc.name)
def _do_push(doc_name):
doc = frappe.get_doc("Sales Invoice", doc_name)
call_external_api(doc)
# frappe.db.commit() here works because we're outside the document hook
The same rule applies to frappe.sendmail(..., now=True) — it used to commit implicitly on v15 and does not on v16.
Which database calls stopped returning strings on v16?
Skimmable summary: db.get_value() casts to native types now, breaking any string comparison of a boolean or integer field.
Before v16, frappe.db.get_value("Settings", "Settings", "enabled") returned "1" — a string. Every codebase has code that compared it as a string:
# v15: True — this worked
if frappe.db.get_value("Settings", "Settings", "enabled") == "1":
do_the_thing()
# v16: False — the value is now the integer 1
if frappe.db.get_value("Settings", "Settings", "enabled") == 1:
do_the_thing()
The same change hits every db.get_value() and db.get_values() return that used to be a string on a numeric or check-field. Grep your app for the four suspects: == "1", == "0", != "1", != "0", and audit each site.
There is a second, more subtle change in the same area: the value cache moved from a flat tuple-keyed dictionary to a nested defaultdict. If your app called frappe.db.value_cache.pop(("User", "Guest", "full_name"), None) (rare, but production apps do this to invalidate specific cache entries), that call now silently does nothing on v16. The new form is frappe.db.value_cache["User"]["Guest"].pop("full_name", None).
What did v16 sort order change to, and what breaks?
Skimmable summary: default sort is now creation, not modified — silently reorders every unsorted list.
Every frappe.get_all(), frappe.get_list(), frappe.db.get_value(), frappe.db.get_values(), and frappe.qb.get_query() that did not pass an explicit order_by now defaults to creation desc instead of modified desc. If your code depended on the old order (for example, "most recently touched invoice for this customer"), it now returns "first-created invoice" and the business logic is silently wrong.
Two clean fixes, depending on intent:
# If you always wanted the most recently touched record, be explicit
frappe.get_all("Sales Invoice", filters={"customer": customer}, order_by="modified desc", limit=1)
# If you wanted the first-created (which happens to match the new default), no change
frappe.get_all("Sales Invoice", filters={"customer": customer}, limit=1)
Grep for frappe.get_all(, frappe.get_list(, frappe.db.get_value(, frappe.qb.get_query( and add an explicit order_by wherever the caller cares. The migration wiki is explicit that this change was made because creation is a stable index and modified triggered constant index churn — the reasoning is sound, but it is a behavioural break.
Which functions were removed outright?
Skimmable summary: a small list, but every one is an ImportError if your app touches it.
10 rows · click a column to sort
frappe.get_lang_dict() | Use per-user language APIs (see wiki) |
frappe.translate.get_dict() | Removed with no direct replacement — audit callers |
frappe.translate.get_lang_js() | Removed |
frappe.translate.get_dict_from_hooks() | Removed |
frappe.flags.in_test | frappe.in_test |
frappe.permission.has_permission(..., raise_exception=...) | Same call, replace raise_exception with print_logs |
frappe.geo.country_info.get_translated_dict() | get_translated_countries() |
make_bank_account whitelisted method | frappe.new_doc("Bank Account") in your own code |
make_pricing_rule whitelisted method | frappe.new_doc("Pricing Rule") in your own code |
bleach.clean (transitively — Frappe swapped) | nh3.clean — forbidden tags are stripped, not encoded |
The ERPNext-side moves are documented in the Migration Guide To ERPNext Version 16 wiki, and additionally four sub-apps were moved to standalone repositories on v16 — if your custom app imports from any of these, they are now separate installs:
- Energy Points →
frappe/eps - Newsletter →
frappe/newsletter - Backup Integrations →
frappe/offsite_backups - Blog →
frappe/blog
bench get-app <the-new-app> and bench --site your-site install-app <the-new-app> restores functionality.
What is the pre-flight before you touch the bench?
Verify v15 is clean
bench --site your-site.local migrate should complete without an error, and bench doctor should show a healthy scheduler + workers. Fix any v15 red before you continue. Upgrading a broken bench is the single most common way a two-hour job becomes a two-day one.
Take a full backup of every site on the bench
bench --site your-site.local backup --with-files — and copy the resulting sql.gz plus files-tarballs off the box. The v16 upgrade touches schema; a rollback without a pre-upgrade backup means data loss.
Confirm Python 3.14+ and Node 24+ are installed
python3.14 --version and node --version. Install both from a source that stays current (deadsnakes for Python, NodeSource for Node on Debian/Ubuntu). Do not skip this and hope for the best.
Rebuild the bench virtualenv on the new Python
bench setup env --python /usr/bin/python3.14. This recreates the bench's virtualenv against Python 3.14 and reinstalls dependencies. bench pip list should show the versions consistent with Frappe v16's pyproject.toml.
Grep your custom app for the six known-break patterns
Before any upgrade command: search for def has_permission, frappe.db.commit( inside hooks.py-referenced modules, == "1" / == "0" on db.get_value returns, frappe.flags.in_test, raise_exception= in permission calls, and imports from Newsletter/Blog/Energy Points/Backup Integrations. Fix each in the branch, then commit before you switch the bench to v16.
Switch and rebuild
bench switch-to-branch version-16 frappe erpnext --upgrade, then bench setup requirements, then bench build. Any error at this stage is a version or dependency problem — read the message before you Google it.
Migrate the sites
bench --site your-site.local migrate. This applies the schema changes and runs patches. Watch the log: patches print progress and errors distinguish themselves clearly.
Reinstall your custom app if it drifted
bench --site your-site.local install-app your_custom_app. If it errors here after all the above, the error will name the exact API or import that failed — walk to the matching section above and fix it in your app's code, then repeat.
What is the fix order after a broken upgrade?
If you skipped the pre-flight and the bench is now half-upgraded, do these in order — do not skip:
- Confirm the Frappe branch.
bench versionshould listfrappe 16.xanderpnext 16.x. If one is on 15 and the other on 16, the mismatch is your first problem — get both on 16. - Run
bench upgrade --patch. This runs any deferred patches that a mid-flight upgrade left behind. If it errors, the error names the specific site and app; fix that first. bench buildon a clean cache.bench build --no-cachebypasses the asset cache that mid-flight upgrades sometimes poison. If the build errors, it is almost always Node 22 — see the version check above.bench migrate. Applies the DB schema. Watch forRestoreException— that usually means a hook is failing under the new API, and the traceback points at the specific hook.bench install-app your_custom_app --force. If your app was partially uninstalled by the failed upgrade,--forcereinstalls without complaining about the pre-existing partial state.
Adjacent breakage that looks like "custom app not building"
Three problems that present as build/install failures but are actually infrastructure issues:
- Assets 404 after
bench build— a static-file misconfiguration in split-containerfrappe_dockerdeployments, unrelated to your app. See the frappe_docker asset break guide. - Background jobs stopped after the upgrade — usually the scheduler being paused mid-upgrade, not a code issue. See ERPNext v16 background jobs not running for the diagnostic ladder.
bench install-appsucceeds but the app is not visible — check the app is listed ininstalled_appsinsites/your-site/site_config.json. Occasionally the install completes but the site config write fails silently.
FAQ
The questions we get most often on this after a client's own team attempts the upgrade first.
Why does bench install-app fail on v16 but worked on v15?
Almost always a Python or Node version mismatch. v16 needs Python 3.14+ and Node 24+; v15 was fine on 3.11 and Node 22. If the traceback points into frappe internals rather than your app, the versions are wrong. Run python3 --version and node --version first, before you read the traceback.
How do I know if my has_permission hook is silently denying access?
Two ways. Fast check: temporarily add return True at the top of every custom has_permission and see if the access problem goes away — if it does, one of the fall-through paths was returning None. Rigorous check: grep for def has_permission across your app, and confirm every function has an explicit return True or return False on every branch.
Do I need to rewrite my custom app for v16?
No. The v16 changes are small and targeted — you fix the specific patterns listed in this post, not the app architecture. A typical custom app of five or ten DocTypes plus a few hooks needs a few dozen small edits, not a rewrite. If someone is quoting you a rewrite, get a second opinion.
Is v16 stable enough to run in production yet?
Version 16 has been through beta and the version-16 branch is where new development lands as of writing. Whether it is stable enough for a given production instance depends on the specific apps installed and how heavily-customised the deployment is — no one else can answer this for your bench. If in doubt, run v16 alongside v15 on a copy of the site for a few weeks before switching.
v15 to v16 upgrade broke your custom app?
We build and maintain custom Frappe apps as part of larger enterprise deployments for manufacturers, distributors, schools, financial services operators and multi-location businesses across India. If your v16 upgrade is stuck on a custom-app error you cannot place, we will help you find the specific API change that fires and get the bench moving again.