The report
“Checkout is slow.” Two words, and the least actionable bug report in commerce, because for most customers it was not.
The distinguishing detail arrived on the second call: it was slow for one account. A B2B customer with roughly nine hundred saved delivery addresses — normal for a wholesaler shipping to hundreds of outlets. Clicking “Checkout” took about twenty-two seconds.
A cost that varies per customer, in proportion to how many related records that customer has, is not a hosting problem. Nobody should be sizing a server for it, and any consultant who suggests more RAM at this point should be asked to reproduce it first.
What the page was actually doing
Odoo 18's checkout renders the customer's saved addresses through the website_sale.address_kanban template — one card per address, each with an Edit link shown only if the current customer is allowed to edit that address.
That permission check is a method on res.partner: _can_be_edited_by_current_customer(). It is called once per card, from inside the loop.
And each call re-runs an identical search: every partner that is a descendant of the customer's commercial entity, via a child_of domain over the whole partner tree. The same search. Nine hundred times. Producing the same answer every time.
Why one call costs several queries
This is the part that turns an N+1 into something considerably worse than N+1.
res.partner has no parent_store. In Odoo, a model with parent_store maintains a nested-set index — parent_path — which makes a child_of domain resolvable in a single indexed query. Without it, the ORM has to walk the hierarchy: fetch this level's children, then their children, then theirs, until the tree is exhausted. Several queries per call, not one.
That is not inference. In the Odoo 18.0 Community source, child_of_domain in odoo/osv/expression.py branches on exactly this: with _parent_store it builds a parent_path range lookup, and without it, it loops — while records: records = records.search([(parent_name, 'in', records.ids)]) — until the tree is exhausted. res.partner in odoo/addons/base/models/res_partner.py declares no _parent_store; the neighbouring res.partner.category does, which is a good way to convince yourself the omission is deliberate rather than an oversight.
So the arithmetic is roughly nine hundred cards, times several recursive queries each, for one page render. Thousands of statements, each individually fast, adding up to twenty-two seconds of a customer's patience.
None of this is a mistake in the ordinary sense. Calling a permission method per record is correct, readable code. The defect only exists at a record count the author never had in front of them.
The fix
Run the search once, before the loop, and let each card do a set-membership test instead.
editable_ids = set(partner_tree_search()) # once, in the controller
...
# in the template, per card:
address.id in editable_ids # O(1), no query
The module hooks _prepare_checkout_page_values, performs the search there, and passes the resulting id set through to the template. Thousands of queries become one.
Two properties made this safe enough to ship on every tenant:
- No behaviour changes. The same cards render, with the same Edit links, for the same customers. It is a pure cost change.
- It degrades to the original. Wherever the template is rendered without that precomputed value — another page, another module reusing the template — the original per-card check still applies. Nothing else can break, because nothing else has to know.
Adding parent_store to res.partner would fix the underlying cost and is exactly the sort of change that produces a large, slow migration on a live database with millions of contacts, for the benefit of one page. Precomputing was the proportionate answer.
The second bug, which was worse
Fixing the speed exposed something that had been hidden behind it, and it was a dead end rather than a delay.
If a cart's billing address pointed at an address that is not valid as a billing address — typically a ship-to contact created without an email — the checkout page redirected to the billing address form. That form, on its own, offered no way to switch the billing address to a different existing address, and no way to turn off “Same as delivery address”. Discard did not help. The customer's only options were to edit the offending address or type an entirely new one, and doing neither returned them to the same form.
The fix is a small “choose an existing address” picker on the address form, listing the customer's saved addresses that are actually valid for the current step. Selecting one assigns it to the cart and returns to checkout; because the chosen address is complete, the redirect loop ends and the standard address selection becomes reachable again. No core behaviour is replaced and the mandatory-field rules are untouched.
That second bug is the more instructive one. It was permanently there, it was silently costing orders, and nobody had reported it — because a customer who cannot check out does not file a ticket. They leave.
The pattern to look for
Every N+1 we have found in Odoo has the same shape: a method call inside a template loop, where the method does a search.
Three habits catch them before a customer does:
- Test against the biggest real account, not demo data. Demo data has three addresses. Your worst page has nine hundred. Take a sanitised copy of production and browse it as your largest customer.
- Watch the query count, not the wall clock. Wall clock hides the defect on a fast machine with a warm cache. A page that issues three thousand statements is broken even when it renders in two seconds on your laptop.
- Be suspicious of any permission or computed check called per row. If it is expensive to compute once, it is catastrophic to compute per record — and it is nearly always hoistable.
Both modules are installed on every zynAIR tenant that runs the online shop. That is the difference between a defect found once and a defect fixed once.
Questions
Because the cost is proportional to something that varies per customer — almost always the number of related records rendered on the page. Odoo 18's checkout renders one card per saved address and performs an editability check per card, and that check re-runs a recursive child_of search over the customer's whole partner tree. A customer with five addresses never notices. A customer with nine hundred waits twenty-two seconds.
Reproduce it against the account that is slow, never against demo data. Then either enable SQL logging (--log-sql) and count the near-identical statements, or use Odoo's own profiler on the request. The signature is unmistakable: hundreds or thousands of statements that differ only in an id. What you are looking for is a method called from inside a template loop.
Check against your own version before assuming so — and check with a realistic record count, because the defect is invisible below a few dozen addresses. Our module is written so that it degrades safely: anywhere the template renders without the precomputed set, the original per-card check still applies, so it cannot break other pages or other modules that reuse the template.