Odoo's modular architecture is its greatest strength — and the thing that trips up most developers who come to it from Django or Rails. The ORM is powerful, the view framework is declarative, and the permission model is fine-grained. But the conventions are opinionated, the documentation has gaps, and a misstep in your module manifest can cause silent failures that waste hours.

This guide covers what we've learned building and maintaining custom Odoo modules across dozens of Community Edition deployments. We'll go from scaffolding a module skeleton through ORM best practices, view design, security records, and finally deploying cleanly into production.

Module Structure: Start With the Scaffold

Odoo ships with a scaffolding tool that generates the directory structure you need. Always start here — hand-rolling a module from scratch inevitably forgets something and leads to mysterious import errors at startup.

# From your Odoo addons directory
python odoo-bin scaffold my_project_module ./custom_addons/

This generates the core layout. The structure you'll actually use in a real module looks like this:

my_project_module/
├── __init__.py
├── __manifest__.py
├── models/
│   ├── __init__.py
│   └── project_task_custom.py
├── views/
│   └── project_task_custom_views.xml
├── security/
│   ├── ir.model.access.csv
│   └── security_groups.xml
├── data/
│   └── default_data.xml
└── static/
    └── description/
        └── icon.png

The __manifest__.py is the module's contract with Odoo. Get it wrong and your module won't load. The fields that matter most in practice:

{
    'name': 'My Project Module',
    'version': '17.0.1.0.0',
    'summary': 'Custom extensions for project management',
    'author': 'Laniakea Cloud Services',
    'website': 'https://laniakeaconsult.com',
    'category': 'Project',
    'depends': ['project', 'mail'],
    'data': [
        'security/security_groups.xml',
        'security/ir.model.access.csv',
        'views/project_task_custom_views.xml',
        'data/default_data.xml',
    ],
    'installable': True,
    'application': False,
    'auto_install': False,
    'license': 'LGPL-3',
}

Key rule: The order of entries in data matters — XML files are loaded sequentially. Always put security_groups.xml before ir.model.access.csv, and both before any views. Violating this order causes record-not-found errors that are easy to miss because Odoo may partially load before failing.

Version the module using Odoo's convention: 17.0.1.0.0 where the first two digits mirror the Odoo major version (17.0), and the last three are your own semantic version. This matters for upgrade scripts.

Models: Getting the ORM Right

Odoo models are Python classes that inherit from models.Model, models.TransientModel, or models.AbstractModel. Understanding which to use is the first decision:

A realistic model that extends Odoo's project.task with custom fields and computed logic:

from odoo import models, fields, api
from odoo.exceptions import ValidationError

class ProjectTaskCustom(models.Model):
    _inherit = 'project.task'

    # Simple fields
    client_ref = fields.Char(
        string='Client Reference',
        index=True,
        copy=False,
    )
    estimated_hours = fields.Float(
        string='Estimated Hours',
        digits=(6, 2),
    )
    priority_score = fields.Integer(
        string='Priority Score',
        default=0,
        help='Internal scoring: 0 (low) to 100 (critical)',
    )

    # Computed field
    is_overdue = fields.Boolean(
        string='Overdue',
        compute='_compute_is_overdue',
        store=True,  # Storing makes it filterable/searchable
    )

    @api.depends('date_deadline', 'state')
    def _compute_is_overdue(self):
        today = fields.Date.today()
        for task in self:
            task.is_overdue = (
                bool(task.date_deadline)
                and task.date_deadline < today
                and task.state not in ('done', 'cancelled')
            )

    @api.constrains('priority_score')
    def _check_priority_score(self):
        for task in self:
            if not (0 <= task.priority_score <= 100):
                raise ValidationError(
                    'Priority score must be between 0 and 100.'
                )

Computed Fields: store=True vs store=False

This decision has significant performance implications. store=False (the default) means the value is recomputed on every read — fast to write, but can cause N+1 queries in list views if you're not careful. store=True persists the value to the database, triggering recomputation only when dependencies change via @api.depends. Use store=True whenever the field will appear in list views, be used in domain filters, or computed from many dependencies.

Onchange vs Depends

A common mistake: using @api.onchange when you should use @api.depends. Onchange only fires in the UI during user interaction — it never runs during imports, API calls, or automated actions. Depends (with store=True) fires on write from any source. For business logic that must be correct regardless of how a record is created, always use @api.depends on a stored computed field.

Views: Extending Without Overriding

Odoo's view inheritance model is one of its best features. Use it correctly and your custom module can survive upstream updates. Break it by replacing entire views and you'll fight every upgrade.

