Odoo Upgrades That Lie: noupdate Records, Cached Manifests and Docs You Can't Trust
Three 'successful' upgrades that changed nothing, a misleading ir_model_access error, an API key that can't log in, and repo docs that would have overcharged every order. The Odoo 18 traps that cost me real time, why they happen, and what works.

I rewrote all seven customer email templates in an Odoo 18 addon, bumped the version and ran the upgrade. The emails were the old design. I ran it again. Still old. A third time. Still old. No errors, a green "upgraded," and nothing changed.
That week on an Odoo 18 e-commerce project, a headless storefront and an operations console in front of an Odoo backend, taught me that Odoo has several ways to tell you something worked when it didn't. These are the ones that cost me real time, why they happen, and what I do now.
Lie #1: "upgraded" while your XML was ignored
Odoo data files can wrap records in . It's a sensible feature. Seeded records like email templates are created once on install, and later upgrades leave them alone, so the business can edit the wording without the next deploy overwriting it.
The catch is that your own changes don't land either. Editing the XML of a noupdate record and upgrading does nothing, on purpose.
The obvious workaround is to clear the stored flag in a pre-migration:
UPDATE ir_model_data SET noupdate = false WHERE module = '...';
That fails silently too. I only understood why after reading odoo/tools/convert.py. The loader checks the XML attribute before it ever reads the stored flag:
if self.noupdate and self.mode != 'init':
if record := env['ir.model.data']._load_xmlid(xid):
...skip...
The database flag never gets a say. The noupdate="1" in the file wins.
What works
A post-migration that writes the new values itself, parsing them out of the module's own data file with odoo.tools.file_path and ElementTree. That keeps the XML as the single source of truth, so there's no second copy of the template in Python to drift. Write only the fields you're redesigning, and leave neighbouring records in the same file alone.
Then guard the migration with a version stamp inside the content, for example an attribute like data-mail-version="2" on the email's outer wrapper. Without it you end up checking whether the template "looks redesigned," and the second redesign will match that check and never arrive. With a stamp, the next redesign bumps both the attribute and the constant, ships a new migration folder, and lands cleanly on databases that took the previous version.
One subtlety helps you here: new records inside a noupdate block are still created on upgrade. noupdate only blocks updates to existing ones. Adding a seeded row works. Changing one needs a migration.
Lie #2: the manifest you just edited isn't the manifest Odoo sees
The next upgrade failed with an error that pointed me in the wrong direction:
null value in column "model_id" ... ir_model_access
I spent a while reading my ir.model.access.csv. The CSV was fine.
The real cause: Odoo caches manifests per process. I'd added a dependency in __manifest__.py, but calling update_list() on the running server still reported the old depends. The module kept its old position in the load graph, so a model from the new dependency was an "unknown comodel," my module's new models never reached ir_model, and the access CSV failed because the model it referred to didn't exist yet.
The order that works locally, every time:
1. Restart the Odoo container whenever __manifest__.py changes.
2. Update the apps list.
3. Upgrade the module.
4. Restart again so the running workers hold the new registry.
The good news is that a failed upgrade rolls back cleanly and the module stays installed at its old version. Still, two failed attempts and a misleading error is a high price for a missing restart.
Use the CLI, not the button
Clicking Upgrade in the UI is slow and hides the log. From the CLI it's faster, it's the only way to run the addon's tests, and you see every line:
docker exec odoo odoo -c /etc/odoo/odoo.conf \
--db_host=postgres --db_user=$DB_USER --db_password=$DB_PASS \
-d mydb -u my_module --stop-after-init --no-http
There's a trap in the official image here. Its config file has no database host, user or password, because the entrypoint injects them from environment variables. A bare odoo -d mydb -u my_module inside the container tries a local Unix socket, fails, and then helpfully tries to create a database. Pass the connection flags explicitly. Add --test-enable to run tests, and point that at a separate CI database, not the one you're developing against.
Lie #3: the docs
The repo had a tidy docs/ folder describing the data model. Before building checkout I checked its claims against the live database over RPC. Several were simply wrong:
- •Prices were tax-inclusive. The company setting was "tax included" and the sales tax had no per-tax override. The docs said the opposite, and the checkout total component added tax on top. Shipped as written, every order would have been overcharged by the tax rate.
- •Product tags had no brackets. The tag parser required a bracketed format, so it returned
nullfor every real tag. Tag filtering would have been dead on arrival. - •The wishlist model didn't exist. The storefront's client code targeted a model whose module wasn't installed.
- •The public website category tree was nearly empty. The real hierarchy lived in the internal product categories.
- •Only a handful of thousands of products were actually published to the website.
- •In Odoo 18, the stock context key is
warehouse_id, notwarehouse.
None of these were exotic. They were the ordinary drift you get when docs are written from intent and the database is configured by people doing their jobs. My rule now: before trusting any data-shape claim, check it against the live database, and write the verified facts into the plan document so the next person doesn't have to.
Lie #4: the API key that can't log in
The headless frontend talks to Odoo over JSON-RPC with an API key, and that works fine. So I assumed I could use the same key to sign into the operations console and check my UI changes with real data.
You can't. /web/session/authenticate rejects API keys with AccessDenied. Keys only work on the RPC endpoints (/jsonrpc, /web/dataset/call_kw). Opening a web session needs a real user's password.
I found that out an hour into a redesign, which left me verifying layout through build output and token inspection. Two lessons:
- •Ask for a staff login on the local instance at the start of any task that involves seeing console screens.
- •For pure layout work, a temporary route outside the authenticated route group that renders the real client component with typed mock props works well. Playwright can open it and measure elements with
getBoundingClientRect(). It found a card overflowing its column in one screenshot, after two rounds of guessing from the source. Delete the route when you're done.
Patterns that kept the storefront honest
Two habits made the headless frontend tolerate all of this:
- •Fail-soft reads for new fields. Any storefront read that touches a field added by a recent addon change gets its own RPC call and its own try/catch. An Odoo instance that hasn't been upgraded yet then degrades gracefully instead of breaking the page. That matters when staging, UAT and production are upgraded on different days.
- •Explicit cache invalidation after data changes. The frontend caches catalogue data for days using tags. After changing data in Odoo, I hit a revalidation endpoint with the affected tags. Otherwise a correct upgrade looks like another failure, because the storefront keeps serving yesterday's payload.
Takeaways
- •
noupdatemeans your changes too. Changing a seeded record needs a post-migration, and clearing the database flag won't help, because the XML attribute is checked first. - •Version-stamp what you migrate, so the second redesign isn't silently skipped.
- •Restart, update list, upgrade, in that order, whenever the manifest changes. Use the CLI with explicit DB flags.
- •Verify the docs against the live database before building on them. Here it was the difference between a correct checkout and an overcharge on every order.
- •Confirm with a real page load, not just "upgrade succeeded." In Odoo, success messages are the least reliable signal you have.
Related Posts

Building ERP Systems That Last: Hard-Won Lessons From Payroll, POS and Rent Ledgers
A kiosk said "attendance marked" for eight months while 3,131 punches went nowhere. Lessons from building an HR/payroll suite, a multi-tenant POS and a rental ledger: tenancy, snapshots, single-source rules, replayed approvals, append-only audit and safe repairs.

E-Signatures That Hold Up: PAdES, RFC 3161 Timestamps and a Hash-Chained Trail
A picture of a signature on a PDF proves nothing. This is how I built advanced e-signatures for HR letters and offers with pyHanko: certification signatures with DocMDP, RFC 3161 timestamps, step-up MFA, PDFs that are never re-rendered, and an append-only hash-chained audit log.

Server-Side Face Attendance: What InsightFace and Anti-Spoofing Taught Me
I built face check-in for a multi-tenant HR platform: InsightFace on ONNX Runtime, framing checks and passive anti-spoofing. The model card was wrong, a deploy deleted the weights without any error, and almost every threshold had to be measured.