Skip to main content
Development · Odoo 19, 18 and 17

Maintaining the same module on Odoo 19, 18 and 17: what changes, what breaks silently and how to test it for real

One branch per version, one real build per version and an inventory of differences. What bites between 17, 18 and 19, verified in Odoo's source code, and how we test it across 117 published modules.

Odoo Apps screen with installed modules on a real backend

Maintaining an Odoo module on 19, 18 and 17 at once is not copying the folder to three branches and changing the manifest number. It is one git branch per version, one real install build per version and an inventory of cross-version differences reviewed on every release. We maintain 117 published modules across the three versions; this is the list of what breaks, including our own mistakes.

One listing, three branches (19.0, 18.0 and 17.0 with the same technical name) is explained in publishing a module on the Odoo App Store. This is about what happens inside each branch.

What changes between 17, 18 and 19 and is not in the release notes

The Odoo 19 release notes talk about features; this table, about what fails at install time or, worse, does not fail. It is checked against the source code of the three versions, not against a changelog.

WhatOdoo 17Odoo 18Odoo 19How it fails
SQL constraints_sql_constraints_sql_constraintsmodels.ConstraintSilently: 19 warns and carries on; the constraint does not exist
Unlimited cronnumbercall=-1 requiredField goneField gone17: without it, runs once and stops. 18/19: with it, install fails
User's groupsgroups_idgroups_idgroup_idsInvalid field at install or run time
Group's usersusersusersuser_idsSame; crashed a notification cron of ours on 17 and 18
Group categorycategory_idcategory_idprivilege_idres.groups.privilegeInvalid field 'category_id' in 'res.groups'
List view<tree><list><list>Invalid view type; //tree xpaths do not match

The most dangerous one: _sql_constraints

On Odoo 19 _sql_constraints is ignored: the model loader writes “no longer supported” to the log and moves on. The module installs, the tests pass and the uniqueness constraint is not in the database; a connector that relies on unique(external_id) to avoid duplicating orders loses that guarantee without anything saying so. The new form, models.Constraint('unique(...)', 'message'), only exists on 19. Odoo ships a converter, odoo/cli/upgrade_code.py --script 18.1-00-sql-constraint, with one trap we learned migrating ours: it exits with code 1 when it has changed files, so 1 means success. The only thing that proves the constraint is alive is a test that attempts the duplicate.

The one that caught us: numbercall

On Odoo 17, ir.cron.numbercall defaults to 1: a cron declared without that field runs once and deactivates itself, with no error and no log line. Odoo 18 removed the field and from there on declaring it blocks the install, so the 17 branch needs <field name="numbercall">-1</field> on every cron and the other two cannot carry it. In September 2026 we audited our own 17 ports and found 14 crons without that field across four connectors, two with a live demo running: nobody tests a periodic sync by waiting for the second run. The fix is two lines per cron; the cost was not having it in the inventory.

What Odoo 17 validates and 18 and 19 let through

Traffic does not only flow upwards. Odoo 17 requires that a field used in a view modifier (invisible, readonly) be in that same view; 18 and 19 do not check it. A module written on 19 and ported backwards fails on 17 with Field 'state' used in modifier 'invisible' (state == 'draft') must be present in view but is missing. That is why a port is not signed off until it installs on the oldest version. More cases in an Odoo 19 module compiles but does not install and Odoo 19 migration errors.

Branch strategy: one per version and no conditionals

The main branch is the newest version's, today 19.0. Every change is made and tested there and then carried backwards with cherry-pick to 18.0 and 17.0, adapting what the table forces you to adapt. What we never do is a single codebase with if version >= 18 inside: you get a module with all three APIs at once, where nobody knows which of the three branches is being tested. For new connectors we generate the 18 and 17 branches from the 19 source with a script that encodes the table above; it does not replace the test, it replaces the copy-and-paste.

Two exceptions, and both ask about a capability, not a version number:

  • Field or model detection at run time. If the module writes to a field that does not exist on every version, ask the registry: 'mobile' in partner._fields or 'hr.expense.sheet' in self.env. It looks at what is there, not at what should be.
  • Tests that skip when the capability is absent, with a skipTest conditioned on the field's presence. What is not acceptable is passing 0 of 0: one module of ours had zero tests on all three branches; writing it 25 surfaced three real bugs, one the notification cron using user_ids on 17 and 18.

The test that counts: a real install on all three

