GuideMarch 13, 2026

Packaging Management in Odoo 19:
UoM Conversions, Inner Packs & Pallet Configuration

INTRODUCTION

Your Warehouse Speaks in Eaches, Your Supplier Speaks in Pallets

A single SKU lives in at least three different containers during its lifecycle: it arrives on a pallet of 48 outer cartons, each carton holds 6 inner packs, and each inner pack contains 12 eaches. Your purchasing team orders in pallets, your warehouse picks in inner packs, and your e-commerce customer buys a single unit. If your ERP cannot translate seamlessly between these packaging tiers, you end up with manual conversion spreadsheets, mis-picks, and shipping labels that show the wrong weight.

Odoo 19 ships with a packaging system that most implementations leave at its defaults — a single "Box" packaging type and no unit-of-measure linkage. This guide walks through the complete configuration: defining multi-tier packaging hierarchies, linking them to UoM conversions, assigning GS1 barcodes per packaging level, and feeding accurate dimensions into shipping carrier APIs for rate calculation.

Every code example in this guide is production-tested on Odoo 19 Community and Enterprise. We will build a custom module that extends the standard packaging model with pallet layer counts, stacking limits, and carrier dimension overrides — the fields Odoo does not give you out of the box.

01

Enabling Product Packaging and Understanding the Data Model

Before writing any code, enable the packaging feature. Navigate to Inventory → Configuration → Settings and check Product Packagings under the Operations section. This activates the product.packaging model and adds the Packaging tab to every product form.

The product.packaging model in Odoo 19 stores the relationship between a product and a specific container type. Each packaging record holds: a name (e.g., "Inner Pack"), a quantity (units per package), an optional barcode, and a link to a package type (stock.package.type) which defines physical dimensions and weight.

Python — models/product_packaging.py
from odoo import models, fields, api


class ProductPackaging(models.Model):
    _inherit = 'product.packaging'

    # ── Extended Packaging Fields ──
    packaging_tier = fields.Selection(
        selection=[
            ('each', 'Each / Unit'),
            ('inner', 'Inner Pack'),
            ('outer', 'Outer Carton'),
            ('pallet', 'Pallet'),
        ],
        string='Packaging Tier',
        default='each',
        required=True,
        help='Defines the hierarchy level in the packaging structure.',
    )
    layers_per_pallet = fields.Integer(
        string='Layers per Pallet',
        help='Number of stacked layers when this packaging is palletized.',
    )
    max_stack_weight = fields.Float(
        string='Max Stack Weight (kg)',
        help='Maximum weight before the bottom layer is crushed.',
    )
    gtin_14 = fields.Char(
        string='GTIN-14',
        size=14,
        help='GS1 identifier for this packaging level (AI 01).',
    )
    is_shipping_default = fields.Boolean(
        string='Default for Shipping',
        help='Use this packaging for carrier rate calculations.',
    )

    @api.constrains('qty')
    def _check_qty_positive(self):
        for rec in self:
            if rec.qty <= 0:
                raise models.ValidationError(
                    f"Packaging '{{rec.name}}' must have a quantity greater than zero."
                )
XML — views/product_packaging_views.xml
<?xml version="1.0" encoding="utf-8"?>
<odoo>
  <record id="product_packaging_form_inherit" model="ir.ui.view">
    <field name="name">product.packaging.form.inherit.tier</field>
    <field name="model">product.packaging</field>
    <field name="inherit_id" ref="product.product_packaging_form_view"/>
    <field name="arch" type="xml">
      <xpath expr="//field[@name='qty']" position="after">
        <field name="packaging_tier"/>
        <field name="gtin_14"/>
        <field name="is_shipping_default"/>
      </xpath>
      <xpath expr="//field[@name='package_type_id']" position="after">
        <field name="layers_per_pallet"
               invisible="packaging_tier != 'pallet'"/>
        <field name="max_stack_weight"
               invisible="packaging_tier != 'pallet'"/>
      </xpath>
    </field>
  </record>
</odoo>
Why Extend, Not Configure?

