Skip to Content

How to Create a Custom Module in Odoo 19: Step-by-Step Tutorial

Build a working module with a model, list and form views, security, a menu and a computed field
October 14, 2026 by
How to Create a Custom Module in Odoo 19: Step-by-Step Tutorial
Bundo Tech

Almost every Odoo project needs something the standard apps don't do out of the box. The right way to add it is a custom module: your own code, kept separate from Odoo's, that survives upgrades. In this tutorial we build a small but complete module, Equipment Rental, and cover the Odoo 19 changes that trip up developers coming from older versions.

You need a working Odoo 19 development setup (see our guide on installing Odoo 19 on Ubuntu) and a custom addons folder listed in addons_path.

1. Module structure

equipment_rental/
├── __init__.py
├── __manifest__.py
├── models/
│   ├── __init__.py
│   └── rental.py
├── security/
│   └── ir.model.access.csv
└── views/
    └── rental_views.xml

You can generate a skeleton with odoo-bin scaffold equipment_rental /path/to/custom-addons, but writing it by hand once helps you understand every file.

2. The manifest

# __manifest__.py
{
    "name": "Equipment Rental",
    "version": "19.0.1.0.0",
    "summary": "Track equipment rented to customers",
    "category": "Sales",
    "author": "Your Company",
    "license": "LGPL-3",
    "depends": ["base", "mail"],
    "data": [
        "security/ir.model.access.csv",
        "views/rental_views.xml",
    ],
    "application": True,
}

Order in data matters: access rights first, then views, and menus after the actions they open. Use the 19.0.x.y.z version format so upgrades are tracked correctly.

3. The model

# __init__.py
from . import models

# models/__init__.py
from . import rental
# models/rental.py
from odoo import api, fields, models
from odoo.exceptions import ValidationError


class EquipmentRental(models.Model):
    _name = "equipment.rental"
    _description = "Equipment Rental"
    _inherit = ["mail.thread"]
    _order = "date_start desc"

    name = fields.Char(required=True, tracking=True)
    partner_id = fields.Many2one("res.partner", string="Customer", required=True)
    date_start = fields.Date(required=True, default=fields.Date.today)
    date_end = fields.Date(required=True)
    daily_rate = fields.Monetary(currency_field="currency_id")
    currency_id = fields.Many2one(
        "res.currency", default=lambda self: self.env.company.currency_id)
    days = fields.Integer(compute="_compute_total", store=True)
    total = fields.Monetary(compute="_compute_total", store=True,
                            currency_field="currency_id")
    state = fields.Selection([
        ("draft", "Draft"),
        ("rented", "Rented"),
        ("returned", "Returned"),
    ], default="draft", tracking=True)

    _name_partner_unique = models.Constraint(
        "UNIQUE(name, partner_id)",
        "This customer already has a rental with this name.",
    )

    @api.depends("date_start", "date_end", "daily_rate")
    def _compute_total(self):
        for rec in self:
            if rec.date_start and rec.date_end:
                rec.days = (rec.date_end - rec.date_start).days + 1
            else:
                rec.days = 0
            rec.total = rec.days * rec.daily_rate

    @api.constrains("date_start", "date_end")
    def _check_dates(self):
        for rec in self:
            if rec.date_end and rec.date_start and rec.date_end < rec.date_start:
                raise ValidationError("End date cannot be before start date.")

    def action_rent(self):
        self.write({"state": "rented"})

    def action_return(self):
        self.write({"state": "returned"})

Odoo 19 note: SQL constraints are now declared as class attributes with models.Constraint instead of the old _sql_constraints list. Python constraints still use @api.constrains.

4. Access rights

Without an access rule, the model exists but nobody (except the superuser) can see it, a very common beginner trap.

id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_equipment_rental_user,equipment.rental user,model_equipment_rental,base.group_user,1,1,1,1

The external id model_equipment_rental is generated automatically from the model name, with dots replaced by underscores.

5. Views, action and menu

<?xml version="1.0" encoding="utf-8"?>
<odoo>
  <record id="equipment_rental_list" model="ir.ui.view">
    <field name="name">equipment.rental.list</field>
    <field name="model">equipment.rental</field>
    <field name="arch" type="xml">
      <list decoration-muted="state == 'returned'">
        <field name="name"/>
        <field name="partner_id"/>
        <field name="date_start"/>
        <field name="date_end"/>
        <field name="total" sum="Total"/>
        <field name="state" widget="badge"/>
      </list>
    </field>
  </record>

  <record id="equipment_rental_form" model="ir.ui.view">
    <field name="name">equipment.rental.form</field>
    <field name="model">equipment.rental</field>
    <field name="arch" type="xml">
      <form>
        <header>
          <button name="action_rent" type="object" string="Mark as Rented"
                  class="btn-primary" invisible="state != 'draft'"/>
          <button name="action_return" type="object" string="Mark as Returned"
                  invisible="state != 'rented'"/>
          <field name="state" widget="statusbar"/>
        </header>
        <sheet>
          <group>
            <group>
              <field name="name"/>
              <field name="partner_id"/>
            </group>
            <group>
              <field name="date_start"/>
              <field name="date_end"/>
              <field name="daily_rate"/>
              <field name="days"/>
              <field name="total"/>
              <field name="currency_id" invisible="1"/>
            </group>
          </group>
        </sheet>
        <chatter/>
      </form>
    </field>
  </record>

  <record id="action_equipment_rental" model="ir.actions.act_window">
    <field name="name">Rentals</field>
    <field name="res_model">equipment.rental</field>
    <field name="view_mode">list,form</field>
  </record>

  <menuitem id="menu_rental_root" name="Rentals" sequence="20"/>
  <menuitem id="menu_rental" name="Rentals" parent="menu_rental_root"
            action="action_equipment_rental"/>
</odoo>

Odoo 17+ and 19 notes:

  • List views use <list>, and view_mode uses list. The old <tree> tag is gone.
  • attrs and states were removed. Write conditions directly: invisible="state != 'draft'", readonly="...", required="...".
  • The chatter is a single <chatter/> tag instead of the old oe_chatter div.

6. Install and test

# restart Odoo, then install from the command line
./odoo-bin -c /etc/odoo.conf -d mydb -i equipment_rental --stop-after-init

# after code changes
./odoo-bin -c /etc/odoo.conf -d mydb -u equipment_rental --stop-after-init

Or enable developer mode, open Apps, click Update Apps List, search "Equipment Rental" and install. Python changes need a server restart; XML and data changes need a module upgrade (-u).

Common errors and fixes

  • Module not in the Apps list: the folder is not in addons_path, or you didn't click Update Apps List.
  • "You are not allowed to access 'Equipment Rental'": missing or wrong line in ir.model.access.csv.
  • "Invalid view type: tree": replace <tree> with <list> and tree with list in view_mode.
  • "Since 17.0, the attrs and states attributes are no longer used": rewrite them as direct invisible/readonly expressions.
  • Computed field not updating: check that every field it reads is listed in @api.depends.

Where to go next

From here you can inherit existing models (add fields to sale.order with _inherit), add QWeb PDF reports, scheduled actions, or REST endpoints with http.Controller. When the requirement gets bigger, such as integrations, migrations or complex workflows, our Odoo development team can help. Bundo Tech is an Official Odoo Partner with 200+ Odoo projects delivered. Talk to us.

How to Install Odoo 19 on Ubuntu 24.04: A Production-Ready Guide
Source install, PostgreSQL, systemd, nginx, SSL, workers and backups, step by step