The Bill of Materials Is the DNA of Your Manufacturing Process
Every manufactured product starts with a Bill of Materials. It defines what components go in, how many, and in what sequence. Get the BoM wrong and everything downstream breaks: production orders consume the wrong quantities, costs are miscalculated, procurement triggers at the wrong time, and finished goods fail quality checks because a sub-assembly was never built.
Odoo 19's Manufacturing module (MRP) supports flat BoMs, multi-level (nested) BoMs, kit BoMs, phantom assemblies, component substitution, routing operations, and BoM versioning. This guide walks through each pattern with real XML and Python examples you can drop into your Odoo 19 instance. We will cover every scenario from a simple single-level BoM to a complex multi-level structure with phantom sub-assemblies and alternative components.
By the end, you will know how to structure BoMs that drive accurate MRP planning, correct cost roll-ups, and reliable production scheduling. Whether you are building furniture, assembling electronics, or packaging food products, the patterns here apply.
Creating Your First Bill of Materials in Odoo 19
A basic BoM links a finished product to its components with quantities. In Odoo 19, navigate to Manufacturing → Bills of Materials → New. Select the product, set the BoM type to "Manufacture this product," and add component lines. Here is the equivalent XML data file you can load as module data:
<odoo>
<data noupdate="0">
<!-- Finished product: Wooden Desk -->
<record id="product_wooden_desk" model="product.product">
<field name="name">Wooden Desk</field>
<field name="type">consu</field>
<field name="standard_price">0</field>
</record>
<!-- Components -->
<record id="product_oak_plank" model="product.product">
<field name="name">Oak Plank (120x60cm)</field>
<field name="type">product</field>
<field name="standard_price">45.00</field>
</record>
<record id="product_steel_leg" model="product.product">
<field name="name">Steel Leg (72cm)</field>
<field name="type">product</field>
<field name="standard_price">12.50</field>
</record>
<record id="product_wood_screw" model="product.product">
<field name="name">Wood Screw M6x30</field>
<field name="type">product</field>
<field name="standard_price">0.15</field>
</record>
<!-- Bill of Materials -->
<record id="bom_wooden_desk" model="mrp.bom">
<field name="product_tmpl_id" ref="product_wooden_desk" />
<field name="product_qty">1</field>
<field name="type">normal</field>
</record>
<!-- BoM Lines -->
<record id="bom_line_oak_plank" model="mrp.bom.line">
<field name="bom_id" ref="bom_wooden_desk" />
<field name="product_id" ref="product_oak_plank" />
<field name="product_qty">1</field>
</record>
<record id="bom_line_steel_leg" model="mrp.bom.line">
<field name="bom_id" ref="bom_wooden_desk" />
<field name="product_id" ref="product_steel_leg" />
<field name="product_qty">4</field>
</record>
<record id="bom_line_wood_screw" model="mrp.bom.line">
<field name="bom_id" ref="bom_wooden_desk" />
<field name="product_id" ref="product_wood_screw" />
<field name="product_qty">16</field>
</record>
</data>
</odoo> The mrp.bom record uses product_tmpl_id (the product template), while mrp.bom.line uses product_id (the specific product variant). If your finished good has variants (e.g., desk in walnut vs oak), you can optionally set product_id on the BoM itself to create variant-specific BoMs.
Creating a BoM Programmatically via Python
When migrating from another ERP or importing BoMs from a spreadsheet, creating BoMs via code is more practical. Here is a Python method you can use in a custom module or server action:
from odoo import models, api
class BomImport(models.TransientModel):
_name = 'bom.import.wizard'
_description = 'BoM Import Wizard'
def action_create_sample_bom(self):
"""Create a BoM for Wooden Desk with components."""
Product = self.env['product.product']
Bom = self.env['mrp.bom']
# Find or create the finished product
desk = Product.search(
[('name', '=', 'Wooden Desk')], limit=1
)
if not desk:
desk = Product.create({{
'name': 'Wooden Desk',
'type': 'consu',
}})
# Create the BoM with inline component lines
bom = Bom.create({{
'product_tmpl_id': desk.product_tmpl_id.id,
'product_qty': 1.0,
'type': 'normal',
'bom_line_ids': [
(0, 0, {{
'product_id': self._get_component(
'Oak Plank (120x60cm)', 45.00
).id,
'product_qty': 1.0,
}}),
(0, 0, {{
'product_id': self._get_component(
'Steel Leg (72cm)', 12.50
).id,
'product_qty': 4.0,
}}),
(0, 0, {{
'product_id': self._get_component(
'Wood Screw M6x30', 0.15
).id,
'product_qty': 16.0,
}}),
],
}})
return bom
def _get_component(self, name, cost):
"""Find or create a storable component product."""
product = self.env['product.product'].search(
[('name', '=', name)], limit=1
)
if not product:
product = self.env['product.product'].create({{
'name': name,
'type': 'product',
'standard_price': cost,
}})
return productMulti-Level BoM Structures: Sub-Assemblies and Nested Bills of Materials
A multi-level BoM is a BoM whose components are themselves manufactured products with their own BoMs. Odoo resolves these recursively: when you confirm a manufacturing order for the top-level product, MRP automatically generates manufacturing orders (or purchase orders) for every sub-assembly down the chain.
Consider a Standing Desk that requires a motorized lift mechanism (itself assembled from a motor, brackets, and a control unit) plus a desktop surface. The structure looks like this:
| Level | Product | Qty | Source |
|---|---|---|---|
| L0 | Standing Desk (finished) | 1 | Manufacture |
| L1 | Motorized Lift Assembly | 1 | Manufacture (sub-BoM) |
| L2 | DC Motor 24V | 2 | Purchase |
| L2 | Steel Mounting Bracket | 4 | Purchase |
| L2 | Control Unit PCB | 1 | Purchase |
| L1 | Bamboo Desktop Surface | 1 | Purchase |
| L1 | Cable Management Tray | 1 | Purchase |
<odoo>
<data noupdate="0">
<!-- ── Level 2: Sub-Assembly BoM ── -->
<record id="bom_lift_assembly" model="mrp.bom">
<field name="product_tmpl_id" ref="product_lift_assembly" />
<field name="product_qty">1</field>
<field name="type">normal</field>
</record>
<record id="bom_line_dc_motor" model="mrp.bom.line">
<field name="bom_id" ref="bom_lift_assembly" />
<field name="product_id" ref="product_dc_motor" />
<field name="product_qty">2</field>
</record>
<record id="bom_line_bracket" model="mrp.bom.line">
<field name="bom_id" ref="bom_lift_assembly" />
<field name="product_id" ref="product_steel_bracket" />
<field name="product_qty">4</field>
</record>
<record id="bom_line_control_pcb" model="mrp.bom.line">
<field name="bom_id" ref="bom_lift_assembly" />
<field name="product_id" ref="product_control_pcb" />
<field name="product_qty">1</field>
</record>
<!-- ── Level 0: Top-Level BoM ── -->
<record id="bom_standing_desk" model="mrp.bom">
<field name="product_tmpl_id" ref="product_standing_desk" />
<field name="product_qty">1</field>
<field name="type">normal</field>
</record>
<!-- This component has its own BoM → triggers sub-MO -->
<record id="bom_line_lift" model="mrp.bom.line">
<field name="bom_id" ref="bom_standing_desk" />
<field name="product_id" ref="product_lift_assembly" />
<field name="product_qty">1</field>
</record>
<record id="bom_line_desktop_surface" model="mrp.bom.line">
<field name="bom_id" ref="bom_standing_desk" />
<field name="product_id" ref="product_bamboo_desktop" />
<field name="product_qty">1</field>
</record>
<record id="bom_line_cable_tray" model="mrp.bom.line">
<field name="bom_id" ref="bom_standing_desk" />
<field name="product_id" ref="product_cable_tray" />
<field name="product_qty">1</field>
</record>
</data>
</odoo>When you confirm a manufacturing order for the Standing Desk, Odoo's MRP scheduler checks each component line. If a component has its own BoM of type "normal," MRP generates a child manufacturing order for that sub-assembly. The parent MO waits until all child MOs are complete. This cascade works to any depth: you can nest 5, 10, or 20 levels deep. The MRP scheduler resolves them all.
Exploding a Multi-Level BoM in Python
Odoo provides the explode() method on mrp.bom to flatten a multi-level BoM into all its raw material components. This is useful for cost computation, procurement planning, and custom reports:
def get_raw_materials(self, product, qty=1.0):
"""Flatten a multi-level BoM to raw materials."""
bom = self.env['mrp.bom']._bom_find(product)[product]
if not bom:
return []
# explode() returns (bom_lines, line_data)
# Each entry includes the effective qty at leaf level
_bom_lines, line_data = bom.explode(
product, qty
)
raw_materials = []
for bom_line, data in line_data:
# data['qty'] is the total quantity needed
raw_materials.append({{
'product': bom_line.product_id.name,
'quantity': data['qty'],
'uom': bom_line.product_uom_id.name,
'cost': (
bom_line.product_id.standard_price
* data['qty']
),
}})
return raw_materialsKit BoMs and Phantom Assemblies: Ship Components Without Manufacturing
Not every BoM triggers a manufacturing order. Kit BoMs (type = phantom) are Odoo's way of saying "this product does not physically exist in stock; when someone orders it, deliver its components instead." This is the mechanism behind product bundles, starter kits, gift sets, and any scenario where you sell a group of items as one SKU but ship them individually.
Kit BoM vs Normal BoM
| Behavior | Normal BoM (Manufacture) | Kit / Phantom BoM |
|---|---|---|
| Creates Manufacturing Order? | Yes | No |
| Consumes components from stock? | Yes (during MO) | Yes (during delivery) |
| Produces finished good in stock? | Yes | No (phantom product has zero on-hand) |
| Delivery order lines | 1 line: the finished product | N lines: one per component |
| Inventory valuation | On the finished product | On each component individually |
<odoo>
<data noupdate="0">
<!-- Kit product: Home Office Starter Kit -->
<record id="product_home_office_kit" model="product.product">
<field name="name">Home Office Starter Kit</field>
<field name="type">consu</field>
<field name="list_price">299.00</field>
</record>
<!-- Kit BoM (type=phantom) -->
<record id="bom_home_office_kit" model="mrp.bom">
<field name="product_tmpl_id" ref="product_home_office_kit" />
<field name="product_qty">1</field>
<field name="type">phantom</field>
</record>
<record id="bom_line_kit_desk" model="mrp.bom.line">
<field name="bom_id" ref="bom_home_office_kit" />
<field name="product_id" ref="product_wooden_desk" />
<field name="product_qty">1</field>
</record>
<record id="bom_line_kit_chair" model="mrp.bom.line">
<field name="bom_id" ref="bom_home_office_kit" />
<field name="product_id" ref="product_ergonomic_chair" />
<field name="product_qty">1</field>
</record>
<record id="bom_line_kit_monitor_arm" model="mrp.bom.line">
<field name="bom_id" ref="bom_home_office_kit" />
<field name="product_id" ref="product_monitor_arm" />
<field name="product_qty">1</field>
</record>
<record id="bom_line_kit_cable_organizer" model="mrp.bom.line">
<field name="bom_id" ref="bom_home_office_kit" />
<field name="product_id" ref="product_cable_organizer" />
<field name="product_qty">2</field>
</record>
</data>
</odoo>Phantom Assemblies Inside Multi-Level BoMs
Phantom assemblies are especially powerful when used inside a normal manufacturing BoM. Suppose your Standing Desk has a "Hardware Pack" (screws, bolts, washers) that you want to manage as a logical group but never actually assemble into a separate stock item. Make the Hardware Pack a phantom BoM: when the Standing Desk MO is created, Odoo skips the phantom and pulls its components directly into the parent MO's raw material list.
def is_kit_product(self, product):
"""Check whether a product is sold as a kit."""
bom = self.env['mrp.bom']._bom_find(product)[product]
if bom and bom.type == 'phantom':
return True
return False
def get_kit_components(self, product, sale_qty=1.0):
"""Return the delivery lines for a kit product."""
bom = self.env['mrp.bom']._bom_find(product)[product]
if not bom or bom.type != 'phantom':
return [(product, sale_qty)]
components = []
for line in bom.bom_line_ids:
line_qty = line.product_qty * sale_qty
# Recurse: a kit component can itself be a kit
components.extend(
self.get_kit_components(line.product_id, line_qty)
)
return componentsUse phantom/kit when you never stock the parent product: bundles, variety packs, starter kits, or logical component groups inside a larger BoM. Use normal when the sub-assembly is a real thing you manufacture, stock, and possibly sell separately. The decision affects inventory valuation, procurement triggers, and delivery order structure.
BoM Versioning: Managing Engineering Changes Without Breaking Production
Products evolve. You switch from Supplier A's motor to Supplier B's, replace a plastic bracket with aluminum, or change the screw count from 16 to 12. Odoo 19 handles this through BoM versioning via date effectivity and ECO (Engineering Change Orders) when the PLM module is installed.
Date-Based Effectivity
Every BoM line in Odoo 19 has optional date_start and date_stop fields. This lets you phase components in and out without deleting history:
<odoo>
<data noupdate="0">
<!-- Old component: active until March 31 -->
<record id="bom_line_old_motor" model="mrp.bom.line">
<field name="bom_id" ref="bom_lift_assembly" />
<field name="product_id" ref="product_dc_motor_v1" />
<field name="product_qty">2</field>
<field name="date_stop">2026-03-31</field>
</record>
<!-- New component: active from April 1 -->
<record id="bom_line_new_motor" model="mrp.bom.line">
<field name="bom_id" ref="bom_lift_assembly" />
<field name="product_id" ref="product_dc_motor_v2" />
<field name="product_qty">2</field>
<field name="date_start">2026-04-01</field>
</record>
</data>
</odoo>Engineering Change Orders with PLM
For formal version control, install the PLM (Product Lifecycle Management) module. ECOs create a new revision of the BoM, track the change reason, require approval, and only swap the active BoM version when the ECO is validated. This gives you a full audit trail:
def create_engineering_change(self, bom, description):
"""Create an ECO to revise a Bill of Materials."""
Eco = self.env['mrp.eco']
eco = Eco.create({{
'name': f'ECO - {{bom.product_tmpl_id.name}}',
'bom_id': bom.id,
'product_tmpl_id': bom.product_tmpl_id.id,
'type_id': self.env.ref('mrp_plm.ecotype_bom').id,
'note': description,
'stage_id': self.env.ref(
'mrp_plm.eco_stage_new'
).id,
}})
# The ECO creates a draft copy of the BoM
# Engineers modify the draft, then validate
# Validation swaps the active BoM to the new version
return ecoIf you do not have the PLM module, you can still version BoMs manually: duplicate the BoM, archive the old one, and modify the new one. The downside is no approval workflow and no audit trail. For regulated industries (medical devices, food, aerospace), PLM is effectively mandatory.
Routing Operations on BoM and Component Substitution
A BoM tells Odoo what goes into a product. Routing operations tell Odoo how to build it: the sequence of work center operations, expected durations, and which components are consumed at which step.
Defining Operations on a BoM
In Odoo 19, routing operations are defined directly on the BoM (the separate mrp.routing model was removed in Odoo 15). Each operation specifies a work center, a duration, and optionally which BoM lines are consumed at that step:
<odoo>
<data noupdate="0">
<!-- Work Centers -->
<record id="wc_cutting" model="mrp.workcenter">
<field name="name">Wood Cutting Station</field>
<field name="costs_hour">35.00</field>
<field name="capacity">1</field>
</record>
<record id="wc_assembly" model="mrp.workcenter">
<field name="name">Assembly Line</field>
<field name="costs_hour">28.00</field>
<field name="capacity">3</field>
</record>
<record id="wc_finishing" model="mrp.workcenter">
<field name="name">Finishing & QC</field>
<field name="costs_hour">30.00</field>
<field name="capacity">2</field>
</record>
<!-- Operations on the Wooden Desk BoM -->
<record id="op_cut_plank" model="mrp.routing.workcenter">
<field name="bom_id" ref="bom_wooden_desk" />
<field name="name">Cut oak plank to size</field>
<field name="workcenter_id" ref="wc_cutting" />
<field name="time_cycle_manual">15</field>
<field name="sequence">10</field>
</record>
<record id="op_assemble_desk" model="mrp.routing.workcenter">
<field name="bom_id" ref="bom_wooden_desk" />
<field name="name">Attach legs and hardware</field>
<field name="workcenter_id" ref="wc_assembly" />
<field name="time_cycle_manual">25</field>
<field name="sequence">20</field>
</record>
<record id="op_finish_desk" model="mrp.routing.workcenter">
<field name="bom_id" ref="bom_wooden_desk" />
<field name="name">Sand, stain, and quality check</field>
<field name="workcenter_id" ref="wc_finishing" />
<field name="time_cycle_manual">20</field>
<field name="sequence">30</field>
</record>
</data>
</odoo>Component Substitution (Allowed Alternatives)
Odoo 19 supports component substitution on BoM lines. When a primary component is out of stock, the shop floor operator can swap it for a pre-approved alternative without editing the BoM. This is configured via the "Allowed alternatives" field on each BoM line:
def configure_alternatives(self):
"""Allow birch plank as substitute for oak plank."""
oak_line = self.env.ref(
'my_module.bom_line_oak_plank'
)
birch = self.env.ref('my_module.product_birch_plank')
maple = self.env.ref('my_module.product_maple_plank')
# Set allowed alternatives on the BoM line
oak_line.write({{
'allowed_operation_ids': [
(4, birch.id),
(4, maple.id),
],
}})
# During manufacturing, if oak is unavailable,
# the operator sees birch and maple as options
# in the component substitution wizardWhen an operator substitutes a component, the manufacturing order uses the substitute product's standard cost for that MO. This means your actual production cost reflects reality. However, the BoM cost report still shows the primary component's cost. Run the "BoM Cost" report and the "Manufacturing Analysis" report side by side to catch cost variances from frequent substitutions.
BoM Cost Computation: Rolling Up Material and Operation Costs
Odoo 19 computes BoM cost by summing component costs and operation costs recursively through every level of the BoM tree. The formula at each level is:
BoM Cost = SUM(component_qty * component_standard_price)
+ SUM(operation_duration_hours * workcenter_cost_per_hour)
For multi-level BoMs, component_standard_price of a
manufactured sub-assembly = its own BoM Cost (recursive).Computing BoM Cost Programmatically
The built-in BoM cost report is accessible via Manufacturing → Reporting → BoM Overview. For custom integrations or batch cost updates, use the _get_report_data() method:
def compute_bom_cost(self, bom):
"""Compute total BoM cost including operations."""
material_cost = 0.0
operation_cost = 0.0
for line in bom.bom_line_ids:
child_bom = self.env['mrp.bom']._bom_find(
line.product_id
)[line.product_id]
if child_bom and child_bom.type == 'normal':
# Recursive: sub-assembly cost
unit_cost = self.compute_bom_cost(child_bom)
else:
unit_cost = line.product_id.standard_price
material_cost += line.product_qty * unit_cost
# Add operation costs
for op in bom.operation_ids:
duration_hours = op.time_cycle_manual / 60.0
wc_cost = op.workcenter_id.costs_hour
operation_cost += duration_hours * wc_cost
total = material_cost + operation_cost
return total
def update_finished_product_cost(self, bom):
"""Set the finished product's standard_price
to the computed BoM cost."""
cost = self.compute_bom_cost(bom)
bom.product_tmpl_id.standard_price = cost
return costExample Cost Breakdown: Wooden Desk
| Component / Operation | Qty | Unit Cost | Line Total |
|---|---|---|---|
| Oak Plank (120x60cm) | 1 | $45.00 | $45.00 |
| Steel Leg (72cm) | 4 | $12.50 | $50.00 |
| Wood Screw M6x30 | 16 | $0.15 | $2.40 |
| Op: Cut oak plank (15 min) | - | $35/hr | $8.75 |
| Op: Assembly (25 min) | - | $28/hr | $11.67 |
| Op: Finishing & QC (20 min) | - | $30/hr | $10.00 |
| Total BoM Cost | $127.82 | ||
The BoM cost report uses standard_price from each product. If you use FIFO or AVCO valuation, the actual cost of a manufacturing order may differ because components are consumed at their real purchase cost, not the standard price. Compare the BoM Overview report with the Manufacturing Analysis report to track standard-vs-actual variance.
5 BoM Configuration Mistakes That Silently Break MRP Planning
Circular BoM References
Product A has a BoM containing Product B. Product B has a BoM containing Product A. Odoo will raise a ValidationError if the cycle is direct, but indirect cycles through 3+ levels can slip through in some edge cases. MRP scheduling enters an infinite loop and the scheduler cron times out silently. Always validate new BoMs with the BoM Overview report before running the scheduler.
Kit BoM on a Storable Product
If you set a phantom/kit BoM on a product of type "Storable", Odoo will explode it into components during delivery, but you might also have that product sitting in stock from a previous purchase. The result: stock moves that make no sense, inventory valuation that double-counts, and warehouse staff confused by pick lists that show components they never stocked. Kit products should almost always be type "Consumable" or "Service" — never "Storable."
Forgetting UoM Conversions on BoM Lines
You stock wire in meters but your BoM specifies 50 centimeters. If the UoM categories do not match or the conversion factor is missing, Odoo will either block the MO with a UserError or — worse — consume 50 meters instead of 50 centimeters. Always double-check that the BoM line UoM belongs to the same UoM category as the product's purchase/stock UoM.
Multiple Active BoMs for the Same Product
Odoo allows multiple active BoMs for one product template. The MRP scheduler picks the first one by sequence number. If you duplicated a BoM for testing and forgot to archive it, production may use the wrong BoM silently. Use the "BoM" smart button on the product form to check how many active BoMs exist. Only one should be active for a given product + variant combination unless you intentionally use sequence-based selection.
Phantom Sub-Assemblies Not Propagating to MO
You add a phantom sub-assembly to a normal BoM but the manufacturing order still shows the phantom product as a single component line instead of its exploded parts. This happens when the phantom product does not have a BoM assigned. The phantom type lives on the BoM, not the product. If the BoM is archived, deleted, or points to the wrong product template, Odoo treats the component as a regular purchased item.
What Properly Structured BoMs Save Your Manufacturing Business
A clean BoM structure is not an IT project — it is an operational efficiency multiplier. Here is the measurable impact:
Accurate BoM quantities mean procurement orders match actual consumption. No more over-ordering "just in case" or emergency purchases when a component runs short mid-production.
With recursive BoM cost roll-ups, your sales team can quote custom configurations in minutes instead of waiting days for engineering to calculate material and labor costs manually.
Multi-level BoMs with correct lead times let MRP schedule sub-assemblies ahead of the parent MO. Components arrive at the right workstation at the right time instead of halting the line.
Beyond direct savings, clean BoMs unlock traceability for regulated industries. When a component has a quality recall, you can trace every finished product that used it through the BoM explosion chain. This turns a potential weeks-long recall investigation into a single SQL query or a click on the "Where Used" report in Odoo.
Kit BoMs also have a direct revenue impact. By bundling slow-moving inventory into attractive kits, businesses have reported 12-18% increases in average order value. The kit appears as a single line on the sales order, but delivery picks the individual components — no re-packaging labor required.
Optimization Metadata
Complete guide to Bill of Materials in Odoo 19. Multi-level BoM, kit/phantom assemblies, component substitution, routing operations, BoM versioning, and cost computation with code examples.
1. "Creating Your First Bill of Materials in Odoo 19"
2. "Multi-Level BoM Structures: Sub-Assemblies and Nested Bills of Materials"
3. "Kit BoMs and Phantom Assemblies: Ship Components Without Manufacturing"
4. "BoM Cost Computation: Rolling Up Material and Operation Costs"
5. "5 BoM Configuration Mistakes That Silently Break MRP Planning"