Odoo's default product.packaging model has a flat structure — every packaging is a peer. By adding a packaging_tier selection, your warehouse logic can distinguish between inners and outers without relying on naming conventions. This matters when you write automated put-away rules or generate pallet labels.

02

Unit of Measure Conversions: Linking Packaging Quantities to UoM

Odoo 19 decoupled UoM categories from strict enforcement (the old uom.category gatekeeping is gone — see our UoM migration guide). This means you have more flexibility but also more rope to hang yourself with. The key rule: packaging quantity is always expressed in the product's base UoM.

If your product's base UoM is kg and an inner pack contains 5 kg, set the packaging qty to 5. If your product's UoM is Unit and a carton holds 24 units, qty = 24. The conversion between the packaging level and any purchase/sales UoM happens through Odoo's standard UoM conversion engine.

Python — models/product_packaging_uom.py
from odoo import models, fields, api
from odoo.exceptions import UserError


class ProductPackagingUoM(models.Model):
    _inherit = 'product.packaging'

    purchase_uom_id = fields.Many2one(
        'uom.uom',
        string='Purchase UoM',
        help='UoM used when ordering this packaging level from suppliers.',
    )
    purchase_uom_qty = fields.Float(
        string='Purchase UoM Qty',
        digits='Product Unit of Measure',
        help='Quantity in Purchase UoM that equals one packaging unit.',
    )

    def _compute_base_qty_from_purchase(self, purchase_qty):
        """Convert a purchase quantity into base UoM quantity.

        Example: Product base UoM = Units
                 Packaging = Pallet (qty=576 units)
                 Purchase UoM = Dozen (1 dozen = 12 units)
                 purchase_uom_qty = 48 (48 dozen per pallet)
                 Ordering 2 pallets = 2 * 576 = 1152 units
        """
        self.ensure_one()
        if not self.purchase_uom_id:
            return purchase_qty * self.qty

        # Convert purchase UoM to product base UoM
        base_uom = self.product_id.uom_id
        converted = self.purchase_uom_id._compute_quantity(
            purchase_qty * self.purchase_uom_qty,
            base_uom,
            raise_if_failure=True,
        )
        return converted

    @api.constrains('purchase_uom_id', 'purchase_uom_qty')
    def _check_purchase_uom_consistency(self):
        for rec in self:
            if rec.purchase_uom_id and rec.purchase_uom_qty <= 0:
                raise UserError(
                    "Purchase UoM quantity must be positive when "
                    "a purchase UoM is set."
                )

Real-World Conversion Table

Here is how a typical beverage product maps across packaging tiers and UoMs:

Packaging TierContainsQty (base UoM: Unit)Purchase UoMPurchase Qty
Each1 bottle1Unit1
Inner Pack (6-pack)6 bottles66-pack1
Outer Carton4 inner packs24Case1
Pallet48 cartons1,152Pallet1
Avoid the Floating-Point Trap

When converting between UoMs with non-integer ratios (e.g., kg to lb), Odoo uses Python's float type. A packaging of 2.2046 lb converts to 0.99999... kg due to IEEE 754 precision. Always use Odoo's float_round utility and set digits='Product Unit of Measure' on your fields. Never compare converted quantities with == — use float_compare instead.

03

Configuring the Inner Pack / Outer Carton / Pallet Hierarchy

The packaging hierarchy is what transforms a flat list of packaging records into a logical tree. Odoo does not enforce parent-child relationships between packaging levels natively. We build this relationship using a computed field that validates the math: a pallet's quantity must be an exact multiple of the outer carton's quantity, which must be an exact multiple of the inner pack's quantity.

Python — models/packaging_hierarchy.py
from odoo import models, fields, api
from odoo.exceptions import ValidationError
from odoo.tools import float_compare, float_round


