# How to create a custom module in Odoo 19

Canonical: https://erpfly.com/blog/create-custom-module-odoo/
Last updated: September 15, 2026

Published March 18, 2026 in Odoo. Scaffold an Odoo 19 addon, write the manifest, model, access CSV, list and form views and menu, then install with -i and upgrade with -u.

A custom module in Odoo 19 is a folder on your addons path with a `__manifest__.py`, Python models, an `ir.model.access.csv` file and XML views. You generate the skeleton with `odoo-bin scaffold`, fill in the model and views, then install it with `-i your_module` and apply later changes with `-u your_module`. The parts that trip people up are rarely the Python. They're the data file order in the manifest, missing access rights, and view syntax copied from tutorials written for Odoo 16.

We'll build a small equipment rental module. It's enough to touch every file you'll need on a real project.

### Step 1: Scaffold the module

```bash
./odoo-bin scaffold equipment_rental /opt/odoo/custom-addons
```

That creates a folder with `__init__.py`, `__manifest__.py`, and stubs for `models/`, `views/`, `security/`, `controllers/` and `demo/`. We delete `controllers/` and `demo/` unless we need them. Dead stubs confuse the next developer.

By the end of this guide, the module will look like this:

```text
equipment_rental/
├── __init__.py
├── __manifest__.py
├── data/
│   └── ir_sequence_data.xml
├── models/
│   ├── __init__.py
│   └── equipment_rental.py
├── security/
│   ├── equipment_rental_security.xml
│   └── ir.model.access.csv
├── tests/
│   ├── __init__.py
│   └── test_equipment_rental.py
└── views/
    ├── equipment_rental_views.xml
    └── menus.xml
```

The folder name is the technical name of the module. Use lowercase with underscores, and pick it once. Renaming a module after it's installed on a live database means migrating every XML ID that carries the old prefix, which is slow and easy to get wrong.

Make sure `/opt/odoo/custom-addons` is in `addons_path` in your config file. If Odoo can't see the folder, nothing else in this guide matters.

### Step 2: Write the manifest

```python
# equipment_rental/__manifest__.py
{
    "name": "Equipment Rental",
    "version": "19.0.1.0.0",
    "summary": "Book machines by the day and track returns",
    "category": "Services",
    "author": "Your Company",
    "license": "LGPL-3",
    "depends": ["base", "mail"],
    "data": [
        "security/ir.model.access.csv",
        "security/equipment_rental_security.xml",
        "data/ir_sequence_data.xml",
        "views/equipment_rental_views.xml",
        "views/menus.xml",
    ],
    "application": True,
    "installable": True,
}
```

A few things we care about here:

- **`version`** starts with the Odoo series. `19.0.1.0.0` tells anyone reading it which Odoo it targets and leaves room for your own version after.
- **`depends`** must list every module whose models, views or XML IDs you use. We depend on `mail` because the model uses the chatter. Forget a dependency and it might still work on your machine, because the other module happens to be installed, then break on a clean database.
- **`data`** loads in order. Anything referenced must be loaded first. Security groups before the access CSV that uses them, actions before the menus that open them.
- **`license`** should be set explicitly. Leave it out and Odoo warns and assumes LGPL-3.

### Step 3: Define the model

```python
# equipment_rental/__init__.py
from . import models
```

```python
# equipment_rental/models/__init__.py
from . import equipment_rental
```

