How to Build Custom Odoo Modules: A Developer's Guide to OWL, ORM & Inheritance

Developer guide to building custom Odoo modules. Covers module structure, OWL framework, ORM inheritance, views, security rules, testing, and OCA guidelines.

E

ECOSIRE Research and Development Team

ECOSIRE Team

February 19, 20266 min read1.3k words

How to Build Custom Odoo Modules: A Developer's Guide to OWL, ORM & Inheritance

When Odoo's 82 official modules and 40,000+ community modules do not cover your exact business requirements, custom module development fills the gap. This guide covers the fundamentals of building Odoo modules in 2026, including module structure, the OWL frontend framework, ORM patterns, inheritance mechanisms, and best practices aligned with OCA (Odoo Community Association) guidelines.

What Is a Custom Odoo Module?

A custom Odoo module is a self-contained package of Python backend logic, XML view definitions, JavaScript frontend components, and security rules that extends Odoo's functionality. Modules can add entirely new features, modify existing behavior, or integrate Odoo with external systems. Every piece of Odoo itself is a module, making the architecture inherently extensible.

Module Directory Structure

A well-organized module follows this standard structure:

my_custom_module/
├── __init__.py              # Python package init
├── __manifest__.py          # Module metadata and dependencies
├── models/
│   ├── __init__.py
│   └── my_model.py          # Business logic and data models
├── views/
│   ├── my_model_views.xml   # Form, tree, and kanban views
│   └── menu.xml             # Menu items and actions
├── security/
│   ├── ir.model.access.csv  # Access control list
│   └── security.xml         # Record rules and groups
├── data/
│   └── data.xml             # Default data records
├── static/
│   └── src/
│       ├── js/              # OWL components
│       ├── css/             # Stylesheets
│       └── xml/             # QWeb templates
├── wizard/                  # Transient models for wizards
├── reports/                 # QWeb report templates
└── tests/                   # Unit tests

The Manifest File

The __manifest__.py file defines your module's identity:

{
    'name': 'My Custom Module',
    'version': '18.0.1.0.0',
    'category': 'Custom',
    'summary': 'Short description of what the module does',
    'description': """
        Long description with details about
        features and configuration.
    """,
    'author': 'ECOSIRE',
    'website': 'https://ecosire.com',
    'license': 'LGPL-3',
    'depends': ['base', 'sale', 'stock'],
    'data': [
        'security/ir.model.access.csv',
        'views/my_model_views.xml',
        'views/menu.xml',
        'data/data.xml',
    ],
    'assets': {
        'web.assets_backend': [
            'my_custom_module/static/src/js/**/*',
            'my_custom_module/static/src/css/**/*',
            'my_custom_module/static/src/xml/**/*',
        ],
    },
    'installable': True,
    'application': False,
    'auto_install': False,
}

Version convention: {odoo_version}.{major}.{minor}.{patch} (e.g., 18.0.1.0.0).

Working with the ORM

Odoo's Object-Relational Mapping (ORM) is the foundation of all backend development. Models map to database tables, and the ORM provides CRUD operations, computed fields, constraints, and workflow management.

Defining a Model

from odoo import models, fields, api

class ProjectTask(models.Model):
    _name = 'my_module.task'
    _description = 'Project Task'
    _order = 'priority desc, create_date desc'

    name = fields.Char(string='Task Name', required=True)
    description = fields.Html(string='Description')
    state = fields.Selection([
        ('draft', 'Draft'),
        ('in_progress', 'In Progress'),
        ('done', 'Done'),
        ('cancelled', 'Cancelled'),
    ], default='draft', tracking=True)
    assigned_to = fields.Many2one('res.users', string='Assigned To')
    deadline = fields.Date(string='Deadline')
    priority = fields.Selection([
        ('0', 'Normal'),
        ('1', 'Important'),
        ('2', 'Urgent'),
    ], default='0')
    tag_ids = fields.Many2many('my_module.tag', string='Tags')
    progress = fields.Float(compute='_compute_progress', store=True)

Field Types Reference

| Field Type | Python Type | Use Case | |---|---|---| | Char | str | Short text (name, reference) | | Text | str | Long plain text | | Html | str | Rich text content | | Integer | int | Whole numbers | | Float | float | Decimal numbers | | Boolean | bool | True/False flags | | Date | date | Date without time | | Datetime | datetime | Date with time | | Selection | str | Dropdown choices | | Many2one | int | Link to one record | | One2many | list | Reverse of Many2one | | Many2many | list | Link to multiple records | | Binary | bytes | File attachments |

Computed Fields and Constraints

@api.depends('subtask_ids.state')
def _compute_progress(self):
    for task in self:
        total = len(task.subtask_ids)
        done = len(task.subtask_ids.filtered(
            lambda t: t.state == 'done'
        ))
        task.progress = (done / total * 100) if total else 0

@api.constrains('deadline')
def _check_deadline(self):
    for task in self:
        if task.deadline and task.deadline < fields.Date.today():
            raise ValidationError(
                "Deadline cannot be in the past."
            )

Inheritance Mechanisms

Odoo provides three types of inheritance, each serving a different purpose:

1. Class Inheritance (Extension)