class PackagingHierarchy(models.Model):
    _inherit = 'product.packaging'

    parent_packaging_id = fields.Many2one(
        'product.packaging',
        string='Parent Packaging',
        domain="[('product_id', '=', product_id), ('id', '!=', id)]",
        help='The next packaging level up (e.g., Inner Pack → Outer Carton).',
    )
    child_packaging_ids = fields.One2many(
        'product.packaging',
        'parent_packaging_id',
        string='Child Packagings',
    )
    units_per_parent = fields.Float(
        string='Units in Parent',
        compute='_compute_units_per_parent',
        store=True,
        help='How many of this packaging fit in the parent packaging.',
    )

    @api.depends('qty', 'parent_packaging_id.qty')
    def _compute_units_per_parent(self):
        for rec in self:
            if rec.parent_packaging_id and rec.qty:
                rec.units_per_parent = float_round(
                    rec.parent_packaging_id.qty / rec.qty,
                    precision_digits=2,
                )
            else:
                rec.units_per_parent = 0.0

    @api.constrains('parent_packaging_id', 'qty')
    def _check_hierarchy_divisibility(self):
        for rec in self:
            if not rec.parent_packaging_id or not rec.qty:
                continue
            parent_qty = rec.parent_packaging_id.qty
            remainder = parent_qty % rec.qty
            if float_compare(remainder, 0, precision_digits=4) != 0:
                raise ValidationError(
                    f"Parent packaging '{{rec.parent_packaging_id.name}}' "
                    f"(qty={{parent_qty}}) is not an exact multiple of "
                    f"'{{rec.name}}' (qty={{rec.qty}}). "
                    f"Remainder: {{remainder}}."
                )

Defining Package Types with Physical Dimensions

Each tier maps to a stock.package.type record that stores length, width, height, and max weight. These dimensions feed directly into carrier rate calculations. Navigate to Inventory → Configuration → Package Types:

XML — data/package_types.xml
<?xml version="1.0" encoding="utf-8"?>
<odoo>
  <data noupdate="1">

    <record id="package_type_inner" model="stock.package.type">
      <field name="name">Inner Pack (6-unit)</field>
      <field name="height">120</field>
      <field name="width">200</field>
      <field name="packaging_length">150</field>
      <field name="max_weight">2.5</field>
      <field name="barcode">PKG-INNER-6</field>
    </record>

    <record id="package_type_outer" model="stock.package.type">
      <field name="name">Outer Carton (24-unit)</field>
      <field name="height">250</field>
      <field name="width">400</field>
      <field name="packaging_length">310</field>
      <field name="max_weight">12.0</field>
      <field name="barcode">PKG-OUTER-24</field>
    </record>

    <record id="package_type_pallet" model="stock.package.type">
      <field name="name">EUR Pallet (1200x800)</field>
      <field name="height">1600</field>
      <field name="width">800</field>
      <field name="packaging_length">1200</field>
      <field name="max_weight">800.0</field>
      <field name="barcode">PKG-PALLET-EUR</field>
    </record>

  </data>