```python
# equipment_rental/models/equipment_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", "mail.activity.mixin"]
    _order = "start_date desc, id desc"

    name = fields.Char(string="Reference", required=True, copy=False, default="New")
    partner_id = fields.Many2one("res.partner", string="Customer", required=True, tracking=True)
    machine = fields.Char(required=True)
    start_date = fields.Date(required=True, default=fields.Date.context_today)
    end_date = fields.Date(required=True)
    days = fields.Integer(compute="_compute_days", store=True)
    company_id = fields.Many2one("res.company", default=lambda self: self.env.company)
    currency_id = fields.Many2one(related="company_id.currency_id")
    daily_rate = fields.Monetary(currency_field="currency_id")
    amount = fields.Monetary(compute="_compute_amount", store=True, currency_field="currency_id")
    state = fields.Selection(
        [("draft", "Draft"), ("out", "Out"), ("returned", "Returned")],
        default="draft",
        tracking=True,
    )

    _name_unique = models.Constraint("UNIQUE(name)", "Rental reference must be unique.")

    @api.depends("start_date", "end_date")
    def _compute_days(self):
        for rental in self:
            if rental.start_date and rental.end_date:
                rental.days = (rental.end_date - rental.start_date).days + 1
            else:
                rental.days = 0

    @api.depends("days", "daily_rate")
    def _compute_amount(self):
        for rental in self:
            rental.amount = rental.days * rental.daily_rate

    @api.constrains("start_date", "end_date")
    def _check_dates(self):
        for rental in self:
            if rental.end_date and rental.start_date and rental.end_date < rental.start_date:
                raise ValidationError(self.env._("End date can't be before the start date."))

    def action_check_out(self):
        self.write({"state": "out"})

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

Two Odoo 19 notes. SQL constraints are now declared as `models.Constraint` attributes. The old `_sql_constraints` list isn't just deprecated: Odoo 19 logs a warning that it's no longer supported and doesn't create those constraints, so a port from 18 can lose its uniqueness checks without failing. And `self.env._()` is the preferred way to translate strings inside methods since 18.

Compute methods always loop over `self`. Even if you're sure only one record comes in, a list view recompute or an import will pass hundreds.

#### Give records a real reference

"New" as a reference won't last past the first demo. Add a sequence as a data file:

```xml
<!-- equipment_rental/data/ir_sequence_data.xml -->
<odoo noupdate="1">
    <record id="seq_equipment_rental" model="ir.sequence">
        <field name="name">Equipment Rental</field>
        <field name="code">equipment.rental</field>
        <field name="prefix">RENT/%(year)s/</field>
        <field name="padding">5</field>
        <field name="company_id" eval="False"/>
    </record>
</odoo>
```

Then pull the next number in `create`. Odoo creates records in batches, so override it with `@api.model_create_multi` and handle a list of value dicts, not a single dict:

```python
    @api.model_create_multi
    def create(self, vals_list):
        for vals in vals_list:
            if vals.get("name", "New") == "New":
                vals["name"] = self.env["ir.sequence"].next_by_code("equipment.rental") or "New"
        return super().create(vals_list)
```

`noupdate="1"` matters. Without it, every `-u` resets the sequence record to what's in the file, and if someone changed the prefix in the UI, their change is gone.

### Step 4: Add access rights

Every model needs at least one line in `ir.model.access.csv`. Without it, only the superuser can see records, your menu disappears for normal users, and the log shows a warning that the model has no access rules.

```text
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,0
access_equipment_rental_system,equipment.rental admin,model_equipment_rental,base.group_system,1,1,1,1
```

`model_equipment_rental` is the XML ID Odoo creates automatically for `equipment.rental`: the prefix `model_` plus the model name with dots replaced by underscores. On a real project we'd define our own `Rental User` and `Rental Manager` groups in a security XML file (loaded before the CSV) rather than hand everything to `base.group_user`.

#### Don't forget the multi-company rule

The model has a `company_id` field, which suggests records belong to a company. The access CSV doesn't enforce that. It only says which groups may read or write the model at all. Filtering by company is the job of a record rule:

```xml
<!-- equipment_rental/security/equipment_rental_security.xml -->
<odoo>
    <record id="equipment_rental_company_rule" model="ir.rule">
        <field name="name">Equipment rental: multi-company</field>
        <field name="model_id" ref="model_equipment_rental"/>
        <field name="domain_force">[('company_id', 'in', company_ids + [False])]</field>
    </record>
