Odoo's real strength is that you can extend it. When standard Sales, Inventory or Accounting stops just short of how your business actually works, a custom module adds exactly the model, fields, screens and logic you need, without forking the core. This guide builds a small but complete module from an empty folder to an installed app.
Every snippet here is written for Odoo 19 specifically. Several APIs changed in recent releases, and most tutorials online still show the old Odoo 17 or 18 way. We flag each of those differences at the end. Our worked example is a simple library.book module.
Before you start
You need a few things in place:
- A working Odoo 19 install you can restart (local source, Docker, or a dev server).
- An
addonspath where Odoo looks for modules, and developer mode switched on. - Basic Python and a little XML. You do not need to be an expert.
- A code editor.
Nothing here needs Odoo Enterprise. It all runs on Community, the free, open-source edition.
Step 1: Create the module folder
An Odoo module is just a folder on the addons path with two required files, __init__.py and __manifest__.py, plus subfolders for your code, security and views. Create this structure:
library/
__init__.py
__manifest__.py
models/
__init__.py
library_book.py
security/
ir.model.access.csv
views/
library_book_views.xml
library_menus.xml
The two __init__.py files simply tell Python what to import:
# library/__init__.py from . import models # library/models/__init__.py from . import library_book
Tip: you can generate this skeleton automatically with odoo-bin scaffold library /path/to/addons, then edit the files below.
Step 2: Write the manifest
The __manifest__.py is the module's ID card. It tells Odoo the name, version, what it depends on, and which data files to load. For a custom module use the full five-part version number starting with 19.0:
{
"name": "Library",
"version": "19.0.1.0.0",
"category": "Services",
"summary": "Manage a small library of books",
"author": "Your Company",
"license": "LGPL-3",
"depends": ["base", "mail"],
"data": [
"security/ir.model.access.csv",
"views/library_book_views.xml",
"views/library_menus.xml",
],
"application": True,
"installable": True,
}
Order matters inside data: load security before the views that use it. We depend on mail because our form will use the chatter.
Step 3: Define the model and its fields
A model is a database table plus a Python class. Here we declare the book, a few fields, and one stored computed field whose value Odoo recalculates automatically when its inputs change:
from odoo import api, fields, models
from odoo.exceptions import ValidationError
class LibraryBook(models.Model):
_name = "library.book"
_description = "Library Book"
_inherit = ["mail.thread"] # enables the chatter
_order = "name"
name = fields.Char(string="Title", required=True, tracking=True)
author_id = fields.Many2one("res.partner", string="Author")
date_published = fields.Date(string="Published on")
list_price = fields.Float(string="List Price")
discount = fields.Float(string="Discount %")
final_price = fields.Float(
string="Final Price",
compute="_compute_final_price",
store=True,
aggregator="sum", # Odoo 19 kwarg, not group_operator
)
@api.depends("list_price", "discount")
def _compute_final_price(self):
for book in self:
book.final_price = book.list_price * (1 - (book.discount or 0) / 100)
The compute plus store=True pattern is the workhorse of real Odoo development: the value is written to the database, is searchable and reportable, and is kept in sync by the @api.depends line.
Step 4: Grant access rights
This step is skipped in a surprising number of tutorials, and then "the menu does nothing" or you hit an access error. In Odoo, every new model needs an access line, or nobody, not even the admin, can read it. Add one row to security/ir.model.access.csv:
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink access_library_book,library.book,model_library_book,base.group_user,1,1,1,1
Odoo builds the model reference for you as model_ plus the model name with dots turned into underscores, so library.book becomes model_library_book. Here we give every internal user (base.group_user) full rights.
Step 5: Build the list and form views
Views are XML. This is the single most common place a v17 or v18 tutorial goes wrong in Odoo 19: the list view root tag is now <list>, not <tree>. Create views/library_book_views.xml:
<record id="library_book_view_list" model="ir.ui.view">
<field name="name">library.book.list</field>
<field name="model">library.book</field>
<field name="arch" type="xml">
<list string="Books">
<field name="name"/>
<field name="author_id"/>
<field name="final_price" sum="Total"/>
</list>
</field>
</record>
<record id="library_book_view_form" model="ir.ui.view">
<field name="name">library.book.form</field>
<field name="model">library.book</field>
<field name="arch" type="xml">
<form>
<sheet>
<group>
<field name="name"/>
<field name="author_id"/>
<field name="date_published"/>
<field name="list_price"/>
<field name="discount"/>
<field name="final_price"/>
</group>
</sheet>
<chatter/>
</form>
</field>
</record>
Note the <chatter/> tag at the bottom of the form. In Odoo 19 that single self-closing tag replaces the old <div class="oe_chatter"> block, and it works because the model inherits mail.thread.
Step 6: Add a menu so users can reach it
An action tells Odoo which model and views to open; menu items place it in the navigation. Create views/library_menus.xml:
<record id="library_book_action" model="ir.actions.act_window">
<field name="name">Books</field>
<field name="res_model">library.book</field>
<field name="view_mode">list,form</field>
</record>
<menuitem id="library_menu_root" name="Library" sequence="30"/>
<menuitem id="library_book_menu"
parent="library_menu_root"
action="library_book_action"
sequence="10"/>
The view_mode also uses list,form in Odoo 19, not the old tree,form.
Step 7: Add business logic with a constraint
Business logic belongs in the model layer, never in views. Odoo gives you two kinds of rules: fast database constraints, and richer Python checks. In Odoo 19, database constraints are declared as a models.Constraint class attribute. The old _sql_constraints = [...] list is inert in v19, so a tutorial that still uses it silently creates no constraint at all. Add these to the model class:
# SQL-level rule, Odoo 19 form
_title_unique = models.Constraint(
"UNIQUE(name)",
"A book with this title already exists.",
)
@api.constrains("discount")
def _check_discount(self):
for book in self:
if book.discount < 0 or book.discount > 100:
raise ValidationError("Discount must be between 0 and 100.")
The @api.constrains method runs on every save and raises a friendly error; the database constraint is your last line of defence at the storage level.
Step 8: Install and test
With the files in place, install the module. From the command line, point Odoo at your database and use -i to install:
# install the module into database "mydb" ./odoo-bin -d mydb -i library # after editing the code, upgrade it ./odoo-bin -d mydb -u library
Prefer the interface? Turn on developer mode, open Apps, click Update Apps List, search for "Library" and activate it. Use -u (or Upgrade) whenever you change the code, and restart the server after Python changes so they load.
Odoo 19 changes that break older tutorials
If you follow an Odoo 17 or 18 guide on Odoo 19, these are the traps that will bite you. Every one is verified against the Odoo 19 source:
| Old way (Odoo 17 / 18) | Odoo 19 |
|---|---|
<tree> list view; view_mode "tree,form" | <list> list view; view_mode "list,form" |
_sql_constraints = [(name, sql, msg)] | models.Constraint(sql, msg) as a class attribute |
res.users field groups_id | res.users field group_ids (and res.groups.category_id is now privilege_id) |
fields.Float(group_operator="sum") | fields.Float(aggregator="sum") |
<div class="oe_chatter"> ... </div> | <chatter/> |
The public imports (from odoo import api, fields, models) are unchanged in Odoo 19, even though the ORM itself moved into a new odoo/orm/ package internally.
CODEerts
Want a custom Odoo module built right?
We build production Odoo modules to framework conventions, with access rules, tests and upgrade-safe code, and we maintain a large catalogue of them on the official Odoo Store. Explore Custom Odoo Development, hire dedicated Odoo developers, or browse our apps on the Odoo Store.
FAQ: building a custom Odoo 19 module
How do I create a custom module in Odoo 19?
Create a folder in your addons path with an __init__.py and a __manifest__.py, then add a model in Python, an ir.model.access.csv for security, and XML views and menus. Turn on developer mode, update the apps list, and install the module. This guide walks through each file.
What files does an Odoo 19 module need?
At minimum a __manifest__.py and an __init__.py. A useful module also adds a models folder with your Python model, a security/ir.model.access.csv access file, and a views folder with the list view, form view and menus. Every new model needs an access-rights line.
Is Odoo 19 module development different from Odoo 17 or 18?
The structure is the same, but several APIs changed. Odoo 19 list views use the <list> tag instead of <tree>, SQL rules use models.Constraint instead of the old _sql_constraints list, the user field groups_id became group_ids, and fields use aggregator instead of group_operator. Older tutorials often miss these.
Do I need to know Python to build an Odoo module?
For anything beyond configuration, yes. Models, fields, computed values and constraints are written in Python, and views and menus in XML. You do not need to be an expert, but basic Python and a little XML are required. Simple layout tweaks can be done without code using Studio on Enterprise.
How do I install a custom module in Odoo 19?
Put the module folder in your addons path, then either install it from the command line with odoo-bin -d yourdb -i module_name, or turn on developer mode, open Apps, click Update Apps List, search for your module and activate it. Use -u to upgrade after code changes.
Should I build my own Odoo module or hire a developer?
A small internal tool is a great learning project. But production modules that touch invoicing, stock or security need access rules, tests and upgrade-safe code, which is where bugs get expensive. If it runs your business, having certified developers build or review it is usually worth it.