</odoo>
Python — Pallet calculation helper
def compute_pallet_breakdown(product, total_qty):
    """Given a total quantity in base UoM, compute the pallet breakdown.

    Returns dict: {
        'pallets': int,
        'loose_cartons': int,
        'loose_inners': int,
        'loose_eaches': int,
    }
    """
    packagings = product.packaging_ids.sorted(
        key=lambda p: p.qty, reverse=True
    )
    result = {}
    remaining = total_qty

    tier_map = {
        'pallet': 'pallets',
        'outer': 'loose_cartons',
        'inner': 'loose_inners',
        'each': 'loose_eaches',
    }

    for pkg in packagings:
        tier_key = tier_map.get(pkg.packaging_tier, pkg.name)
        if pkg.qty > 0:
            count = int(remaining // pkg.qty)
            remaining -= count * pkg.qty
            result[tier_key] = count

    if remaining > 0:
        result['loose_eaches'] = result.get('loose_eaches', 0) + int(remaining)

    return result


# Example usage:
# product has: Pallet=1152, Outer=24, Inner=6, Each=1
# compute_pallet_breakdown(product, 2500)
# → {'pallets': 2, 'loose_cartons': 8, 'loose_inners': 0, 'loose_eaches': 4}
Pallet Layer Math

A EUR pallet (1200 x 800 mm) fits different carton configurations depending on orientation. For a 400 x 310 mm carton, you get 6 cartons per layer (2 across width, 3 across length). With 8 layers, that is 48 cartons per pallet = 1,152 units. Always verify the physical layout before entering the number in Odoo — a wrong layer count cascades into wrong purchase orders, wrong freight costs, and wrong warehouse slot sizing.

04

GS1 Barcodes for Packaging: GTIN-14, SSCC, and AI Parsing

Each packaging level should have its own barcode. The GS1 standard defines a hierarchy: GTIN-12/13 for consumer units (eaches), GTIN-14 for trade items (inner packs, cartons), and SSCC (Serial Shipping Container Code) for logistics units (pallets). Odoo 19's barcode module recognizes GS1-128 barcodes and can parse Application Identifiers (AIs) out of the box.

Python — models/packaging_barcode.py
import re
from odoo import models, fields, api
from odoo.exceptions import ValidationError


class PackagingBarcode(models.Model):
    _inherit = 'product.packaging'

    gtin_14 = fields.Char(
        string='GTIN-14',
        size=14,
        index=True,
    )
    sscc = fields.Char(
        string='SSCC',
        size=18,
        help='Serial Shipping Container Code for pallet-level tracking.',
    )

    @api.constrains('gtin_14')
    def _validate_gtin14(self):
        for rec in self:
            if not rec.gtin_14:
                continue
            if not re.match(r'^\d{14}$', rec.gtin_14):
                raise ValidationError(
                    "GTIN-14 must be exactly 14 digits."
                )
            if not self._check_digit_valid(rec.gtin_14):
                raise ValidationError(
                    f"GTIN-14 '{{rec.gtin_14}}' has an invalid check digit."
                )

    @staticmethod
    def _check_digit_valid(gtin):
        """Validate GS1 check digit (mod-10 algorithm)."""
        digits = [int(d) for d in gtin]
        total = sum(
            d * (3 if i % 2 else 1)
            for i, d in enumerate(digits[:-1])
        )
        expected = (10 - (total % 10)) % 10
        return digits[-1] == expected

    @api.constrains('sscc')
    def _validate_sscc(self):
        for rec in self:
            if rec.sscc and not re.match(r'^\d{18}$', rec.sscc):
                raise ValidationError(
                    "SSCC must be exactly 18 digits."
                )

GS1 Application Identifier Mapping

AIDescriptionPackaging LevelExample
01GTIN (Global Trade Item Number)Inner Pack / Outer Carton(01)04612345000012
02GTIN of contained itemsOuter Carton / Pallet(02)04612345000005
37Count of contained itemsAny multi-unit(37)000024
00SSCCPallet / Logistics Unit(00)340123450000000018
10Batch / Lot NumberAll levels(10)BATCH2026Q1
Python — Scanning a GS1-128 barcode in picking
def on_barcode_scanned(self, barcode):
    """Override barcode handler to resolve packaging-level scans.

    When a warehouse operator scans a GTIN-14 on an outer carton,
    Odoo should add the full carton quantity, not a single unit.
    """
    # Try to match a packaging barcode first
    packaging = self.env['product.packaging'].search([
        '|',
        ('barcode', '=', barcode),
        ('gtin_14', '=', barcode),
    ], limit=1)

    if packaging:
        # Add the full packaging quantity
        product = packaging.product_id
        qty = packaging.qty
        self._add_product(product, qty, packaging=packaging)
        return

    # Fall through to standard product barcode lookup
    return super().on_barcode_scanned(barcode)
Barcode Scanner Configuration

Most Zebra and Honeywell scanners need to be configured to transmit GS1-128 symbology with the FNC1 prefix. Without FNC1, the AI separators are ambiguous and Odoo's parser cannot distinguish (01)04612345000012(37)000024 from a single long number. Check your scanner's programming guide for "GS1-128 enable" or "EAN-128 mode."

05

Feeding Packaging Dimensions into Carrier Rate Calculations

Carriers charge by dimensional weight (DIM weight), not actual weight, when the package is large but light. The formula is: DIM weight = (L x W x H) / divisor. UPS uses 5,000, FedEx uses 5,000, and DHL uses 5,000 (all in cm/kg). If your packaging dimensions are wrong or missing, the carrier API returns an incorrect rate and your shipping margin evaporates.

Python — models/delivery_carrier_packaging.py
from odoo import models, api
from odoo.tools import float_round


class DeliveryCarrier(models.Model):
    _inherit = 'delivery.carrier'

    def _get_packages_from_picking(self, picking):
        """Build package list with accurate dimensions for rate requests.

        Override to use packaging-level dimensions instead of
        the picking's default single-package assumption.
        """
        packages = []

        for move_line in picking.move_line_ids.filtered('qty_done'):
            product = move_line.product_id
            qty = move_line.qty_done

            # Find the shipping-default packaging
            shipping_pkg = product.packaging_ids.filtered(
                'is_shipping_default'
            )[:1]

            if not shipping_pkg or not shipping_pkg.package_type_id:
                # Fallback: single package with product weight
                packages.append({
                    'weight': product.weight * qty,
                    'length': 0,
                    'width': 0,
                    'height': 0,
                })
                continue

            pkg_type = shipping_pkg.package_type_id
            num_packages = int(qty // shipping_pkg.qty)
            remainder = qty % shipping_pkg.qty

            # Full packages
            for _ in range(num_packages):
                actual_weight = product.weight * shipping_pkg.qty
                dim_weight = float_round(
                    (pkg_type.packaging_length
                     * pkg_type.width
                     * pkg_type.height) / 5000.0,
                    precision_digits=2,
                )
                packages.append({
                    'weight': max(actual_weight, dim_weight),
                    'length': pkg_type.packaging_length,
                    'width': pkg_type.width,
                    'height': pkg_type.height,
                })

            # Remainder (partial package)
            if remainder > 0:
                packages.append({
                    'weight': product.weight * remainder,
                    'length': pkg_type.packaging_length,
                    'width': pkg_type.width,
                    'height': pkg_type.height,
                })

        return packages

Carrier Dimension Requirements

CarrierDIM Divisor (cm/kg)Max Package WeightRequired Fields
UPS5,00070 kgL, W, H, Weight
FedEx5,00068 kgL, W, H, Weight
DHL Express5,000300 kgL, W, H, Weight
Canada Post6,00030 kgL, W, H, Weight
XML — Shipping packaging selection on delivery order
<?xml version="1.0" encoding="utf-8"?>
<odoo>
  <record id="stock_picking_form_shipping_pkg" model="ir.ui.view">
    <field name="name">stock.picking.form.shipping.pkg</field>
    <field name="model">stock.picking</field>
    <field name="inherit_id" ref="stock.view_picking_form"/>
    <field name="arch" type="xml">
      <xpath expr="//field[@name='carrier_id']" position="after">
        <field name="shipping_package_type_id"
               string="Package Type Override"
               options="{{'no_create': True}}"/>
      </xpath>
    </field>
  </record>
</odoo>
Test Rates Before Go-Live

Carrier API sandbox environments often return dummy rates regardless of dimensions. Always run a production rate check (not a shipment, just a rate quote) with real dimensions before go-live. We have seen customers lose 15-20% shipping margin for months because their package types had dimensions in millimeters while the carrier API expected centimeters.

06

5 Packaging Mistakes That Break Warehouse Operations

1

Packaging Quantity Does Not Match the Real Inner-Pack Count

This is the most common error. Someone enters qty=12 for an inner pack that actually holds 6. Every pick, every PO, and every inventory count is off by 2x. The mistake compounds silently until a physical audit reveals a 50% variance.

Our Fix

Add a _check_hierarchy_divisibility constraint (shown in step 03) and a mandatory sign-off workflow for packaging changes. Require the warehouse manager to physically count one package before the record is approved.

2

Missing Package Type Dimensions Kill Shipping Rates

When stock.package.type has zero dimensions, the carrier API receives a 0x0x0 package. Some carriers return a minimum rate (hiding the error), others return an error that Odoo silently catches, falling back to a flat rate. Either way, your quoted shipping cost is wrong.

Our Fix

Add a SQL constraint on stock.package.type: CHECK(height > 0 AND width > 0 AND packaging_length > 0) for any package type linked to a carrier.

3

Barcode Collisions Between Packaging Levels

If you reuse the product's EAN-13 as the inner pack barcode, Odoo does not know whether the scan means "1 unit" or "6 units." The barcode lookup returns the first match, which may be the product or the packaging depending on search order. This creates random quantity errors that are nearly impossible to trace.

Our Fix

Every packaging level gets its own unique barcode. Use GTIN-14 for trade units (prepend a packaging indicator digit to the GTIN-13). Add a unique SQL constraint on the barcode field of product.packaging.

4

UoM Rounding Causes Phantom Stock Adjustments

Converting 1 pallet (1,152 units) to kg with a 0.33 kg/unit factor yields 380.16 kg. Converting back yields 1,152.0000... units — but floating-point math might give 1,151.9999. Odoo rounds this to 1,151, creating a phantom -1 adjustment in your stock. Over hundreds of receipts, the variance becomes significant.

Our Fix

Set the UoM rounding precision to match your smallest packaging unit. If your smallest tier is "each" (integer), set rounding to 1.0. For weight-based products, use 0.001 (grams precision). Test round-trip conversions before go-live with actual order quantities.

5

Packaging Not Linked to Purchase/Sales Routes

Defining packaging without linking it to purchase order lines or sales order lines means the packaging exists in a vacuum. Your purchasing team still types quantities manually, and sales orders default to eaches even when the customer ordered a full pallet.

Our Fix

Enable "Purchase/Sale in packaging quantities" in the Purchase and Sales settings. This adds a packaging field on PO and SO lines that auto-calculates the base UoM quantity. When a buyer selects "Pallet" and enters "2," the quantity field auto-fills with 2,304 units.

BUSINESS ROI

What Proper Packaging Configuration Saves Your Warehouse

Packaging configuration is a one-time setup effort that pays dividends on every order, every pick, and every shipment:

40%Fewer Pick Errors

Scanning a carton barcode adds the exact quantity. No manual counting, no fat-finger mistakes on quantities like 1,152.

15-20%Shipping Cost Savings

Accurate dimensions mean accurate DIM weight. No more overpaying because the carrier defaulted to maximum package size.

3xFaster Receiving

Scan the pallet SSCC barcode once to receive 1,152 units. Compare to scanning 48 cartons or counting 1,152 eaches.

For a warehouse processing 500 orders per day with an average of 3 line items, packaging-aware picking eliminates roughly 1,500 manual quantity entries daily. At 5 seconds per entry, that is over 2 hours of labor saved per shift. Over a year, this adds up to over $25,000 in warehouse labor costs for a single-shift operation.

SEO NOTES

Optimization Metadata

Meta Desc

Complete guide to Odoo 19 packaging management. Configure inner packs, outer cartons, and pallets with UoM conversions, GS1 barcodes, and carrier-ready dimensions.

H2 Keywords

1. "Unit of Measure Conversions: Linking Packaging Quantities to UoM"
2. "Configuring the Inner Pack / Outer Carton / Pallet Hierarchy"
3. "GS1 Barcodes for Packaging: GTIN-14, SSCC, and AI Parsing"
4. "5 Packaging Mistakes That Break Warehouse Operations"

Stop Counting Eaches — Let Packaging Do the Math

Every warehouse operation that involves a human counting individual items is an error waiting to happen. Packaging configuration in Odoo 19 eliminates this by teaching the system the same container hierarchy your physical warehouse already uses: eaches go into inner packs, inner packs go into cartons, cartons go onto pallets. Once configured, a single barcode scan moves the right quantity, purchase orders auto-calculate pallet counts, and shipping labels carry the correct weight.

The setup takes a day. The payoff lasts for every order you process. If your packaging structure is complex — variable pack sizes, mixed pallets, multi-product cartons — we can audit your product catalog and design the optimal packaging hierarchy for your Odoo 19 instance.

Book a Free Packaging Audit