Staff had accepted the system was slow. It wasn't — it was fixable.
Screens that took about a second now open instantly. A page that froze for nine seconds loads immediately. Fixed on the live system during working hours.
- Sector
- Retail group, dozens of branches
- Year
- 2026
- Built with
- Odoo · PostgreSQL · Python
The result
- The busiest screens open without a wait
- A page that used to freeze for nine seconds now loads immediately
- Applied to the live system during working hours — nobody stopped working
- Users see exactly the same records, with exactly the same permissions
The situation
A retail group running dozens of branches on one ERP. Everybody knew the system was slow, and everybody had stopped mentioning it — staff had built their day around the waiting. Some screens took about a second to open. One took nine.
When a business gets to that point, the usual assumption is that the system has simply outgrown its hardware, and the fix is to spend money on a bigger server. That is an expensive assumption, and it is usually wrong.
What I found
I measured before I theorized. The slowest operations on the system pointed at two places, and neither of them was the hardware — or the ERP itself. Both were in custom code written years earlier, and both were the same mistake.
The system was asking the database for everything, pulling the whole result into memory, and only then filtering it down to the handful of rows actually on screen. On one screen that meant moving more than eighty thousand records to display eighty of them. On another, a counter that ran once per row on the page turned a single page load into a hundred and sixty separate trips to the database.
The important part: neither query looked slow on its own. They only became a problem when you counted how often they ran.
What I changed
Three things, in order of how much they mattered:
- Let the database do the filtering. Instead of fetching everything and narrowing it down afterwards, the code now asks a precise question and gets a precise answer. The screen’s own row limit finally reaches the database.
- Ask once, not once per row. The per-row counter became a single grouped query for the whole page.
- Added the missing indexes so the database can find matching rows directly instead of reading whole tables.
The same mistake existed in six other places. I fixed all of them, not just the two that had been reported.
The result
The transfers screen went from about a second to under fifty milliseconds. The page that froze for nine seconds now loads immediately. Memory pressure on the server dropped, because it was no longer holding tens of thousands of records per user per page load.
Nothing a user can see changed. Same records, same permissions, before and after — a performance fix that quietly changes what someone is allowed to see is not an optimization, it is a security incident.
The indexes were applied to the live production database during working hours without locking a single table, so the business never stopped trading.
For the technically minded
The two root causes were a computed search field resolving IDs in Python instead of
returning a domain — which prevented the list view’s LIMIT from being pushed down —
and a classic N+1 in a stored counter with no @api.depends, costing 160 sequential
scans per page render. Fixes: return domains from search methods, replace the per-row
counter with a single read_group, and add thirteen indexes via
CREATE INDEX CONCURRENTLY so they could be built on the live database without locking.
Index names were chosen to match what the ORM generates, so later module upgrades
recognize them as present rather than rebuilding them.
Client names and identifying details are withheld deliberately. Every figure on this page comes from a system I can open.