</odoo>
```

A rule with no groups is global and applies to everyone. `company_ids` is the set of companies currently selected in the company switcher, and the `[False]` keeps records without a company visible. Single-company databases won't notice the rule, which is exactly why it gets forgotten until the customer opens a second company and people start seeing each other's rentals.

### Step 5: Write the views

Odoo 18 renamed tree views to list views, and Odoo 17 removed `attrs` and `states` in favour of plain Python expressions in `invisible`, `readonly` and `required`. Most broken tutorials fail on one of those two.

```xml
<!-- equipment_rental/views/equipment_rental_views.xml -->
<odoo>
    <record id="equipment_rental_view_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="machine"/>
                <field name="start_date"/>
                <field name="end_date"/>
                <field name="amount" sum="Total"/>
                <field name="state" widget="badge"/>
            </list>
        </field>
    </record>

    <record id="equipment_rental_view_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_check_out" type="object" string="Check Out"
                            class="btn-primary" invisible="state != 'draft'"/>
                    <button name="action_return" type="object" string="Mark Returned"
                            invisible="state != 'out'"/>
                    <field name="state" widget="statusbar"/>
                </header>
                <sheet>
                    <group>
                        <group>
                            <field name="name"/>
                            <field name="partner_id"/>
                            <field name="machine"/>
                        </group>
                        <group>
                            <field name="start_date" readonly="state != 'draft'"/>
                            <field name="end_date"/>
                            <field name="days"/>
                            <field name="daily_rate"/>
                            <field name="amount"/>
                            <field name="currency_id" invisible="1"/>
                        </group>
                    </group>
                </sheet>
                <chatter/>
            </form>
        </field>
    </record>

    <record id="equipment_rental_view_search" model="ir.ui.view">
        <field name="name">equipment.rental.search</field>
        <field name="model">equipment.rental</field>
        <field name="arch" type="xml">
            <search>
                <field name="name"/>
                <field name="partner_id"/>
                <filter name="filter_out" string="Out" domain="[('state', '=', 'out')]"/>
            </search>
        </field>
    </record>

    <record id="equipment_rental_action" 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>
</odoo>
```

### Step 6: Add the menu

```xml
<!-- equipment_rental/views/menus.xml -->
<odoo>
    <menuitem id="equipment_rental_menu_root" name="Rentals" sequence="40"/>
    <menuitem id="equipment_rental_menu_rentals" name="Rentals"
              parent="equipment_rental_menu_root"
              action="equipment_rental_action" sequence="10"/>
</odoo>
```

We keep menus in their own file, loaded after the views, so the action always exists when the menu references it.

### Step 7: Install and upgrade

```bash
# first install
./odoo-bin -c /etc/odoo/odoo.conf -d rentals_dev -i equipment_rental --stop-after-init

# after changing models, XML or the CSV
./odoo-bin -c /etc/odoo/odoo.conf -d rentals_dev -u equipment_rental --stop-after-init

# while iterating on views locally
./odoo-bin -c /etc/odoo/odoo.conf -d rentals_dev --dev=reload,xml
```

The rule of thumb: Python changes need a server restart, and anything that changes the database (new fields, XML records, access rules) needs `-u`. `--dev=xml` reads view arch straight from your files so you can tweak layouts without upgrading, but don't let it hide the fact that you still need a real `-u` before deploying.

To install from the UI instead, turn on developer mode, go to Apps, click **Update Apps List**, and remove the default "Apps" filter from the search bar. That filter hides modules that aren't flagged as applications, and it's the most common reason people think Odoo can't see their module.

### Step 8: Add a test for the rules that matter

Odoo's test runner picks up anything imported from a `tests` package. You don't need a big suite. You need the rules that would cost money if they broke.

```python
# equipment_rental/tests/__init__.py
from . import test_equipment_rental
```

```python
# equipment_rental/tests/test_equipment_rental.py
from datetime import date

from odoo.exceptions import ValidationError
from odoo.tests import TransactionCase, tagged


