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
| Type | Has company_id? | Example |
|---|---|---|
| Company-specific | Yes (required) | sale.order, account.move, stock.picking |
| Shared | No | product.template, res.partner, res.users |
| Company-dependent fields | Shared model, per-company values | product.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.idsUse 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 resTesting 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_idwith 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