<?xml version="1.0" encoding="utf-8"?>
<odoo>
  <!-- Extend the existing task form view -->
  <record id="view_task_form_custom" model="ir.ui.view">
    <field name="name">project.task.form.custom</field>
    <field name="model">project.task</field>
    <field name="inherit_id" ref="project.view_task_form2"/>
    <field name="arch" type="xml">
      <!-- Use xpath to precisely target the insertion point -->
      <xpath expr="//field[@name='description']" position="before">
        <group string="Client Info">
          <field name="client_ref"/>
          <field name="priority_score"/>
          <field name="estimated_hours"/>
        </group>
      </xpath>

      <!-- Add overdue indicator to the status bar area -->
      <xpath expr="//div[@class='oe_title']" position="after">
        <div attrs="{'invisible': [('is_overdue', '=', False)]}"
             style="color:#e74c3c;font-weight:600;margin-bottom:8px;">
          ⚠ This task is overdue
        </div>
      </xpath>
    </field>
  </record>

  <!-- Add client_ref to the list view -->
  <record id="view_task_list_custom" model="ir.ui.view">
    <field name="name">project.task.list.custom</field>
    <field name="model">project.task</field>
    <field name="inherit_id" ref="project.view_task_tree2"/>
    <field name="arch" type="xml">
      <xpath expr="//field[@name='user_ids']" position="after">
        <field name="client_ref" optional="show"/>
        <field name="is_overdue" optional="show"/>
      </xpath>
    </field>
  </record>
</odoo>

Prefer targeting fields by name attribute over targeting by position or CSS class. Field names are stable across Odoo versions; layout classes change. When you must target a container, use a string attribute as the anchor — it's more resilient than relying on div structure.

Security: The Part Nobody Reads Until Something Breaks

Odoo's security model has two layers: record rules (row-level security via domain filters) and access control lists (model-level CRUD permissions). Both need to be defined for every new model.

The ir.model.access.csv file controls who can read, write, create, and delete records from your model at a model level:

id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_task_custom_user,task.custom.user,model_project_task,project.group_project_user,1,1,1,0
access_task_custom_manager,task.custom.manager,model_project_task,project.group_project_manager,1,1,1,1

Critical: The model_id:id column follows a specific pattern — it's model_ + the model's _name with dots replaced by underscores. For a model named project.task, this becomes model_project_task. Getting this wrong causes a silent failure where the access rule is simply ignored.

For row-level security (e.g., users only see their own records), define record rules in an XML file:

<record id="rule_task_custom_own" model="ir.rule">
  <field name="name">Own Tasks Only</field>
  <field name="model_id" ref="project.model_project_task"/>
  <field name="domain_force">
    [('user_ids', 'in', [user.id])]
  </field>
  <field name="groups" eval="[(4, ref('project.group_project_user'))]"/>
</record>

Testing Your Module Before It Hits Production

Odoo has a built-in test runner that many developers skip in favor of manual testing. That's a mistake — automated tests are the only reliable way to catch regressions across Odoo point releases.

# tests/__init__.py
from . import test_project_task_custom

# tests/test_project_task_custom.py
from odoo.tests import TransactionCase

class TestProjectTaskCustom(TransactionCase):

    def setUp(self):
        super().setUp()
        self.project = self.env['project.project'].create({
            'name': 'Test Project',
        })

    def test_overdue_computed_field(self):
        """Overdue flag should be set for past-deadline open tasks."""
        task = self.env['project.task'].create({
            'name': 'Overdue Task',
            'project_id': self.project.id,
            'date_deadline': '2020-01-01',
        })
        self.assertTrue(task.is_overdue)

    def test_priority_score_validation(self):
        """Priority score outside 0-100 should raise ValidationError."""
        from odoo.exceptions import ValidationError
        with self.assertRaises(ValidationError):
            self.env['project.task'].create({
                'name': 'Bad Task',
                'project_id': self.project.id,
                'priority_score': 150,
            })

Run the tests with:

python odoo-bin -d mydb --test-enable --stop-after-init \
  -i my_project_module --log-level=test

Deployment: Installing and Upgrading Cleanly

In development, you install a new module via the Apps menu or the -i flag. In production, use the -u flag to upgrade an existing module — this re-runs the data files and applies schema changes:

# Install for the first time
python odoo-bin -d production_db -i my_project_module --stop-after-init

# Upgrade after code changes
python odoo-bin -d production_db -u my_project_module --stop-after-init

A few deployment rules learned from hard experience: always test upgrades on a database restore of production before running them live. If you add a new required field (required=True) without providing a default, the upgrade will fail with a NOT NULL constraint violation on the existing rows. For adding required fields to populated tables, always provide a default value or run a pre-migration script.

Keep your custom addons in a separate directory from Odoo's core addons. This makes upgrades cleaner, simplifies Docker image layering, and makes it obvious which code is yours versus upstream. In your Odoo config:

[options]
addons_path = /opt/odoo/addons,/opt/odoo/custom_addons

Common Pitfalls in Production

After deploying dozens of custom Odoo modules, these are the issues we see most often in production systems. First, computed fields with store=True that aren't properly invalidated — if you forget a dependency in @api.depends, the stored value silently goes stale and users see incorrect data. Second, XML IDs that collide across modules: always prefix your record IDs with your module name to avoid conflicts (my_module.view_task_form_custom, not just view_task_form_custom). Third, calling env['model'].search([]) without a limit inside loops — this is the most common source of severe performance degradation in custom modules.

Building custom Odoo modules well is as much about discipline and conventions as it is about Python skill. Follow the manifest ordering rules, use xpath targeting that's resilient to upgrades, write tests from day one, and always test upgrades on a database clone before touching production. Done right, a custom Odoo module can extend the platform for years across multiple major version upgrades without becoming a maintenance burden.