Skip to content

Odoo Multi-Company Coding Patterns: Complete Developer Guide

DeployMonkey Team · March 23, 2026 12 min read

How Multi-Company Works in Odoo

Odoo supports multiple companies in a single database. Each user has an allowed_company_ids set and a current company. Models can be company-specific (one record per company) or shared (one record accessible to all companies). Understanding this distinction is critical for correct multi-company code.

Company-Specific vs Shared Models

TypeHas company_id?Example
Company-specificYes (required)sale.order, account.move, stock.picking
SharedNoproduct.template, res.partner, res.users
Company-dependent fieldsShared model, per-company valuesproduct.template.list_price

Adding company_id to Your Model

class CustomModel(models.Model):
    _name = 'custom.model'
    _description = 'Company-Specific Model'

    company_id = fields.Many2one(
        'res.company',
        string='Company',
        required=True,
        default=lambda self: self.env.company,
        index=True,
    )

The default=lambda self: self.env.company sets the current company when creating records.

Multi-Company Record Rules

Add a record rule to restrict access by company:

<record id="custom_model_company_rule" model="ir.rule">
    <field name="name">Custom Model: multi-company</field>
    <field name="model_id" ref="model_custom_model"/>
    <field name="domain_force">
        ['|', ('company_id', '=', False), ('company_id', 'in', company_ids)]
    </field>
</record>

The standard domain pattern ['|', ('company_id', '=', False), ('company_id', 'in', company_ids)] allows records with no company (shared) and records matching the user's allowed companies.

Company-Dependent Fields (Properties)

Some fields need different values per company on a shared model. Odoo calls these "company-dependent" fields:

class ProductTemplate(models.Model):
    _inherit = 'product.template'

    # Different price per company
    list_price = fields.Float(company_dependent=True)

    # Different supplier per company
    preferred_supplier_id = fields.Many2one(
        'res.partner', company_dependent=True
    )

Company-dependent fields store values in ir.property records, one per company. When a user reads the field, they see the value for their current company.

self.env.company vs self.env.companies

# Current company (single record)
current = self.env.company  # res.company record
current_id = self.env.company.id

# All allowed companies (recordset)
all_companies = self.env.companies  # res.company recordset
all_ids = self.env.companies.ids

Use self.env.company for defaults and current-company logic. Use self.env.companies when you need to query across all allowed companies.

with_company()

Switch the current company context for a specific operation:

# Read product price for a specific company
product = product.with_company(other_company)
other_price = product.list_price  # reads the other company's price

# Create record for a specific company
new_record = self.env['custom.model'].with_company(target_company).create({
    'name': 'Test',
    # company_id defaults to target_company
})

Common Multi-Company Bugs

Bug 1: Forgetting company_id Default

# BUG: no default company — can create records without company_id
company_id = fields.Many2one('res.company')

# FIX: always set default
company_id = fields.Many2one('res.company', default=lambda self: self.env.company)

Bug 2: Cross-Company References

# BUG: journal from Company A used in Company B
invoice.write({'journal_id': some_journal.id})
# The journal might belong to a different company!

# FIX: always filter by company
journal = self.env['account.journal'].search([
    ('company_id', '=', self.env.company.id),
    ('type', '=', 'sale'),
], limit=1)

Bug 3: sudo() Bypasses Company Rules

# BUG: sudo() ignores record rules including company rules
all_orders = self.env['sale.order'].sudo().search([])
# Returns ALL orders from ALL companies!

# FIX: always add company domain with sudo
all_orders = self.env['sale.order'].sudo().search([
    ('company_id', 'in', self.env.companies.ids)
])

Bug 4: Cron Jobs and Company Context

# BUG: cron runs as admin — which company?
def _cron_process(self):
    records = self.search([])  # which company's records?

# FIX: iterate over companies
def _cron_process(self):
    for company in self.env['res.company'].search([]):
        records = self.with_company(company).search([
            ('company_id', '=', company.id)
        ])
        for record in records:
            record.with_company(company)._process()

Inter-Company Transactions

When companies need to transact with each other:

class SaleOrder(models.Model):
    _inherit = 'sale.order'

    def action_confirm(self):
        res = super().action_confirm()
        # Create purchase order in the other company
        if self.partner_id.is_company and self.partner_id.ref_company_ids:
            other_company = self.partner_id.ref_company_ids[0]
            self.env['purchase.order'].with_company(other_company).sudo().create({
                'partner_id': self.company_id.partner_id.id,
                'company_id': other_company.id,
                'order_line': [(0, 0, {
                    'product_id': line.product_id.id,
                    'product_qty': line.product_uom_qty,
                    'price_unit': line.price_unit,
                }) for line in self.order_line],
            })
        return res

Testing Multi-Company

class TestMultiCompany(TransactionCase):

    def setUp(self):
        super().setUp()
        self.company_a = self.env.company
        self.company_b = self.env['res.company'].create({'name': 'Company B'})
        self.user = self.env['res.users'].create({
            'name': 'Test User',
            'login': 'test_mc',
            'company_id': self.company_a.id,
            'company_ids': [(6, 0, [self.company_a.id, self.company_b.id])],
        })

    def test_company_isolation(self):
        record_a = self.env['custom.model'].with_company(self.company_a).create({'name': 'A'})
        record_b = self.env['custom.model'].with_company(self.company_b).create({'name': 'B'})

        # User with company A should only see record A
        records = self.env['custom.model'].with_user(self.user).with_company(self.company_a).search([])
        self.assertIn(record_a, records)
        self.assertNotIn(record_b, records)

Best Practices

  • Always add company_id with a default to company-specific models
  • Always create a multi-company record rule using the standard domain pattern
  • Use with_company() when operating across companies
  • Filter by company_id when using sudo() — it bypasses record rules
  • Handle cron jobs by iterating over companies
  • Test with multiple companies and a non-admin user