You are here because your build spat out something like ValueError: Invalid field 'category_id' in 'res.groups' while nothing failed locally. It is not your environment. The linter, the import and the unit tests never execute the part of Odoo that breaks: the registry built at install time.
The "what changed in Odoo 19" ground is well trodden. This is about the next step: the module passes the linter, imports cleanly, and the -i still fails. Or worse, it installs and misbehaves in silence.
What your CI checks, and what it does not
python -m compileall checks Python syntax. xmllint --noout checks the XML is well formed. A "pure" test suite, with no Odoo running, checks your logic. None of the three opens Odoo's registry, and that is where the failure lives.
Installing is another matter: Odoo builds the registry and every <record> ends up in an ORM create() or write(). And write() validates field names one by one: in odoo/orm/models.py on 19.0, a KeyError on self._fields[field_name] surfaces as ValueError: Invalid field 'category_id' in 'res.groups'. Not a syntax error; a runtime error in your XML.
And the big one, the trap that turns a gate into theatre: an -i on an already-installed module does nothing. In odoo/modules/loading.py on 19.0, update_operation is 'install' only when the state is to install; when it is installed, it is None. And with None, whenever the command line carries a -i or a -u, neither the data nor the test block is reached. Green build, zero tests. Before you believe a gate, read how many tests it ran.
res.groups.privilege is not a renamed field: it is a new model
Migration guides ship a rename table and that fixes half the cases. category_id is not in it, because Odoo 19 did not rename it: it removed it from res.groups. A search-and-replace has nothing to point at.
On 17 and 18, res.groups.category_id points at ir.module.category. On 19 there is a new model in between, res.groups.privilege (odoo/addons/base/models/res_groups_privilege.py, fourteen lines): the group carries privilege_id and the privilege carries category_id. On top of that, 19 adds UNIQUE (privilege_id, name): two groups called "Manager" under the same privilege will not install.
The fix is not renaming anything: it is creating a record of a model that did not exist in your source version. Here it is in our own modules, with the comment we left in the file so it does not happen again:
<record id="module_category_mi_modulo" model="ir.module.category">
<field name="name">Mi modulo</field>
</record>
<!-- Odoo 19 moved the grouping of access groups out of res.groups: a group
now points at a res.groups.privilege, and the privilege is what carries
the module category. Setting category_id on res.groups raises
"Invalid field 'category_id' in 'res.groups'". -->
<record id="privilege_mi_modulo" model="res.groups.privilege">
<field name="name">Mi modulo</field>
<field name="category_id" ref="module_category_mi_modulo"/>
</record>
<record id="group_mi_modulo_user" model="res.groups">
<field name="name">User</field>
<field name="privilege_id" ref="privilege_mi_modulo"/>
</record>
Around it there are genuine renames, and those a replace does fix: res.groups.users becomes user_ids, res.users.groups_id becomes group_ids, same on ir.actions.act_window and ir.actions.server. What did not change are the tables: res_groups_users_rel(gid, uid) and res_groups_implied_rel(gid, hid) are identical on 17, 18 and 19. A migration's SQL survives all three series; the ORM code around it does not.
base.group_system with noupdate: it installs fine and the permissions stay as they were
The usual idiom for making the administrator inherit your group is a <record> on someone else's xmlid:
<record id="base.group_system" model="res.groups">
<field name="implied_ids" eval="[(4, ref('group_mi_manager'))]"/>
</record>
That applies on a fresh install. On an upgrade, Odoo ignores it silently. The rule lives in _load_records (odoo/orm/models.py) and fits on one line: if not (update and d_noupdate): to_update.append(data). What decides is not your file but the noupdate column of the row already in ir_model_data for that xmlid. Do not assume it either way: ask your own database.
SELECT module, name, noupdate FROM ir_model_data
WHERE module = 'base' AND name IN ('group_system', 'group_user');
On our own Odoo.sh production database, checked on 3 September 2026, both rows come back with noupdate = t. With that value, the XML above is never reapplied on an upgrade.
Why it matters: if you also put groups="my_module.group_manager" on a credentials field, then on an already-installed database that group ends up with no members at all and nobody — not even the administrator — can read or write it. Internal code using sudo() carries on, so no test sees it: tests run on a clean install, where the <record> did apply. That is exactly how it happened to us.
The recipe: keep the XML, which covers fresh installs, and add a migrations/<series>.<version>/post-migrate.py writing it as idempotent SQL against res_groups_implied_rel and res_groups_users_rel — implied_ids only propagates to users through the ORM, so insert those too. Plus a test that fails when the group is empty. The transitive closure is all_implied_ids on 19 and trans_implied_ids on 17 and 18.
ir.cron without numbercall: the cron runs once and switches itself off
This one is invisible until the following week. On Odoo 17, odoo/addons/base/models/ir_cron.py declares numbercall = fields.Integer(..., default=1, ...). A <record model="ir.cron"> that omits it inherits that 1. When the first run ends, call_count_left is 0 and the UPDATE that closes the run writes active = job['active'] and bool(call_count_left): false. In case of doubt, the query that picks jobs filters on AND numbercall != 0.
The result: the cron runs once, deactivates itself and leaves no trace. No exception, no log line, nothing in the UI. It looks exactly like "the cron never fires". The fix is <field name="numbercall">-1</field>, negative meaning no limit.
And here is the nasty symmetry: Odoo 18 removed the field and 19 never had it. Setting numbercall on 19 blocks the install (Invalid field 'numbercall' in 'ir.cron'); not setting it on 17 breaks behaviour without a word. The same file fails in two opposite directions. Same with doall.
We make this mistake at home too. Auditing our catalogue we found fourteen ir.cron records across four connectors ported to 17 without a single numbercall; widening the sweep to the 17.0 branch of the deployment that actually runs, 130 records across fifteen modules, none with the field. Reported as what it is: we found ours because we went looking with a grep, not because anything went off.
The inventory, and which version each trap bites on
This is what we collected by actually installing on Odoo.sh across the three series, with our own catalogue. Not one of these traps is caught by a compiler or a linter. The rows marked "silent" are worse: the module installs and the build goes green.
| What you write | What happens | Where it bites |
|---|---|---|
category_id on res.groups | Invalid field 'category_id' in 'res.groups' | Will not install · 19 |
users or groups_id | Renamed to user_ids and group_ids | Will not install · 19 |
numbercall or doall on ir.cron | Invalid field 'numbercall' in 'ir.cron' | Will not install · 18 and 19 |
numbercall missing on ir.cron | Installs; the cron runs once and stops | Silent · 17 only |
_sql_constraints in a 19 model | Installs; one warning line in the log and no constraint | Silent · 19 |
models.Constraint on 18 or 17 | module 'odoo.models' has no attribute 'Constraint' | Will not install · 18 and 17 |
<record id="base.group_system"> | Installs; on an upgrade it is not reapplied | Silent · all three |
<tree> in an inherited view | The xpath misses and the view blows up | Will not install · 18 and 19 |
| Field cited in a modifier but not rendered | must be present in view but is missing | Will not install · 17 only |
Look at the last row: only 17 runs that check. The message is still in the 18 and 19 source, but there it only covers names and actions, not a field cited in a modifier. If you develop against 19 and port backwards, the view that installs perfectly for you breaks on 17. The traps do not all point the same way.
How you actually catch this: install twice, on two different databases
Test A — clean database, -i. It catches everything that blocks the install: fields that no longer exist, xpaths that miss, invalid views, constraints the database rejects. It catches nothing that only happens on upgrade: here update is false, noupdate flags are ignored, and the scripts under migrations/ never run, because migrate_module (odoo/modules/migration.py) returns immediately unless the module is to upgrade.
Test B — database with the previous version, -u. It catches what only shows up on upgrade: records that are not reapplied, new columns left unfilled, migrations that fail halfway, old data that no longer fits. This is where your pre-migrate and post-migrate actually run, and the only way to know whether they work.
Neither replaces the other, and if you publish for 17, 18 and 19 that is six installs, not two. One practical detail: before the -i, reset with a real ORM uninstall. Flipping the state by hand with psql leaves the graph inconsistent and the loader skips the module silently: another green build that proved nothing.
The checklist before you push
grepforcategory_idin every<record model="res.groups">. If any turns up, you need ares.groups.privilege.grepforgroups_idandusersin the XML: on 19 they aregroup_idsanduser_ids.grepfornumbercallanddoall: out on 18 and 19, mandatory at-1on 17.grepfor_sql_constraintson 19: it leaves a warning in the log and creates no constraint. Andmodels.Constrainton 18 and 17: that name does not exist there, so the module will not even import.grepfor<treeon 18/19 and<liston 17, including theview_modeof your actions.- For every
<record id="base....">: is there a migration for the upgrade path and a test that fails when the group ends up empty? - Install on a clean database. Count the tests that ran. If it says zero, the gate is empty.
- Install on a database carrying the previous version. Count the tests again.
- Repeat on every series you intend to publish.
The first five points are a static script that runs in a second; the real build takes minutes. Run the cheap one first, but do not let it replace the expensive one: grep finds what you already know to look for, and the build finds what you do not.
If you are coming from a migration, the level below this one is in the common mistakes when migrating to Odoo 19.
The underlying rule, the only one worth taking away: compiling, parsing and having green tests is not the same as installing. The two modules that cost us most this year were green everywhere locally and would not install.
Frequently asked questions
Why do my tests pass if the module will not install?
Because a pure-Python suite never opens Odoo's registry: it validates your logic, not your data files. And an -i on an already-installed module processes nothing: the test block is never entered and the build comes back green with zero tests.
Do I need a res.groups.privilege on 17 and 18 too?
No: that model does not exist before 19. On 17 and 18 the category sits on the group itself, via category_id. It is one reason your security XML cannot be one single file across the three series.
Can I just leave numbercall in so it works on all three versions?
No. On 18 and 19 the field does not exist and its presence blocks the install. Removing it on 17 makes the cron switch itself off after the first run. You have to generate the file per series.
If it installs fine on a clean database, is that not enough?
No, because on a clean install the scripts under migrations/ never run and records flagged noupdate do get applied. The two behaviours that fail on upgrade are precisely the ones that test cannot see.
Your module will not install and you cannot see why?
We look at your security XML, your crons, your inherited views and your migration scripts, and tell you what breaks on each series before a customer finds out. Book a call on Calendly or call us at +34 616 809 504.