Neither the linter, nor import, nor a green suite on your machine proves a module installs: an XML with numbercall is valid XML and an xpath on //tree is a valid xpath. The failure only shows up when Odoo loads the module against a database of that version. Our rule: a change does not exist until it has been installed, clean, on a real Odoo of every version and, if the module is already deployed somewhere, also as an upgrade from the previous version.

The upgrade matters for a concrete reason: base groups carry noupdate="1", so a module that adds its manager group to base.group_system via implied_ids gets it on a fresh install and Odoo silently ignores it on upgrade. We saw it on an Odoo.sh production: the group was left with no members and not even the administrator could read the credentials field that depended on it. You need a migration under migrations/ on top of the XML, and a test that fails if the group ends up empty.

We use two kinds of rig. A test rig, one Odoo per version —local or in containers— that installs each module and runs its suite against the three: that is where the 37 apps of our first campaign ran, where the Mirakl connector passes 163 tests on each version and the Amazon connector 786 on 19 and 685 on 18 and on 17. And Odoo.sh, one build per branch, for the real install. A gate round is about ten minutes and every one is worth it: the two modules that cost us the most this year had everything green locally and did not install (green tests and the connector fails in production).

Our own failure: we called the first 37-app campaign done with install and tests green. Four days later, a second pass over 47 apps on Odoo 17 and 18 Community, exercising every view with data, left 61 of 94 runs clean: 28 install failures and 4 exercise failures, some eleven real bugs. Xpaths on //list on 17, anchors on fields that do not exist on 17. Code ported backwards without installing it on the target version.

The real cost and when to drop 17

Three branches do not triple development, because the logic is written once. They triple testing and publishing: three installs per change, three suites, three listings to check, and an inventory to re-read in full when 20 ships and adds rows to the table. Odoo ships one major version a year and supports the last three; the day it publishes 20, 17 leaves that window and gets dropped. Until then the 17 branch gets every fix: whoever is still on 17 is planning a migration, and will migrate with your module or without it.

What does reduce the cost is having the inventory as a tool: a read-only scanner that flags which line steps on which difference according to the manifest version. Ours once flagged 67 modules for writing category_id on a group, and all 67 were right: that line sat inside a <record> of res.groups.privilege, which is exactly the correct 19 fix. The rule looked at the line and not at the record around it. A count without opening the code is not a fact, and a rule that flags the correct fix teaches you to ignore the tool.

Checklist before every push

  1. Constraints: models.Constraint on 19, _sql_constraints on 18 and 17, and a test that attempts the duplicate.
  2. Crons: numbercall=-1 on every one on the 17 branch; none on 18 and 19.
  3. Groups: group_ids, user_ids and privilege_id (model res.groups.privilege) on 19; groups_id, users and category_id on 18 and 17.
  4. Views: <list> on 19 and 18, <tree> on 17; on 17, every field used in a modifier is in the view.
  5. Clean install on all three, and an upgrade from the previous version if it is already deployed.
  6. Full suite green on all three, and not empty: 0 of 0 is not green.
  7. No __pycache__ in the commit. A git add -A after the tests put 1,078 .pyc files into 35 of 37 repositories.

It is what we do when an Odoo partner asks us to maintain one of their modules across several versions, or when a consultancy needs a development that works for them on 17 to reach 19 without losing 17. The shortcut is what the listing's first buyer discovers.

Frequently asked questions

Can I keep a single codebase with version conditionals?

The App Store publishes from 17.0, 18.0 and 19.0 branches, so you end up with three branches anyway, only with all three APIs mixed into each. One branch per version is more repeated code and far fewer failures. The only acceptable conditions ask about a capability, never about the version number.

Why is it not enough that it compiles and the tests pass locally?

Because version failures show up when the module is loaded against a database of that version, not when the code is imported; and some, like _sql_constraints on 19, do not even fail: you have to prove the constraint exists.

Useful links inside FlexigoTech

Publishing a module on the Odoo App StoreOne listing, three branches: repository, manifest and re-scanAn Odoo 19 module compiles but does not installThe 19 API traps static analysis does not seeGreen tests and the connector fails in productionWhy the test that counts is the real installMigration to Odoo 19Migration service with your own modules included

Got a module on one version and need it on all three?

We install it on 19, 18 and 17, tell you what breaks on each and leave it with one branch per version and its build green. Email comercial@flexigobe.com or call +34 616 809 504.

Talk to an engineer