Your Inventory Module Just Lost Its Safety Net
If you're planning an Odoo 18 → 19 migration and your business relies on custom Units of Measure, you need to read this before you touch that upgrade button.
Odoo 19 has completely removed the uom.category model. Gone. Not deprecated, not hidden behind a feature flag—deleted from the codebase. In its place, a new relative_uom_id field on uom.uom defines conversion relationships directly between units.
Why should you care? Because every custom module that references uom.category—whether it's a custom inventory report, a manufacturing BOM calculator, or a purchase price comparison tool—will break on upgrade. And if the data migration is handled incorrectly, your historical stock valuations, average cost calculations, and FIFO layers can silently corrupt.
We discovered this the hard way during a pilot migration for a multi-warehouse food distributor. Their custom UoM logic touched 14 modules. This article is the playbook we built to fix it.
How Odoo 19 Restructured Units of Measure (And Why It Matters)
In Odoo 18 and earlier, UoM conversions were organized through a category-based hierarchy:
uom.categorygrouped related units (e.g., "Weight" containing kg, g, lb, oz)- Each
uom.uomrecord belonged to exactly one category viacategory_id - Within a category, one unit was marked as the "reference" (
uom_type = 'reference') - Conversions between units required them to share the same
category_id
The Odoo 19 model eliminates the intermediary table entirely. Instead:
- Each
uom.uomrecord now has arelative_uom_idfield pointing directly to the unit it converts from - The reference unit points to itself (
relative_uom_id = self) - The
factorandroundingfields remain, but they now express the ratio relative torelative_uom_id - Cross-unit conversion is resolved by walking the
relative_uom_idchain up to a common ancestor
This is architecturally cleaner. It removes an entire SQL table, simplifies the ORM query path for conversions, and—critically—allows Odoo to support chained multi-step conversions that the flat category model couldn't express without hacks.
The old uom.category model enforced a hard constraint: you could never convert between categories. This made sense for preventing "kg to hours" conversions, but it also blocked legitimate use cases like converting packaging units (e.g., "pallet of 48 cases" → "case of 12 units" → "unit"). The new relative_uom_id chain handles this natively.
Data Migration Logic: Moving Custom UoM Code from Odoo 18 to 19
The official upgrade scripts handle standard uom.uom records. But if you have custom modules that reference uom.category—foreign keys, domain filters, computed fields, or Python logic—you need a manual migration strategy.
Here's the migration pattern we use at Octura Solutions:
from odoo import api, SUPERUSER_ID
import logging
_logger = logging.getLogger(__name__)
def migrate(cr, version):
"""
Pre-migration: Remap uom.category references
to relative_uom_id before the table is dropped.
"""
env = api.Environment(cr, SUPERUSER_ID, {})
# Step 1: Build a mapping of category → reference UoM
cr.execute("""
SELECT id, category_id
FROM uom_uom
WHERE uom_type = 'reference'
""")
cat_to_ref = {row[1]: row[0] for row in cr.fetchall()}
# Step 2: For each non-reference UoM, set relative_uom_id
# to the reference unit of its former category
cr.execute("""
SELECT id, category_id
FROM uom_uom
WHERE uom_type != 'reference'
""")
for uom_id, cat_id in cr.fetchall():
ref_id = cat_to_ref.get(cat_id)
if ref_id:
cr.execute("""
UPDATE uom_uom
SET relative_uom_id = %s
WHERE id = %s
""", (ref_id, uom_id))
else:
_logger.warning(
"UoM %s: no reference unit found for "
"category %s — manual fix required",
uom_id, cat_id,
)
# Step 3: Reference units point to themselves
cr.execute("""
UPDATE uom_uom
SET relative_uom_id = id
WHERE uom_type = 'reference'
""")
# Step 4: Remap any custom foreign keys in your modules
# Example: custom_inventory_report.uom_category_id
cr.execute("""
UPDATE custom_inventory_report r
SET reference_uom_id = u.id
FROM uom_uom u
WHERE u.category_id = r.uom_category_id
AND u.uom_type = 'reference'
""")
_logger.info(
"Pre-migration complete: %d categories remapped",
len(cat_to_ref),
)Key principles behind this script:
Run this before Odoo's official upgrade scripts drop the uom_category table. If you run it post-migration, the category data is already gone.
We use cr.execute() instead of the ORM because during migration the model definitions may not match the database schema. Raw SQL is the only safe approach.
The script can be run multiple times without corrupting data. If relative_uom_id is already set, the UPDATE simply overwrites with the same value.
Every orphaned UoM (no reference unit in its category) is logged as a warning. This catches data quality issues before they corrupt stock valuations.
Old Way vs. Odoo 19 Way: A Side-by-Side Comparison
This table summarizes every major change that impacts custom module code:
| Aspect | Odoo 18 (uom.category model) | Odoo 19 (relative_uom_id) |
|---|---|---|
| Data Model | uom.category + uom.uom (two tables) | uom.uom only (single table) |
| Reference Unit | uom_type = 'reference' per category | relative_uom_id = self |
| Conversion Guard | Hard constraint: same category_id required | Resolved by walking the relative_uom_id chain |
| Domain Filters | [('category_id', '=', ref.category_id.id)] | [('relative_uom_id', '=', ref.relative_uom_id.id)] |
| XML Data | <record model="uom.category"> required | No category record needed; set relative_uom_id directly |
| Access Rights | ACL rules on both uom.category and uom.uom | ACL rules on uom.uom only |
| Multi-Step Conversion | Not supported natively (requires custom code) | Built-in via ancestor chain traversal |
| Python API | uom._compute_quantity() checks category_id | uom._compute_quantity() walks relative_uom_id |
Bottom line: Any code that imports, queries, or filters on uom.category must be refactored. This includes XML views with category_id domain filters, security rules referencing the model, and any Python self.env['uom.category'] calls.
3 Gotchas That Break Odoo 19 UoM Migrations
We've now migrated multiple databases through this change. These are the traps that catch even experienced developers:
Orphaned categories with no reference unit. In Odoo 18, it was possible to create a uom.category record and add units without marking one as the reference (especially via direct SQL imports or third-party connectors). When migration runs, these units have no target for relative_uom_id—and the UPDATE silently skips them. Post-migration, any stock move referencing these units will throw a ValueError on conversion. Our fix: We run a pre-flight audit query that flags every category missing a reference unit, and we assign one before migration begins.
Computed fields that cache category_id. Many custom modules store category_id in a related or compute field for performance. After migration, these fields reference a column that no longer exists. The ORM won't raise an error during module update—it silently returns False. Your reports and dashboards show blanks, and nobody notices until month-end close. Our fix: We grep the entire codebase for category_id references before migration and build a refactoring checklist.
Historical stock valuation recalculation. If your FIFO or average cost layers were computed using a _compute_quantity() method that referenced the old category-based conversion, the stored valuation amounts are correct—but the conversion path metadata may be stale. If anyone triggers a stock valuation recalculation post-migration (e.g., via an inventory adjustment), the new relative_uom_id logic might produce slightly different rounding results. Our fix: We freeze stock valuation layers before migration with a snapshot table, then run a reconciliation script post-migration to verify that no layer has drifted by more than the configured rounding precision.
For every UoM migration, we deliver a Migration Impact Report before touching production: a document listing every affected module, every remapped field, and a test matrix covering stock moves, BOM explosions, purchase price calculations, and accounting entries. No surprises on go-live day.
Why This Technical Change Saves Your Business Money
For the non-technical reader: here's what the removal of uom.category actually means for your bottom line.
Every stock move, every sale order line, every BOM explosion performs a UoM conversion. Eliminating the JOIN to uom_category reduces query time. On a database with 500K+ stock moves, we measured a 12-18% improvement in inventory valuation report generation.
One fewer model means fewer ACL rules to maintain, fewer data records to export/import, and fewer places for configuration errors to hide. For companies running 50+ custom UoMs across multiple warehouses, this simplification reduces the surface area for bugs.
The relative_uom_id architecture is clearly the direction Odoo is moving. Migrating now means your next upgrade (v19 → v20) will be smoother and cheaper. Delaying means compounding technical debt across two version jumps.
Concrete example: For a mid-size manufacturer with 200 products and 8 custom UoMs, a clean migration costs roughly $3,000–5,000 in consulting time. A botched migration that corrupts stock valuations? We've seen remediation bills exceed $25,000—not counting the business disruption of a frozen warehouse for 2 weeks while data is repaired.