Odoo compatibility
Metrilens runs on Odoo 17, 18 and 19.
That is the whole answer for most people. If you want to see the work behind it, the rest of this page is the engineering matrix itself — published as it is maintained, including the parts still marked unconfirmed.
17.0, 18.0 and 19.0
All three are verified. Not from release notes alone — the checks were made against the odoo/odoo source branches, and the HR section additionally against a live Odoo 19.0 Enterprise instance.
It connects the way Odoo expects
Metrilens reads over Odoo's JSON-RPC endpoint with an API key. That path works unchanged on 17, 18 and 19. Odoo has flagged it as deprecated with removal announced for a much later version, so nothing about your install has to change to use it today.
Six differences that matter
Between 17 and 19 Odoo made six changes a reporting tool has to know about — a removed product type, a purchase state that became a flag, the removal of hr.contract. The matrix names each one, the version it landed in, and what it requires. Two of them are branched on your server version while the dashboard runs.
If you run Odoo 16 or older
Metrilens signs in with an Odoo API key, and Odoo only introduced API keys in version 14 — below that there is no supported way to connect. Versions 14 to 16 are not on the verified list: parts of the product will work, but we have not checked them and do not claim them. Tell us which version you are on before you buy and we will say so plainly.
The verified matrix
From here on this is the internal document, rendered unedited. It is written for engineers, it cites the exact source files behind each claim, and it flags what could not be confirmed. That is deliberate: a compatibility promise you cannot check is not worth much.
This file is published. The public page/compatibilityis generated from it at build time (../src/app/compatibility/page.tsx) — edit it here and nowhere else, and read it as customer-facing text. The former standalone renderingdocs/odoo-compat-matrix.htmlwas retired because it had already gone stale (it never got the HR section).
Verified July 2026 against primary source:odoo/odooGitHub branches17.0,18.0,19.0(raw source files), official release notes and developer docs. Every non-trivial claim is cited in the Sources section. Items that could not be confirmed from a primary source are flagged UNCONFIRMED.
TL;DR — the breaking changes you must handle
| # | Breaking change | Version | Action |
|---|---|---|---|
| 1 | Storable filter: type='product' → is_storable=True | 18 | Branch on version when filtering storable products |
| 2 | <tree> view tag → <list> (hard removed, raises ValueError) | 18 | Rename all <tree> roots to <list> |
| 3 | purchase.order state='done' → locked boolean | 19 | Query state='done' OR locked=True; confirmed PO is state='purchase' |
| 4 | purchase.menu_purchase_report never existed | all | Use purchase.purchase_report_main |
| 5 | read_group() deprecated (still works, warns) | 19 | Keep for now; migrate to _read_group before ~v22 |
| 6 | hr.contract removed → hr.version (no state, contract_date_* dates, many rows per employee) | 19 | Go through hr-compat.ts; never count version rows as headcount |
Everything else in the audited surface is stable 17 → 19. The read-only web-session JSON-RPC path used by the SaaS dashboard is safe across all three versions.
PRODUCT / INVENTORY
| Item | 17 | 18 | 19 | Changed in | Fix |
|---|---|---|---|---|---|
| product.type / storable detection | type ∈ consu,service; stock adds ('product','Storable'); also detailed_type | type ∈ consu("Goods"),service,combo; 'product' REMOVED; stock adds is_storable Boolean; detailed_type removed | same as 18 | 18 | Storable filter — 17: [('type','=','product')]; 18/19: [('is_storable','=',True)] |
product standard_price / list_price / default_code / categ_id | ok | ok | ok | — | Stable |
stock.quant quantity + location_id.usage=='internal' | ok | ok | ok | — | Stable |
stock.move state=='done' + date | ok | ok | ok | — | Stable |
stock.warehouse.orderpoint product_min_qty/product_max_qty | ok | ok | ok | — | Stable |
MRP
| Item | 17 | 18 | 19 | Changed in | Fix |
|---|---|---|---|---|---|
mrp.bom (product_tmpl_id,product_id,product_qty,active,type=normal/phantom) + mrp.bom.line (bom_id,product_id,product_qty) | ok | ok | ok | — | Stable |
| mrp.bom._bom_find() | _bom_find(self, products, picking_type=None, company_id=False, bom_type=False) → defaultdict keyed by product record → mrp.bom recordset | byte-identical | byte-identical | — | Stable. Returns dict-like; value is empty recordset when none (never None/False) |
SALES
| Item | 17 | 18 | 19 | Changed in | Fix |
|---|---|---|---|---|---|
sale.order state | draft,sent,sale,cancel + locked bool | same | same | 'done' removed at/before 17 → locked bool | Never query state='done'; confirmed order = state='sale', locked = locked=True |
sale.order date_order/amount_total/amount_untaxed/user_id/partner_id/currency_id | ok | ok | ok | — | Stable |
sale.order.line price_subtotal/price_unit/discount/product_uom_qty/display_type | ok | ok | ok | — | Stable |
sale_margin: margin/purchase_price (+margin_percent) | ok | ok | ok | — | Module name & fields stable; present only if sale_margin installed |
ACCOUNTING
| Item | 17 | 18 | 19 | Changed in | Fix |
|---|---|---|---|---|---|
account.account account_type (asset_receivable,liability_payable,…) | full set | identical to 17 | adds expense_other | 19 (additive only) | Existing values unchanged; add expense_other only if enumerating expense types on 19 |
account.move.line amount_residual/date_maturity/parent_state/partner_id/company_id/date | ok | ok | ok | — | Stable |
account.move invoice_date/move_type/state | ok | ok | ok | — | Stable (state draft/posted/cancel) |
PURCHASE
| Item | 17 | 18 | 19 | Changed in | Fix |
|---|---|---|---|---|---|
purchase.order state | draft,sent,to approve,purchase,done,cancel | same (incl. done) | done REMOVED: draft,sent,to approve,purchase,cancel + locked bool | 19 (PR #188374) | 17/18: done valid. 19: state='done' matches nothing — use locked=True. Cross-version: state='done' OR locked=True. PO = state='purchase' |
purchase.order amount_total/date_approve/date_order | ok | ok | ok | — | Stable |
stock.picking purchase_id / picking_type_code=='incoming' / date_done / scheduled_date | ok | ok | ok | — | Stable (purchase_id from purchase_stock) |
HR
Verified July 2026 against the live 19.0+e reference instance (fields_get + search_count), not only the source branches.
| Item | 17 | 18 | 19 | Changed in | Fix |
|---|---|---|---|---|---|
| hr.contract | ok | ok | model REMOVED — Object hr.contract doesn't exist | 19 | Employment terms live on hr.version, one row per version of an employee's terms. Branch via contractModel() in ../src/lib/odoo/hr-compat.ts |
contract state | draft,open,close,cancel | same | field does not exist on hr.version | 19 | "In force" = contract_date_start set and reached AND (contract_date_end unset OR still ahead) — the rule behind Odoo's own is_in_contract |
| contract start/end dates | date_start / date_end | same | contract_date_start / contract_date_end (stored). date_start/date_end still exist as unstored aliases | 19 | Unstored aliases work in a domain but raise "Cannot convert hr.version.date_end to SQL because it is not stored" in order. Only ever name the contract_* fields |
| is_in_contract | — | — | exists, unstored | 19 | Not usable in a domain at all; replicate the rule instead |
| row ↔ person | one open contract per employee | same | many versions per employee (reference instance: 17 rows / 12 employees) | 19 | Never count rows as headcount — read_group by employee_id (countEmployeesWithContract()) |
hr.version name | (contract name, set) | same | often false | 19 | Fall back to the employee name (contractLabel()) |
hr.employee (active,department_id,job_id,create_date) | ok | ok | ok | — | Stable |
hr.leave (state,date_from,date_to,number_of_days) | ok | ok | ok | — | Stable (hr.holidays only pre-12) |
VIEWS
| Item | 17 | 18 | 19 | Changed in | Fix |
|---|---|---|---|---|---|
<tree> list tag | valid (normalized to list internally) | HARD REMOVED — <list> required | HARD REMOVED | 18 | Rename <tree>→<list>. No backward compat: <tree> root raises ValueError: Wrong value for ir.ui.view.type: 'tree' |
<form>/<pivot>/<graph>/<search> | ok | ok | ok | — | Stable |
<header><button> in list | ok | ok | ok | — | Supported all three |
column_invisible/optional/decoration-* | ok | ok | ok | column_invisible since 17 | Use column_invisible (plain invisible hides only the cell) |
attrs=/states= | REMOVED | removed | removed | 17 | Use direct expressions: invisible="state in ('draft','cancel')", readonly=, required= |
MENU & GROUP XMLIDs
| Item | 17 | 18 | 19 | Changed in | Fix |
|---|---|---|---|---|---|
| sale.menu_sale_report | ok | ok | ok | — | OK |
| account.menu_finance_reports | ok | ok | ok | — | OK |
| purchase.menu_purchase_report | does not exist | does not exist | does not exist | n/a | Wrong id in all versions — use purchase.purchase_report_main |
| stock.menu_warehouse_report | ok | ok | ok | — | OK |
| mrp.menu_mrp_reporting | ok | ok | ok | — | OK |
| All 10 group xmlids (sales_team.group_sale_salesman/manager; account.group_account_readonly/manager; purchase.group_purchase_user/manager; stock.group_stock_user/manager; mrp.group_mrp_user/manager) | ok | ok | ok | — | All confirmed present in every branch |
ORM
| Item | 17 | 18 | 19 | Changed in | Fix |
|---|---|---|---|---|---|
read_group() (public) | works | works | works but @api.deprecated (warns) | 19 | Still functional in 19 (compat over _read_group). Keep read_group for a 17–19 tool — only dict-returning group API common to all three. Migrate before ~v22 |
_read_group() | (domain, groupby, aggregates, having, offset, limit, order) → list of tuples | same | same (type-annotated) | rewritten in 17 | Stable 17→19; returns tuples not dicts; private |
create([{…}]) batch | ok (@api.model_create_multi) | ok | ok | v12 | No change; single dict still accepted |
SQL view _auto=False + init() + tools.sql.drop_view_if_exists | ok | ok | ok | — | Pattern valid all three; drop_view_if_exists(cr, name) unchanged |
MANIFEST
| Item | 17 | 18 | 19 | Changed in | Fix |
|---|---|---|---|---|---|
__manifest__.py keys (name/version/depends/data/assets/demo/installable/application/auto_install/license/*_hook) | stable | stable | stable (internals refactored to "Manifest API") | — | No key add/remove/rename observed diffing 17 vs 19 |
version string | 17.0.x.y.z | 18.0.x.y.z | 19.0.x.y.z | — | Prefix with Odoo serie for custom modules |
assets bundle format | bundle → [paths] dict | same | same | — | No format change |
EXTERNAL API (read-only JSON-RPC SaaS)
| Item | 17 | 18 | 19 | Changed in | Fix |
|---|---|---|---|---|---|
/web/session/authenticate + /web/dataset/call_kw (search_read) — web session path | ok | ok | ok — not deprecated | type='json'→type='jsonrpc' controller rename in 19 (transparent to clients) | Current path. Fully supported 17–19 self-hosted. No client change needed |
/jsonrpc (external RPC endpoint) | ok | ok | ok but DEPRECATED | 19 | Deprecated; official removal on-prem v22 (fall 2028) / Online 21.1 (winter 2027). Some blogs say "removed in 20" — official docs say 22; trust primary source |
/xmlrpc,/xmlrpc/2 (execute_kw) | ok | ok | ok but DEPRECATED | 19 | Same timeline as /jsonrpc |
New /json/2 REST API (Bearer API-key) | — | — | new in 19 | 19 | Optional modern alternative; API keys default max lifetime 90 days for non-admins |
Auth/2FA note: No confirmed change breaking programmatic read via the web-session path in 18/19. API keys remain recommended; 2FA affects interactive login, not key/session RPC. Low risk, but verify against your specific Odoo Online plan.
Odoo 20 Outlook
Status as of July 2026: does NOT exist yet — roadmap only. Verified from primary source:
- No
20.0branch on GitHub.git ls-remoteshows only17.0,18.0,19.0,master.raw.githubusercontent.com/odoo/odoo/20.0/odoo/release.py→ HTTP 404. - 19.0 is FINAL:
release.py=(19, 0, 0, FINAL, 0, ''). - Official release-notes page tops out at 19.3 (May 2026) — the Online incremental cadence. No Odoo 20 entry.
Publicly known (secondary / soft):
- Odoo 20 roadmap presented April 2026 (Luc Nailis, Odoo PO) — explicitly caveated "things we will maybe do."
- Expected unveiling at Odoo Experience 2026, Brussels, ~Sept 24–26 2026; GA typically Oct–Nov 2026.
- Themes: heavy AI automation, accounting/POS/payroll/website, mobile redesign. No confirmed breaking schema/API changes for the audited items.
Pre-planning for v20:
lockedboolean is the durable representation of "done/locked" — v19 finished it for purchase.- Migrate
read_group→_read_group/formatted_read_groupopportunistically. <tree>stays removed; standardize on<list>.- Plan a
/json/2migration path off/jsonrpc+/xmlrpc(removal 2027 Online / 2028 on-prem).
UNCONFIRMED — verify before relying
formatted_read_groupexact signature/location in 19 — referenced in the deprecation message but thedefwas not located. Check:grep -rn "def formatted_read_group"on a real 19.0 checkout. Does not affect a read-only tool./jsonrpcremoval version — official docs say v22 / Online 21.1; some third-party blogs say "Odoo 20." Sources disagree; primary source (docs) wins.- New optional manifest keys in 19 — confirmed no existing keys changed; did not exhaustively rule out a new optional key
saledoesn't happen to use. - 2FA/API-key impact on RPC read in 18/19 — no breaking change found, not exhaustively confirmed against every Odoo Online plan variant.
Sources
Primary (raw GitHub, branches 17.0 / 18.0 / 19.0):
addons/product/models/product_template.py,product_product.py;addons/stock/models/product.py,stock_quant.py,stock_move.py,stock_orderpoint.pyaddons/mrp/models/mrp_bom.pyaddons/sale/models/sale_order.py,sale_order_line.py;addons/sale_margin/models/sale_order_line.pyaddons/account/models/account_account.py,account_move.py,account_move_line.pyaddons/purchase/models/purchase_order.py;addons/purchase_stock/models/stock.py;addons/stock/models/stock_picking.pyodoo/addons/base/models/ir_ui_view.py;addons/*/views/*.xml,addons/*/security/*_security.xml, menu XMLsodoo/orm/models.py(19),odoo/models.py(17/18);odoo/tools/sql.py;addons/sale/report/sale_report.py;addons/sale/__manifest__.py;odoo/release.py- PR https://github.com/odoo/odoo/pull/188374 (purchase done→locked)
Official docs / release notes:
- https://www.odoo.com/odoo-19-release-notes , https://www.odoo.com/page/release-notes
- https://www.odoo.com/documentation/19.0/developer/reference/external_api.html , .../external_rpc_api.html
- https://www.odoo.com/documentation/19.0/developer/reference/backend/module.html , .../orm/changelog.html
- Odoo Experience 2025 talk "XMLRPC is dead. All Hail JSON-2": https://www.odoo.com/event/odoo-experience-2025-6601/track/xmlrpc-is-dead-all-hail-json-2-8760
Secondary (tree→list hard error, attrs/states, Odoo 20 roadmap — soft):
- https://github.com/odoo/odoo/issues/192829
- https://www.odoo.com/forum/help-1/valueerror-wrong-value-for-iruiviewtype-treein-odoo18-285451
- https://www.odoo.com/forum/help-1/since-170-the-attrs-and-states-attributes-are-no-longer-used-239190
- Odoo 20 roadmap coverage (unofficial, dates unconfirmed by Odoo): odoo-bs.com, ksolves.com, solvync.com, waogroup.com.au
Generated 2026-07-07.
The other question buyers ask first: What it cannot do.