Your Production Line Produces More Than One Thing. Your ERP Should Know That.
A sawmill cuts lumber. The main product is planks. But the same process also produces sawdust, bark chips, and wood shavings. A dairy processes raw milk into cheese (the main product), but also yields whey, cream, and buttermilk. A petroleum refinery cracks crude oil into gasoline, diesel, kerosene, and asphalt — none of them are "waste," and all of them have market value.
If your ERP only tracks the primary finished good, you're flying blind on the secondary outputs. That sawdust has value — it sells to particleboard manufacturers, garden centers, and biomass energy plants. The whey goes to protein supplement companies. Untracked by-products are uncosted inventory sitting on your floor, invisible to accounting, and missing from your margin calculations.
Odoo 19's Manufacturing module (MRP) has first-class support for by-products and co-products — secondary outputs that are automatically created when a manufacturing order completes. This guide walks you through configuring them on Bills of Materials, allocating costs correctly, routing co-products to different warehouses, valuing by-products for accounting, managing waste streams, and building reports that give you full visibility into every gram of output from your production line.
Configuring By-Products on a Bill of Materials in Odoo 19
A by-product in Odoo is any product that is produced as a side effect of manufacturing the main product. It is defined on the BoM (Bill of Materials) and is automatically moved into stock when the manufacturing order is marked as done. Odoo 19 requires you to enable the feature first.
Step 1: Enable the By-Products Feature
Navigate to Manufacturing → Configuration → Settings. Under the Operations section, enable By-Products. Click Save. This adds a "By-Products" tab on every BoM form.
Step 2: Add By-Products to Your BoM
Open a Bill of Materials (Manufacturing → Bills of Materials). Click the By-Products tab. Add a line for each secondary output. For each by-product, you specify the product, quantity, and unit of measure. You can also assign a specific operation if you use work centers — this tells Odoo at which stage of the routing the by-product is produced.
<!-- By-product: Sawdust from Plank manufacturing -->
<record id="bom_plank_oak" model="mrp.bom">
<field name="product_tmpl_id" ref="product_oak_plank"/>
<field name="product_qty">1</field>
<field name="product_uom_id" ref="uom.product_uom_unit"/>
</record>
<!-- BoM components (inputs) -->
<record id="bom_line_oak_log" model="mrp.bom.line">
<field name="bom_id" ref="bom_plank_oak"/>
<field name="product_id" ref="product_oak_log"/>
<field name="product_qty">1</field>
<field name="product_uom_id" ref="uom.product_uom_unit"/>
</record>
<!-- By-product lines (outputs) -->
<record id="byproduct_sawdust" model="mrp.bom.byproduct">
<field name="bom_id" ref="bom_plank_oak"/>
<field name="product_id" ref="product_sawdust"/>
<field name="product_qty">5</field>
<field name="product_uom_id" ref="uom.product_uom_kgm"/>
<field name="cost_share">3.0</field>
</record>
<record id="byproduct_bark_chips" model="mrp.bom.byproduct">
<field name="bom_id" ref="bom_plank_oak"/>
<field name="product_id" ref="product_bark_chips"/>
<field name="product_qty">2</field>
<field name="product_uom_id" ref="uom.product_uom_kgm"/>
<field name="cost_share">2.0</field>
</record>When the manufacturing order for 1 Oak Plank completes, Odoo automatically creates stock moves for 5 kg of Sawdust and 2 kg of Bark Chips into your finished goods location. No manual stock adjustments, no forgotten inventory.
Step 3: Programmatic By-Product Creation
If you need to add by-products dynamically — for example, a custom module that calculates by-product quantities based on input quality — here is the Python approach:
from odoo import models, fields, api
class MrpBom(models.Model):
_inherit = 'mrp.bom'
def action_add_sawmill_byproducts(self):
"""Add standard sawmill by-products to a BoM.
Called from a button on the BoM form view or
via automated action during BoM creation.
"""
ByProduct = self.env['mrp.bom.byproduct']
sawdust = self.env.ref('my_module.product_sawdust')
bark = self.env.ref('my_module.product_bark_chips')
kg = self.env.ref('uom.product_uom_kgm')
for bom in self:
# Skip if by-products already configured
if bom.byproduct_ids:
continue
ByProduct.create([
{
'bom_id': bom.id,
'product_id': sawdust.id,
'product_qty': 5.0,
'product_uom_id': kg.id,
'cost_share': 3.0, # 3% of total cost
},
{
'bom_id': bom.id,
'product_id': bark.id,
'product_qty': 2.0,
'product_uom_id': kg.id,
'cost_share': 2.0, # 2% of total cost
},
]) In Odoo 19, the term "by-product" covers both concepts technically. The distinction is economic: a by-product is a secondary output with minor value relative to the main product (sawdust from lumber). A co-product is a secondary output with significant, sometimes equal, value (diesel from crude oil refining). In Odoo, you model both using the same mrp.bom.byproduct model — the difference is in the cost_share percentage you assign.
Cost Allocation for By-Products and Co-Products in Odoo 19 Manufacturing
When a manufacturing order consumes $100 of raw materials and labor, that $100 needs to be distributed across all outputs — not just the main product. Without proper cost allocation, the main product carries 100% of the cost while by-products enter inventory at $0. This distorts your margins on both sides: the main product looks less profitable than it is, and the by-products show infinite margin when sold.
The cost_share Field
Each by-product line on the BoM has a cost_share field expressed as a percentage. The main product's cost share is calculated as 100% minus the sum of all by-product cost shares. For example:
| Output | Type | cost_share | Cost (if total = $100) |
|---|---|---|---|
| Oak Plank | Main Product | 95% (implicit) | $95.00 |
| Sawdust | By-Product | 3% | $3.00 |
| Bark Chips | By-Product | 2% | $2.00 |
This cost split happens at the accounting level. When the manufacturing order is completed, Odoo creates journal entries that debit the finished goods accounts for each product according to its cost share, and credits the WIP (Work in Progress) account for the total consumed amount.
Co-Product Scenario: Equal-Value Outputs
For a petroleum refinery or a dairy processor, the outputs are closer in value. Here is how you would configure a cheese-making BoM where whey protein is a valuable co-product:
# BoM: Raw Milk → Cheddar Cheese + Whey Protein + Buttermilk
bom = env['mrp.bom'].create({
'product_tmpl_id': cheddar_cheese.product_tmpl_id.id,
'product_qty': 10, # 10 kg of cheese
'product_uom_id': kg.id,
})
# Components (inputs)
env['mrp.bom.line'].create({
'bom_id': bom.id,
'product_id': raw_milk.id,
'product_qty': 100, # 100 liters of raw milk
'product_uom_id': liter.id,
})
# Co-products (valuable secondary outputs)
env['mrp.bom.byproduct'].create([
{
'bom_id': bom.id,
'product_id': whey_protein.id,
'product_qty': 6, # 6 kg whey protein
'product_uom_id': kg.id,
'cost_share': 25.0, # 25% — whey is valuable
},
{
'bom_id': bom.id,
'product_id': buttermilk.id,
'product_qty': 80, # 80 liters buttermilk
'product_uom_id': liter.id,
'cost_share': 10.0, # 10% — lower value
},
])
# Main product (Cheddar) gets 100% - 25% - 10% = 65%Use the net realizable value (NRV) method: calculate the market price of each output, subtract any post-split processing costs, and allocate the joint cost proportionally to each output's NRV. For example, if cheese sells for $8/kg (NRV $80), whey sells for $5/kg (NRV $30), and buttermilk sells for $0.50/L (NRV $40), the total NRV is $150. Cheese gets 53%, whey gets 20%, buttermilk gets 27%. Adjust quarterly as market prices shift.
Routing Co-Products to Different Locations and Warehouses in Odoo 19
By default, all by-products move to the same finished goods location as the main product. But in practice, by-products often need to go somewhere else: sawdust goes to a bulk storage silo, whey goes to a cold storage room, or waste material routes directly to a scrap location. Odoo 19 lets you override the destination per by-product line.
Setting a Custom Destination on By-Product Lines
On the BoM form, each by-product line has an optional Operation field. When the manufacturing order uses routing (work centers), by-products are produced at the operation's output location. For more control, you can override the stock move destination in a custom module:
from odoo import models, fields, api
class MrpBomByproduct(models.Model):
_inherit = 'mrp.bom.byproduct'
destination_location_id = fields.Many2one(
'stock.location',
string='Destination Location',
help='Override the default finished goods location '
'for this by-product. Leave empty to use the '
'manufacturing order default.',
)
class MrpProduction(models.Model):
_inherit = 'mrp.production'
def _get_moves_finished_values(self):
"""Override to apply custom destinations to by-product moves."""
moves = super()._get_moves_finished_values()
for move_vals in moves:
# Find matching by-product line
byproduct_id = move_vals.get('byproduct_id')
if byproduct_id:
bp_line = self.env['mrp.bom.byproduct'].browse(
byproduct_id
)
if bp_line.destination_location_id:
move_vals['location_dest_id'] = (
bp_line.destination_location_id.id
)
return moves<record id="view_mrp_bom_byproduct_form_inherit" model="ir.ui.view">
<field name="name">mrp.bom.byproduct.form.inherit</field>
<field name="model">mrp.bom.byproduct</field>
<field name="inherit_id" ref="mrp.mrp_bom_byproduct_form_view"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='operation_id']" position="after">
<field name="destination_location_id"
options="{{'no_create': True}}"
domain="[('usage', '=', 'internal')]"/>
</xpath>
</field>
</record> With this customization, your production planners can specify that sawdust routes to WH/Stock/Bulk Silo, bark chips route to WH/Stock/Outdoor Yard, and the main product routes to WH/Stock/Finished Goods — all from a single manufacturing order.
If your by-product is always produced at a specific work center (e.g., sawdust is always produced at the "Cutting" operation), assign the operation on the by-product line instead of writing custom code. The by-product will move to that operation's output location. The custom destination_location_id approach above is only needed when you want a destination that is different from any work center's output location.
Inventory Valuation of By-Products: FIFO, AVCO, and Standard Cost in Odoo 19
By-products enter inventory with a cost determined by the cost_share percentage. But how that cost is subsequently tracked depends on the product's costing method. Odoo 19 supports three costing methods, and each interacts differently with by-product costs.
| Costing Method | By-Product Entry Cost | Subsequent Valuation | Best For |
|---|---|---|---|
| Standard Cost | Fixed price from product form, variance posted to adjustment account | Always at standard price | Stable by-products with predictable value (sawdust, scrap metal) |
| AVCO | cost_share % of MO cost, averaged with existing stock | Running weighted average | By-products received from multiple MOs at varying costs |
| FIFO | cost_share % of MO cost, tracked per layer | Sold at oldest layer cost | Perishable by-products (whey, buttermilk) where batch cost matters |
Journal Entries on MO Completion
When a manufacturing order with by-products is marked as done, Odoo generates the following accounting entries (assuming automated valuation):
# MO: 1 Oak Log ($100) → 1 Oak Plank + 5kg Sawdust + 2kg Bark
# cost_share: Plank=95%, Sawdust=3%, Bark=2%
# 1. Consume raw materials (Oak Log)
Debit: WIP Account (Work in Progress) $100.00
Credit: Raw Materials Inventory $100.00
# 2. Receive finished goods (main product)
Debit: Finished Goods — Oak Plank $95.00
Credit: WIP Account $95.00
# 3. Receive by-product (Sawdust)
Debit: Finished Goods — Sawdust $3.00
Credit: WIP Account $3.00
# 4. Receive by-product (Bark Chips)
Debit: Finished Goods — Bark Chips $2.00
Credit: WIP Account $2.00
# Result: WIP = $0 (fully allocated)The key insight: the WIP account zeroes out completely. Every dollar of input cost is allocated across all outputs. If your cost_share percentages don't sum to 100%, the main product absorbs the remainder — the WIP account still zeroes out, but your main product's unit cost will be higher than expected.
Add a constraint to catch BoMs where by-product cost shares exceed 100%. While Odoo won't crash, a by-product cost_share of 60% on top of another at 50% means the main product gets a negative cost share of -10%, creating a negative inventory valuation entry. This is technically valid accounting (the main product is "subsidized" by the by-products), but it confuses auditors and usually signals a data entry error.
from odoo import models, api
from odoo.exceptions import ValidationError
class MrpBom(models.Model):
_inherit = 'mrp.bom'
@api.constrains('byproduct_ids')
def _check_byproduct_cost_share(self):
for bom in self:
total_share = sum(
bom.byproduct_ids.mapped('cost_share')
)
if total_share > 100:
raise ValidationError(
f'By-product cost shares total '
f'{total_share}%%, which exceeds 100%%. '
f'The main product would receive a '
f'negative cost allocation. Please '
f'adjust the cost_share values.'
)
if total_share > 80:
# Warning-level: log but allow
import logging
_logger = logging.getLogger(__name__)
_logger.warning(
'BoM %s: by-product cost shares total '
'%s%%, leaving only %s%% for the main '
'product.', bom.display_name,
total_share, 100 - total_share,
)Managing Production Waste Alongside By-Products in Odoo 19
Not every secondary output has value. Some outputs are genuine waste — material that must be disposed of, often at a cost. Odoo distinguishes between by-products (which enter stock) and scrap (which exits stock). The trick is knowing when to use each.
| Output Type | Odoo Model | Enters Inventory? | Has Cost Share? | Example |
|---|---|---|---|---|
| By-Product | mrp.bom.byproduct | Yes | Yes (0-100%) | Sawdust, whey, scrap metal |
| Scrap | stock.scrap | No (moves to scrap location) | No (cost absorbed by main product) | Defective pieces, contaminated material |
| Zero-Value By-Product | mrp.bom.byproduct | Yes (for tracking) | Yes (set to 0%) | Emissions data, water usage tracking |
Automated Scrap from Manufacturing Orders
Odoo 19 allows operators to scrap directly from the manufacturing order. But if your process always produces a predictable amount of waste (e.g., 2% material loss during cutting), you can automate scrap creation:
from odoo import models, api
class MrpProduction(models.Model):
_inherit = 'mrp.production'
def button_mark_done(self):
"""Override to auto-scrap predictable waste."""
res = super().button_mark_done()
for production in self:
production._create_automatic_scrap()
return res
def _create_automatic_scrap(self):
"""Create scrap orders for predictable waste.
Reads a 'waste_percentage' field from the BoM
and scraps that percentage of each component.
"""
Scrap = self.env['stock.scrap']
scrap_location = self.env.ref(
'stock.stock_location_scrapped'
)
for move in self.move_raw_ids.filtered(
lambda m: m.state == 'done'
):
waste_pct = (
self.bom_id.waste_percentage or 0.0
)
if waste_pct <= 0:
continue
scrap_qty = move.quantity * (waste_pct / 100)
if scrap_qty > 0:
Scrap.create({
'product_id': move.product_id.id,
'scrap_qty': scrap_qty,
'product_uom_id': move.product_uom.id,
'production_id': self.id,
'location_id': (
self.location_src_id.id
),
'scrap_location_id': (
scrap_location.id
),
}).action_validate()Use a zero-cost by-product when you need to track the quantity in inventory (for environmental reporting, regulatory compliance, or future sale potential) but don't want to allocate production cost to it. Use scrap when the material is truly disposed of and leaves your inventory system. The accounting difference: a zero-cost by-product sits in stock at $0; scrap posts a loss to your scrap expense account.
Reporting on By-Product Yield, Cost Recovery, and Production Efficiency
Once by-products are flowing through your system, you need visibility into three metrics: yield (are we producing the expected quantity of each by-product?), cost recovery (how much revenue are by-products generating?), and efficiency (what percentage of input is becoming useful output vs. waste?).
Custom SQL View for By-Product Yield Analysis
Odoo's standard manufacturing analysis report tracks main product output. To get by-product visibility, create a custom report model:
from odoo import models, fields, tools
class ByproductYieldReport(models.Model):
_name = 'report.byproduct.yield'
_description = 'By-Product Yield Analysis'
_auto = False
_order = 'date desc'
date = fields.Date(readonly=True)
production_id = fields.Many2one(
'mrp.production', readonly=True,
)
product_id = fields.Many2one(
'product.product', string='By-Product',
readonly=True,
)
main_product_id = fields.Many2one(
'product.product', string='Main Product',
readonly=True,
)
expected_qty = fields.Float(readonly=True)
actual_qty = fields.Float(readonly=True)
yield_pct = fields.Float(
string='Yield %', readonly=True,
)
cost_share = fields.Float(readonly=True)
allocated_cost = fields.Float(readonly=True)
def init(self):
tools.drop_view_if_exists(
self.env.cr, self._table
)
self.env.cr.execute(f"""
CREATE OR REPLACE VIEW {self._table} AS (
SELECT
sm.id AS id,
mp.date_start::date AS date,
mp.id AS production_id,
sm.product_id AS product_id,
mp.product_id AS main_product_id,
(bp.product_qty * mp.product_qty
/ bb.product_qty)
AS expected_qty,
sm.quantity AS actual_qty,
CASE
WHEN bp.product_qty > 0
THEN (sm.quantity * 100.0)
/ (bp.product_qty
* mp.product_qty
/ bb.product_qty)
ELSE 0
END AS yield_pct,
bp.cost_share AS cost_share,
sm.quantity * sm.price_unit
AS allocated_cost
FROM stock_move sm
JOIN mrp_production mp
ON sm.production_id = mp.id
JOIN mrp_bom bb
ON mp.bom_id = bb.id
JOIN mrp_bom_byproduct bp
ON bp.bom_id = bb.id
AND bp.product_id = sm.product_id
WHERE sm.state = 'done'
AND sm.production_id IS NOT NULL
AND sm.byproduct_id IS NOT NULL
)
""")This report gives you a row for every by-product produced across all manufacturing orders. You can pivot it by date, by-product, or main product to spot yield trends. A yield percentage consistently below 90% signals a process issue — the BoM expected 5 kg of sawdust but you're only getting 4.2 kg, meaning material is being lost somewhere in the process.
<record id="action_byproduct_yield_report" model="ir.actions.act_window">
<field name="name">By-Product Yield Analysis</field>
<field name="res_model">report.byproduct.yield</field>
<field name="view_mode">pivot,graph,list</field>
<field name="context">{{
'search_default_group_by_product': 1,
'search_default_this_month': 1,
}}</field>
</record>
<menuitem id="menu_byproduct_yield"
name="By-Product Yield"
parent="mrp.menu_mrp_reporting"
action="action_byproduct_yield_report"
sequence="30"/>Add a second metric to your dashboard: cost recovery rate = (by-product sales revenue / by-product allocated cost) x 100. If you allocated $3 of production cost to sawdust and sold it for $4.50, your cost recovery is 150% — the by-product is more than paying for itself. If recovery drops below 100%, you're losing money on the by-product and should either reduce its cost_share (pushing more cost to the main product) or find a higher-paying buyer.
4 By-Product Mistakes That Silently Corrupt Your Manufacturing Costs
Leaving cost_share at 0% on Valuable By-Products
The default cost_share on a new by-product line is 0%. If you add sawdust as a by-product but forget to set its cost share, the sawdust enters inventory at $0.00. When you sell it, your books show 100% gross margin on sawdust (revenue minus zero cost). Meanwhile, the main product absorbs 100% of the manufacturing cost, making it look less profitable than it actually is. Both margins are wrong.
Add a server action or onchange that warns when a by-product is saved with cost_share = 0 and the product has a non-zero sale price. Use the constraint we showed in Step 04 to enforce a minimum cost share for products that have a sales price configured.
By-Product UoM Mismatch with the Product's Default UoM
If the sawdust product is configured with a default UoM of "Units" but the BoM by-product line specifies "kg," Odoo will attempt a UoM conversion. If the product's UoM category doesn't match (Unit vs. Weight), the manufacturing order will fail on completion with a cryptic UoM conversion error. This only surfaces at MO completion time, not when saving the BoM.
Always ensure the by-product's UoM on the BoM line belongs to the same UoM category as the product's default UoM. Add a BoM validation check that runs on save: compare byproduct.product_uom_id.category_id with byproduct.product_id.uom_id.category_id.
Forgetting By-Products When Using "Produce" Wizard Partial Quantities
When a manufacturing order is partially produced (e.g., you planned to make 10 planks but only completed 7), Odoo scales the by-product quantities proportionally. However, real-world by-product ratios are not always linear. A batch of 7 planks might produce more sawdust per plank than a full batch of 10, because setup waste is constant regardless of batch size. If operators don't adjust the by-product quantities in the completion wizard, your inventory counts will drift.
Train operators to verify by-product quantities in the "Produce" wizard before confirming. For processes with non-linear by-product ratios, consider using the Manufacturing Tablet View with required quality checks that force the operator to weigh or count by-products before the system accepts the completion.
Not Tracking By-Products with Lot/Serial Numbers for Traceability
If your main product uses lot tracking but your by-products don't, you lose the ability to trace a batch of by-product back to its source manufacturing order. For food, pharma, and chemical industries, this breaks regulatory traceability requirements. A contamination recall on the main product should also flag all by-products from the same batch.
Enable lot tracking on by-product products when the main product uses lot tracking. Configure the BoM to auto-generate lot numbers for by-products that inherit the main product's lot prefix. This creates a traceable chain: MO-2026-0547 produced LOT-PLANK-0547 (main) and LOT-SAWDUST-0547 (by-product).
What Proper By-Product Tracking Saves Your Manufacturing Operation
By-product tracking isn't overhead — it's revenue recovery. Here's what changes when every output from your production line is captured in the system:
By-products that were previously discarded or given away are now tracked, valued, and sold. Sawmills, dairies, and metal fabricators typically find 5-15% of additional revenue hiding in their waste streams.
With cost_share allocation, your main product's unit cost reflects reality. Pricing decisions, margin analysis, and make-vs-buy calculations are based on correct numbers, not inflated costs.
Every gram of output is accounted for in the system. ISO 14001 environmental audits, food safety HACCP reviews, and financial audits all require this level of material traceability.
The hidden ROI is process improvement visibility. When you track by-product yield over time, you can spot trends: a declining sawdust yield might indicate blade dulling on the saw, an increasing whey ratio might signal changes in milk quality from a supplier. By-product data becomes a leading indicator for production issues — before the main product quality starts to suffer.
Optimization Metadata
Configure by-products and co-products in Odoo 19 MRP. Cost allocation with cost_share, co-product routing, inventory valuation, waste management, and yield reporting.
1. "Configuring By-Products on a Bill of Materials in Odoo 19"
2. "Cost Allocation for By-Products and Co-Products in Odoo 19 Manufacturing"
3. "Inventory Valuation of By-Products: FIFO, AVCO, and Standard Cost in Odoo 19"
4. "4 By-Product Mistakes That Silently Corrupt Your Manufacturing Costs"