Odoo
The best entry point into ERP development for a self-taught developer, because you can read the entire source code for free.
What it is
- Open-source core (LGPLv3) plus a paid Enterprise edition
- Python + PostgreSQL, with its own ORM
- Modular: ~40 official apps and thousands of community modules
- Belgian company, large global partner network, strong in SMB and mid-market
- Annual major release (Odoo 16, 17, 18, 19…)
Community vs Enterprise
| Community (free) | Enterprise (paid) | |
|---|---|---|
| Licence | LGPLv3 | Proprietary |
| Core apps | Sales, Purchase, Inventory, Manufacturing, CRM, Accounting (basic), Website | All of Community plus full Accounting, Studio, Documents, Sign, Planning, Field Service, Quality, PLM, Barcode, IoT |
| Support | Community | Odoo SA |
| Mobile app | No | Yes |
The Accounting difference matters: Community has invoicing but not the full localised accounting engine (bank reconciliation automation, asset management, localisation packages, follow-up). Many “Community is enough” plans discover this at implementation.
The architecture
┌───────────────────────────────────┐
│ Web client (OWL — JS framework) │
├───────────────────────────────────┤
│ Controllers (HTTP routes) │
├───────────────────────────────────┤
│ Models (Python, the ORM layer) │
├───────────────────────────────────┤
│ PostgreSQL │
└───────────────────────────────────┘
Everything is a model, and models are registry entries — which is how inheritance across modules works.
The ORM
from odoo import models, fields, api
from odoo.exceptions import ValidationError
class SaleOrder(models.Model):
_inherit = 'sale.order' # extend the existing model
credit_risk_score = fields.Integer(
string='Credit Risk Score',
compute='_compute_credit_risk',
store=True,
)
requires_review = fields.Boolean(string='Requires Credit Review')
@api.depends('partner_id', 'amount_total')
def _compute_credit_risk(self):
for order in self:
overdue = self.env['account.move.line'].search_count([
('partner_id', '=', order.partner_id.id),
('date_maturity', '<', fields.Date.today()),
('parent_state', '=', 'posted'),
('reconciled', '=', False),
])
order.credit_risk_score = min(100, overdue * 10)
@api.constrains('amount_total')
def _check_credit_limit(self):
for order in self:
if order.amount_total > order.partner_id.credit_limit:
raise ValidationError(
f"Order total {order.amount_total} exceeds the credit limit "
f"for {order.partner_id.name}."
)
def action_confirm(self):
res = super().action_confirm() # call the original
self.filtered(lambda o: o.credit_risk_score > 70).write({
'requires_review': True
})
return res
Field types
Char, Text, Html, Integer, Float, Monetary, Boolean, Date, Datetime, Binary, Selection, Many2one, One2many, Many2many, Reference, Json.
Field attributes worth knowing
compute=+@api.depends— computed fields, cached and invalidated automaticallystore=True— persist a computed field so you can search and group by itrelated=— a shortcut through a relationinverse=— makes a computed field writabletracking=True— records changes in the chatter (the message log on the record)company_dependent=True— a different value per company, stored as a property
Inheritance — the three types
| Type | Syntax | Effect |
|---|---|---|
| Classical extension | _inherit = 'sale.order' |
Adds fields/methods to the existing model. The one you use 95% of the time |
| Prototype | _inherit = 'x' + _name = 'y' |
Copies into a new model |
| Delegation | _inherits = {'product.template': 'product_tmpl_id'} |
Composition — the child exposes the parent’s fields |
Module structure
my_module/
├── __init__.py
├── __manifest__.py ← name, version, depends, data files, licence
├── models/
│ ├── __init__.py
│ └── sale_order.py
├── views/
│ └── sale_order_views.xml ← forms, lists, search, menus, actions
├── security/
│ ├── ir.model.access.csv ← model-level CRUD per group
│ └── security.xml ← groups and record rules
├── data/
│ └── data.xml ← seed/master data
├── wizard/ ← TransientModel dialogs
├── report/ ← QWeb PDF templates
├── static/
│ ├── src/js/ ← OWL components
│ └── description/ ← app store icon and description
└── tests/
└── test_sale_order.py
__manifest__.py:
{
'name': 'Credit Risk Extension',
'version': '18.0.1.0.0',
'category': 'Sales',
'depends': ['sale_management', 'account'],
'data': [
'security/ir.model.access.csv',
'views/sale_order_views.xml',
],
'license': 'LGPL-3',
'installable': True,
}
Views (XML)
<record id="view_order_form_credit" model="ir.ui.view">
<field name="name">sale.order.form.credit</field>
<field name="model">sale.order</field>
<field name="inherit_id" ref="sale.view_order_form"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='partner_id']" position="after">
<field name="credit_risk_score"/>
<field name="requires_review" invisible="credit_risk_score <= 70"/>
</xpath>
</field>
</record>
XPath-based view inheritance is how you modify screens without touching the original XML. Positions: after, before, inside, replace, attributes.
Security
# ir.model.access.csv — model-level permissions
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_credit_user,credit.user,model_credit_review,sales_team.group_sale_salesman,1,0,0,0
access_credit_mgr,credit.manager,model_credit_review,sales_team.group_sale_manager,1,1,1,1
Record rules add row-level filtering:
<record id="rule_own_orders" model="ir.rule">
<field name="name">Salesperson sees own orders</field>
<field name="model_id" ref="sale.model_sale_order"/>
<field name="domain_force">[('user_id','=',user.id)]</field>
<field name="groups" eval="[(4, ref('sales_team.group_sale_salesman'))]"/>
</record>
Multi-company record rules are built in — every model with a company_id gets automatic isolation.
Reports — QWeb
<template id="report_credit_summary">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="doc">
<t t-call="web.external_layout">
<div class="page">
<h2>Credit Review — <span t-field="doc.partner_id.name"/></h2>
<table class="table">
<tr t-foreach="doc.order_line" t-as="line">
<td><span t-field="line.product_id.name"/></td>
<td><span t-field="line.price_subtotal"
t-options='{"widget": "monetary",
"display_currency": doc.currency_id}'/></td>
</tr>
</table>
</div>
</t>
</t>
</t>
</template>
QWeb → HTML → PDF via wkhtmltopdf. Templates are inheritable with the same XPath mechanism as views.
Integration
# XML-RPC — the classic external API
import xmlrpc.client
common = xmlrpc.client.ServerProxy(f'{url}/xmlrpc/2/common')
uid = common.authenticate(db, username, password, {})
models = xmlrpc.client.ServerProxy(f'{url}/xmlrpc/2/object')
orders = models.execute_kw(db, uid, password,
'sale.order', 'search_read',
[[['state', '=', 'sale'], ['date_order', '>=', '2026-01-01']]],
{'fields': ['name', 'partner_id', 'amount_total'], 'limit': 100})
Also: JSON-RPC, custom HTTP controllers for REST endpoints, and community REST API modules.
Development setup
git clone https://github.com/odoo/odoo --depth 1 --branch 18.0
python3 -m venv venv && source venv/bin/activate
pip install -r odoo/requirements.txt
createdb odoo_dev
./odoo/odoo-bin -d odoo_dev --addons-path=odoo/addons,my_addons \
-i base --dev=all
--dev=all gives you auto-reload on Python changes and XML reload on view changes. The developer loop is fast.
Testing
from odoo.tests import TransactionCase, tagged
@tagged('post_install', '-at_install')
class TestCreditRisk(TransactionCase):
def setUp(self):
super().setUp()
self.partner = self.env['res.partner'].create({
'name': 'Test Customer', 'credit_limit': 10000
})
def test_credit_limit_blocks_large_order(self):
with self.assertRaises(ValidationError):
self.env['sale.order'].create({
'partner_id': self.partner.id,
'order_line': [(0, 0, {
'product_id': self.env.ref('product.product_product_1').id,
'product_uom_qty': 1000, 'price_unit': 50,
})],
})
Tests run in a rolled-back transaction, so they’re isolated and fast.
Learning path
1. Install locally, use the apps as a business user (2 weeks)
2. Python + PostgreSQL fundamentals (if needed)
3. ORM: models, fields, decorators, domains (1 month)
4. Views, inheritance, XPath (2 weeks)
5. Security: groups, ACLs, record rules (1 week)
6. Reports (QWeb), wizards, scheduled actions (2 weeks)
7. READ THE SOURCE of sale, purchase, stock, account (ongoing — the real education)
8. OWL for front-end work (1 month)
2–4 months to productive. And uniquely, you can learn entirely for free with no licence, no VM, no partner agreement.
The OCA
The Odoo Community Association maintains thousands of high-quality free modules on GitHub. Reading OCA code is the best way to learn idiomatic Odoo, and contributing is a genuine portfolio credential.
Reality check
Pros: free to learn, readable source, Python, fast development, huge community, strong SMB/mid-market demand globally including South Asia, and low barrier to freelancing.
Cons: lower rates than SAP or Oracle, annual version upgrades that break custom modules, Enterprise/Community feature confusion, and localisation quality that varies a lot by country.
The upgrade treadmill is the main operational pain. Custom modules need work at every annual release. Write clean, minimal extensions and keep them small.