@tagged("post_install", "-at_install")
class TestEquipmentRental(TransactionCase):
    @classmethod
    def setUpClass(cls):
        super().setUpClass()
        cls.partner = cls.env["res.partner"].create({"name": "Test Customer"})

    def _rental(self, start, end):
        return self.env["equipment.rental"].create({
            "partner_id": self.partner.id,
            "machine": "Excavator 3T",
            "start_date": start,
            "end_date": end,
            "daily_rate": 100.0,
        })

    def test_amount_counts_both_days(self):
        rental = self._rental(date(2026, 3, 1), date(2026, 3, 3))
        self.assertEqual(rental.days, 3)
        self.assertEqual(rental.amount, 300.0)
        self.assertNotEqual(rental.name, "New")

    def test_end_before_start_is_rejected(self):
        with self.assertRaises(ValidationError):
            self._rental(date(2026, 3, 5), date(2026, 3, 1))
```

```bash
./odoo-bin -c /etc/odoo/odoo.conf -d rentals_test -i equipment_rental \
    --test-enable --test-tags /equipment_rental --stop-after-init
```

Run tests on a throwaway database. `TransactionCase` rolls back, but installing a module with tests enabled on a database you care about is a habit that eventually bites.

### Errors we see on almost every first module

**"Since 17.0, the "attrs" and "states" attributes are no longer used."** You copied an old view. Rewrite `attrs="{'invisible': [('state', '!=', 'draft')]}"` as `invisible="state != 'draft'"`.

**A `<tree>` view that won't load.** Rename the tag to `<list>` and change `tree` to `list` in the action's `view_mode`.

**"External ID not found in the system".** Something is referenced before it's loaded. Check the order of `data` in the manifest, and check the module prefix if the ID lives in another module (`base.group_user`, not `group_user`).

**Field doesn't exist on the model in view validation.** Usually a missing import in `models/__init__.py`, or you added the field in Python but restarted without `-u`.

**The menu is there for admin but not for users.** Missing or wrong line in `ir.model.access.csv`. Odoo hides menus whose action points to a model the user can't read.

**"Access Error" when a normal user creates a record.** Test with a non-admin user before you call the module done. The admin user sits in Settings and a pile of other groups, so it passes checks your real users fail. A module can look finished for weeks while being unusable for the people it was built for.

### What we'd do differently from the tutorials

Don't start with a new model if you're really extending an existing one. If rentals were a kind of sale order, we'd add fields to `sale.order` with `_inherit` instead of building a parallel document that needs its own invoicing. Our explainer on [`_inherit` vs `_inherits` in Odoo](https://erpfly.com/blog/odoo-inherit-vs-inherits/) covers when each is right. And if a customer only needs two extra fields and a filter, check whether [Odoo Studio or a custom module](https://erpfly.com/blog/odoo-studio-vs-custom-module/) is the better call before writing any Python.

Write a test for the one rule that would cost money if it broke. For this module, that's the date check and the amount compute. Everything else you'll catch clicking around.

### Getting the boring parts generated

The manifest, access CSV, list, form, search, action and menu above are the same shape on every project. erpfly generates them, plus models and tests, from a plain-English description and gives you the addon as a pull request. Have a look at [custom Odoo module development with erpfly](https://erpfly.com/odoo-module-development/), or at [Odoo customization](https://erpfly.com/odoo-customization/) if you're extending existing apps. For a sense of scope, the [field service module](https://erpfly.com/modules/field-service/) is close to what this tutorial grows into.

### Sources

- [Module Manifests, Odoo 19 developer documentation](https://www.odoo.com/documentation/19.0/developer/reference/backend/module.html)
- [Command-line interface, Odoo 19 documentation](https://www.odoo.com/documentation/19.0/developer/reference/cli.html)
- [ORM API, Odoo 19 developer documentation](https://www.odoo.com/documentation/19.0/developer/reference/backend/orm.html)
- [Security in Odoo, Odoo 19 developer documentation](https://www.odoo.com/documentation/19.0/developer/reference/backend/security.html)
- [Chapter 4: Security, Odoo 19 developer tutorial](https://www.odoo.com/documentation/19.0/developer/tutorials/server_framework_101/04_securityintro.html)