You upgraded, bench migrate finished clean, then the desk refused to load with ModuleNotFoundError: No module named 'frappe.desk.doctype.workspace_settings'. Nothing you deployed references that module — because Frappe itself removed it. What is still referencing it is a leftover row in your tabNavbar Item table from the version you upgraded from. This post pins the exact commit that removed the module, gives you a two-line bench console fix, and hands you the pre-upgrade checklist plus nightly smoke test that would have caught it before your users did.
The forum thread that surfaces this most cleanly is I have issues with ERPNext after update on discuss.frappe.io — same signature, same fix. I am Manoj, ERPNext and Frappe implementation lead at MithTech in Bengaluru — we run this playbook on every upgrade for our self-hosted clients.
Try it: paste your traceback
If you got here from Google with the traceback still in your terminal, drop it in. The matcher is a client-side regex — nothing leaves the browser — and tells you whether your error is this specific regression or a lookalike.
Client-side regex match on ModuleNotFoundError + workspace_settings to tell you whether your traceback matches this post's regression.
What actually changed in Frappe
The load-bearing fact behind this whole post: Workspace Settings used to be a real Frappe DocType. In our local version-16 checkout (Frappe __version__ = "16.27.1", resolved from ${ERPNEXT_APPS_PATH}/frappe/frappe/__init__.py), the module directory frappe/desk/doctype/workspace_settings/ is entirely absent. So is every reference to it in Python and JavaScript outside the translation .po files.
git log on that directory shows the revert clearly:
a0568ae99e chore: revert workspace settings (2026-01-20)
95d9568a80 chore: revert workspace settings (2026-01-20)
...
52ce3f9f9e feat: Workspace Settings - allow enabling and disabling of workspaces during setup
774d86f642 feat: Workspace Settings - allow enabling and disabling of workspaces during setup
The revert (git describe --contains a0568ae99e → v16.3.0~1^2~17^2~3; first tag containing it: v16.10.0) deletes eight files, including the entire DocType directory and a single line from frappe/patches.txt:
--- a/frappe/patches.txt
+++ b/frappe/patches.txt
@@ -239,7 +239,6 @@ frappe.patches.v15_0.migrate_session_data
frappe.patches.v16_0.switch_default_sort_order
frappe.integrations.doctype.oauth_client.patches.set_default_allowed_role_in_oauth_client
-execute:frappe.db.set_single_value("Workspace Settings", "workspace_setup_completed", 1)
frappe.patches.v16_0.add_app_launcher_in_navbar_settings
frappe.desk.doctype.workspace.patches.update_app
Two things to notice. First, the same commit was cherry-picked back to the version-15 line, so anyone on v15.33+ receives the removal too — it is not a v16-only story. Second, the removed patch is the one that used to seed the Workspace Settings singleton on migrate. Removing the seed is fine on greenfield installs. It is not fine on installs that already had a Navbar Item row pointing at /app/workspace-settings from a build before the revert.
That row is what breaks you.
Why the desk crashes, not bench migrate
bench migrate succeeds because none of the migration steps try to import the removed module. The moment a browser hits the desk, though, Frappe rebuilds the navbar from Navbar Settings and its child table of Navbar Item rows. Each Route-typed row that starts with /app/<something> implicitly asks Python to load a controller for <something>. For every current DocType this succeeds; for the stale Workspace Settings row it fails with:
ModuleNotFoundError: No module named 'frappe.desk.doctype.workspace_settings'
You can see the shape of that navbar rebuild in the patch that Frappe did keep — frappe/patches/v16_0/add_app_launcher_in_navbar_settings.py:11:
navbar_settings = frappe.get_single("Navbar Settings")
settings_items = navbar_settings.as_dict().settings_dropdown
view_website_item_idx = -1
for i, item in enumerate(navbar_settings.settings_dropdown):
if item.get("item_label") == "View Website":
view_website_item_idx = i
settings_items.insert(
view_website_item_idx + 1,
{"item_label": "Apps", "item_type": "Route", "route": "/apps", "is_standard": 1},
)
That patch mutates the same settings_dropdown child table your stale Workspace Settings row lives in. Frappe knows how to add rows during migration; it does not walk the table and evict rows whose target module has been deleted. Someone has to do that, and today that someone is you — for two lines.
The two-line fix, verbatim
Take a backup first
Before you run this, run bench --site your-site backup --with-files and copy the resulting .sql.gz off the server. The command below is safe — one row deletion from tabNavbar Item, no user data — but "take a backup" is the checklist item you want to have followed if anything else goes sideways. The Upgrade Safety Checklist below tracks this for you.
The literal bench console fix — delete the stale Navbar Item row, commit, clear cache — with copy buttons.
frappe.db.delete("Navbar Item", {"item_label": "Workspace Settings"})
frappe.db.commit()# On the ERPNext server, as the frappe user
bench --site your-site.local console
# Then, inside the Python console:
frappe.db.delete("Navbar Item", {"item_label": "Workspace Settings"})
frappe.db.commit()
exit()
# Finally, clear the cache so the navbar rebuilds without the stale row:
bench --site your-site.local clear-cacheThe row you are deleting is a Navbar Item pointing at a DocType Frappe itself removed. No user data, no business records, no accounting tables are touched. Take a routine DB backup first anyway — the checklist below covers that.
If your DB has more than one row (some deployments carried both a top-level Navbar Item and a nested settings_dropdown row), the same delete matches both — the filter is on item_label, not parent, and frappe.db.delete handles the WHERE clause on its own. After bench clear-cache, refresh the desk. It boots.
The correct upgrade workflow, end to end
The reason you are reading this post is that the upgrade path skipped a rehearsal step. Here is the workflow that would have surfaced the bug on staging instead of production.
Snapshot the production DB and sites/ directory
bench --site your-site backup --with-files; tar czf sites.tgz sites/. Copy both off the server. site_config.json also gets its own copy into your password manager — losing db_password or encryption_key is the one non-recoverable failure mode.
Restore the backup onto a throwaway staging site
Same server or a spare VPS, doesn't matter. bench new-site staging.local; bench --site staging.local restore /path/to/backup.sql.gz. Staging must run the exact same OS, Python, Node, and MariaDB versions as production — otherwise the rehearsal misses environment-specific bugs.
Switch staging to the target Frappe / ERPNext branch and migrate
bench switch-to-branch version-16 frappe erpnext; bench --site staging.local migrate. Watch the logs. If migrate succeeds but the desk 500s, you have found this bug on staging — apply the two-line fix here, not in production.
Run the smoke test against staging
Hit /app/home and the top-5 workspace URLs. Log in as a real user role, not just Administrator. Open one Sales Invoice, one Purchase Receipt, one Stock Ledger view. Anything that 500s here would 500 in production tomorrow — fix it in staging.
Only then flip DNS / traffic to the upgraded instance
Keep the old instance warm and reachable at a fallback hostname for at least the first hour after cutover. If a class of user hits a bug your smoke test missed, you can re-point DNS back in seconds — no restore required.
Six pre-upgrade checkboxes — backups, dry-run migrate, staging smoke test, DNS cutover — that persist to your browser between sessions.
What if devops owns the upgrade and accounts owns the workday?
The workflow above assumes one person can pause production, run a rehearsal, and flip DNS at their own tempo. That is not how most SMEs run. Devops queues the upgrade on a Friday evening, accounts finds the desk broken on Monday morning, and neither of them knows what changed over the weekend. The split that actually works:
Devops owns the rehearsal and the rollback SLO
The pre-upgrade checklist and the smoke test are devops artefacts. If either fails, devops does not proceed to production. Accounts are not paged during the rehearsal window.
Accounts owns the smoke-test URL list
Accounts writes down the five workspaces they open first every morning — usually Home, Accounting, Stock, Selling, Buying — and hands the list to devops. Devops encodes that list into the smoke script. This is why the script in the next tool ships with those five defaults.
Both sign off on the rollback trigger
"If the desk 500s for more than N% of users within M minutes of cutover, we roll back." N and M live in a shared runbook. Reading them off a chat message during an outage is how outages become escalations.
At high upgrade cadences you can automate step 3: an n8n workflow that polls the smoke-test endpoint every 5 minutes for the first 60 minutes post-cutover, and Slacks devops the moment failure ratio crosses the rollback threshold. Details below.
Nightly workspace smoke test — the permanent fix
The two-line fix clears today's incident. The nightly smoke test guarantees the next removed-module regression — and there will be one, Frappe ships them every couple of releases — surfaces at 02:30 to your inbox, not at 09:00 to your users.
Three flavours of nightly workspace smoke test: Frappe Server Script (Scheduler), bench execute via cron, and a one-shot Python probe you can wire into any orchestration.
Nightly Server Script that fetches the top-5 workspace URLs and emails you if any return non-200 — so a workspace_settings-style regression is caught before users hit it.
# Server Script → Type: Scheduler Event → Frequency: Daily
# DocType: no linked DocType. Runs as Administrator.
import frappe
import requests
from frappe.utils import get_url
TOP_WORKSPACES = [
"app/home",
"app/accounting",
"app/stock",
"app/selling",
"app/buying",
]
session = requests.Session()
base = get_url()
alerts = []
for path in TOP_WORKSPACES:
try:
r = session.get(f"{base}/{path}", timeout=10, allow_redirects=False)
# 200 or a login-redirect (302 → /login) both mean the route resolves.
if r.status_code not in (200, 302):
alerts.append(f"{path} → HTTP {r.status_code}")
except Exception as e:
alerts.append(f"{path} → {type(e).__name__}: {e}")
if alerts:
frappe.sendmail(
recipients=["ops@yourco.in"],
subject=f"[ERPNext smoke] {len(alerts)} workspace URL(s) failing",
message="<pre>" + "\n".join(alerts) + "</pre>",
)What each recipe actually guarantees
Route resolution
Every top-5 workspace URL returns 200 or a login redirect. A 500 from ModuleNotFoundError fails loudly. This is the check that would have caught workspace_settings on staging.
Controller import
get_controller("Workspace") runs for every Workspace row. If a Workspace's Python controller module has been removed, the assertion fails and the smoke test alerts.
Navbar target validity
Every Navbar Item with an /app/<something> route resolves to an existing DocType. This is the check that surfaces the exact class of bug this post fixes — a Navbar Item pointing at a DocType that no longer exists.
Independent of the desk
bench execute and the boot probe run without a browser. Even if the desk is 500-ing for humans, the smoke test still tells you whether the underlying controllers load.
Deploy order that actually works
- Ship the boot probe first — it is a one-shot Python that a human can run manually as a sanity check.
- Wire it into the Frappe Scheduler as a daily Server Script, mail to devops. Verify you receive at least one green run.
- Only then add the alerting branch. Watching a green inbox for a week before adding alerts avoids paging on the smoke test's own false-positives.
- Never route these alerts through GitHub Actions or paid GitHub services — they belong in your VPS's local cron, your n8n instance, or Frappe's own scheduler.
Upgrade-rollback SLO — a defensible starting draft
Materiality here is not accounting materiality; it is time-to-rollback. Your ops lead needs a written policy that says exactly when to abandon a botched upgrade and re-point traffic to the old instance. Below is a defensible starting point — treat it as a template your ops / engineering lead signs off on, not gospel:
Upgrade Rollback SLO (draft — for ops-lead review): Every ERPNext production upgrade must be preceded by (a) a full DB +
sites/backup less than 30 minutes old, and (b) a fullmigrate → smoke testdry-run on a staging site restored from that backup. The old production instance stays warm and reachable at a fallback hostname for at least 60 minutes post-cutover. If the new instance's smoke test fails any of the top-5 workspace URLs, or more than 1% of unique authenticated users hit a500within the first 30 minutes, devops rolls back within 15 minutes by re-pointing DNS to the fallback hostname. Rollback does not require a data restore because no schema-breaking migrations run on cutover day (schema changes run on the staging migrate the day before, are reviewed, and only then applied to production). Every rollback is followed within 48 hours by a written post-mortem naming the exactModuleNotFoundError/ImportError/ assertion that fired.
Three things to notice about writing this down rather than leaving it to individual judgement:
- Auditors and enterprise buyers love a written SLO. Even a conservative one is dramatically easier to defend at a security review than "we roll back when it looks bad."
- The numbers should match your traffic pattern. 1% of authenticated users on a 20-user SME is zero users; on a 2000-user distributor it is 20 people in support tickets. Scale N and M to your actual usage.
- 60 minutes of warm fallback costs almost nothing. A stopped VM in the same VPC that you paid for anyway is your cheapest insurance policy. See the hosting cost comparison for the maths on why keeping the old instance warm is essentially free.
Fix immediately vs backlog the process
Either way, both columns cost you an afternoon at most. There is no reason to only do the left one.
FAQ
+How do I fix the workspace_settings ModuleNotFoundError in ERPNext?
Take a DB backup, then in bench --site your-site.local console run frappe.db.delete("Navbar Item", {"item_label": "Workspace Settings"}) followed by frappe.db.commit(). Exit the console and run bench --site your-site.local clear-cache. Refresh the desk — it boots.
+Why does bench migrate succeed but the desk still crash?
Because migrate only runs the patches listed in frappe/patches.txt, and none of the current patches try to import the removed module. The desk boot process, however, rebuilds the navbar from Navbar Settings and tries to load a controller for every /app/<route> entry — the stale Workspace Settings row is what fails to import, not migrate itself.
+What version of Frappe / ERPNext does this apply to?
The DocType was reverted in Frappe commit a0568ae99e on 20 January 2026 and first shipped in v16.10.0. The same revert was cherry-picked back to the version-15 line, so v15.33+ installs are affected too. Anything older than v15.33 still has the DocType and this bug does not apply.
+Is it safe to just delete the Navbar Item row like that?
Yes. Navbar Item is a UI configuration table — the row you delete is a link, not business data. No accounting records, no user data, no permissions are affected. The row's target DocType was already removed by Frappe itself; you are only removing the dangling reference.
+Can I run the fix through bench execute instead of dropping into console?
Yes: bench --site your-site.local execute frappe.db.delete --args "['Navbar Item', {'item_label': 'Workspace Settings'}]" — but you still need bench --site your-site.local execute frappe.db.commit after it, and the shell escaping is fiddly. The two-line console form is more legible for a runbook.
+What happens if we skip this and just leave the desk broken?
Every user hitting /app gets a 500. Background jobs (scheduler, workers) keep running, so scheduled emails and integrations still fire — but no human can touch the desk, and any client script or form event tied to a UI action is unreachable. Business-critical exposure grows with every hour.
+Will this recur on the next upgrade?
Not for this specific row — once deleted it is gone. A similar class of bug will recur when Frappe removes some other DocType and leaves stale Navbar Item / Workspace Link / Report rows behind. The nightly smoke test in this post is designed to catch that class, not just this instance.
+Do I need to run the same fix on every site in a multi-tenant bench?
Yes — Navbar Item is site-scoped, so the row lives in each site's DB independently. Loop through your bench's sites and run the fix per site, or wrap it in a small shell script that iterates bench --site $S execute …. The smoke test recipe above already assumes per-site scope.
Related issues you may also hit
- ERPNext v15 → v16 upgrade — the full pre-flight guide — the wider upgrade rehearsal this post fits inside.
- ERPNext v15 vs v16 — what actually changed — the workspace / navbar changes across the version boundary.
- ERPNext v16 workspace guide — how the current Workspace DocType (the one that stayed) is expected to be configured.
- Real ERPNext bugs and their real fixes — sibling triage patterns for GitHub-issue-driven regressions.
- Latest ERPNext version, 2026 — which point release you should actually be on right now.
Closing
Workspace Settings is not a bug in your instance — it is the residue of a DocType Frappe removed after shipping it briefly. Deleting the stale row unblocks the desk in under a minute. The interesting question is not how do I fix this, but why did my upgrade path let this reach production without a rehearsal. Fix that, and the class of bug retires quietly.
Upgrading ERPNext and want the rehearsal done for you?
We run a one-week engagement for self-hosted SMEs — full staging rehearsal, smoke test wired into the scheduler, rollback SLO drafted with your ops lead, and the cutover run alongside your team.