Extend an existing model by adding fields or overriding methods. This is the most common pattern.

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

    custom_reference = fields.Char(string='Custom Ref')
    approved_by = fields.Many2one('res.users')

    def action_confirm(self):
        # Add custom logic before standard confirmation
        for order in self:
            if order.amount_total > 10000 and not order.approved_by:
                raise UserError("Orders over $10,000 require approval.")
        return super().action_confirm()

2. Prototype Inheritance

Create a new model that copies all fields and methods from an existing model.

class CustomPartner(models.Model):
    _name = 'my_module.partner'
    _inherit = 'res.partner'   # Copies structure
    _description = 'Custom Partner'

3. Delegation Inheritance

Create a new model that delegates to an existing model via a Many2one link. The parent model's fields appear on the child model transparently.

class LibraryMember(models.Model):
    _name = 'library.member'
    _inherits = {'res.partner': 'partner_id'}

    partner_id = fields.Many2one('res.partner', required=True,
                                  ondelete='cascade')
    membership_date = fields.Date()
    member_number = fields.Char()

The OWL Framework (Frontend)

Odoo 18 uses OWL (Odoo Web Library) as its frontend framework. OWL is a component-based framework similar to React or Vue but designed specifically for Odoo's needs.

Basic OWL Component

/** @odoo-module */

import { Component, useState } from "@odoo/owl";
import { registry } from "@web/core/registry";

class TaskDashboard extends Component {
    static template = "my_module.TaskDashboard";

    setup() {
        this.state = useState({
            tasks: [],
            filter: 'all',
        });
        this.loadTasks();
    }

    async loadTasks() {
        this.state.tasks = await this.env.services.orm.searchRead(
            "my_module.task",
            [["state", "!=", "cancelled"]],
            ["name", "state", "assigned_to", "deadline"]
        );
    }

    get filteredTasks() {
        if (this.state.filter === 'all') return this.state.tasks;
        return this.state.tasks.filter(
            t => t.state === this.state.filter
        );
    }
}

QWeb Template (XML)

<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
    <t t-name="my_module.TaskDashboard">
        <div class="o_task_dashboard">
            <div class="task-filters">
                <button t-on-click="() => state.filter = 'all'">All</button>
                <button t-on-click="() => state.filter = 'in_progress'">
                    In Progress
                </button>
            </div>
            <div class="task-list">
                <t t-foreach="filteredTasks" t-as="task" t-key="task.id">
                    <div class="task-card">
                        <span t-esc="task.name"/>
                    </div>
                </t>
            </div>
        </div>
    </t>
</templates>

Security Configuration

Every model needs explicit access rules. Without them, no user can access the model's data.

Access Control List (ir.model.access.csv)

id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_task_user,task.user,model_my_module_task,base.group_user,1,1,1,0
access_task_manager,task.manager,model_my_module_task,my_module.group_manager,1,1,1,1

Record Rules (security.xml)

<record id="task_own_rule" model="ir.rule">
    <field name="name">Own Tasks Only</field>
    <field name="model_id" ref="model_my_module_task"/>
    <field name="domain_force">
        [('assigned_to', '=', user.id)]
    </field>
    <field name="groups" eval="[(4, ref('base.group_user'))]"/>
</record>

Testing Your Module

Odoo supports Python unit tests using the unittest framework with Odoo-specific test classes:

from odoo.tests.common import TransactionCase

class TestTask(TransactionCase):
    def setUp(self):
        super().setUp()
        self.task = self.env['my_module.task'].create({
            'name': 'Test Task',
            'state': 'draft',
        })

    def test_task_creation(self):
        self.assertEqual(self.task.state, 'draft')
        self.assertEqual(self.task.progress, 0)

    def test_deadline_constraint(self):
        with self.assertRaises(ValidationError):
            self.task.write({
                'deadline': fields.Date.subtract(
                    fields.Date.today(), days=1
                ),
            })

Run tests with: odoo-bin -d test_db --test-enable -i my_custom_module --stop-after-init

Frequently Asked Questions

Q: How long does it take to build a custom Odoo module? Simple modules (new fields, basic views) take 1-3 days. Moderate modules (new models, workflows, reports) take 1-3 weeks. Complex modules (multi-model systems, external integrations, custom OWL components) take 4-12 weeks. For businesses that need custom modules but lack in-house Odoo developers, hire an experienced Odoo developer from our team.

Q: Should I modify core Odoo code or create a separate module? Always create a separate module. Modifying core code breaks upgradability and creates merge conflicts during version updates. Use inheritance to extend existing models and views from your custom module.

Q: What are OCA guidelines? The Odoo Community Association (OCA) publishes coding standards for module quality, including naming conventions, documentation requirements, test coverage expectations, and code style rules. Following OCA guidelines ensures your module is maintainable and compatible with the broader community ecosystem.

Getting Professional Help

Building custom Odoo modules requires expertise in Python, JavaScript, PostgreSQL, and Odoo's framework conventions. Whether you need a simple field extension or a complex multi-module system, ECOSIRE's Odoo customization services deliver production-ready modules built to OCA standards.

Contact us to discuss your custom module requirements and get a development estimate.

E

Written by

ECOSIRE Research and Development Team

Building enterprise-grade digital products at ECOSIRE. Sharing insights on Odoo integrations, e-commerce automation, and AI-powered business solutions.

Chat on WhatsApp