Creating a custom Odoo module sounds daunting, but the framework provides clear conventions that make it straightforward once you understand the structure. This guide builds a fully functional library_book module from scratch, covering every file you need to create a real-world module.
What We Are Building
A simple Library module that adds a library.book model with title, author, ISBN, and availability. It demonstrates models, views, security, data files, and inheritance of the partner model.
Step 1 — Scaffold the Module
Odoo ships with a scaffold command that generates the boilerplate structure:
cd /opt/odoo/custom
python /opt/odoo/src/odoo-bin scaffold library_book .
This creates:
library_book/
├── __init__.py
├── __manifest__.py
├── controllers/
│ ├── __init__.py
│ └── controllers.py
├── demo/
│ └── demo.xml
├── models/
│ ├── __init__.py
│ └── models.py
├── security/
│ └── ir.model.access.csv
├── static/
│ └── description/
│ └── icon.png
└── views/
├── templates.xml
└── views.xml
Delete controllers/ and static/ if you do not need them — keeping the tree clean helps.
Step 2 — __manifest__.py
The manifest is the module's identity card. Replace the scaffolded version:
{
'name': 'Library Books',
'version': '19.0.1.0.0',
'summary': 'Manage your library book catalog',
'description': 'Track books, authors, and availability in your Odoo library.',
'category': 'Services',
'author': 'Your Company',
'depends': ['base', 'mail'],
'data': [
'security/ir.model.access.csv',
'views/library_book_views.xml',
'data/library_book_data.xml',
],
'demo': ['demo/demo.xml'],
'installable': True,
'application': True,
'license': 'LGPL-3',
}
Key fields: depends lists modules that must be installed first. data files are loaded in order during install/upgrade. Always list security files before view files.
Step 3 — Define the Model
Edit models/library_book.py (rename models.py):
from odoo import models, fields, api
from odoo.exceptions import ValidationError
class LibraryBook(models.Model):
_name = 'library.book'
_description = 'Library Book'
_inherit = ['mail.thread', 'mail.activity.mixin']
_order = 'title asc'
title = fields.Char(string='Title', required=True, tracking=True)
author_id = fields.Many2one('res.partner', string='Author', required=True)
isbn = fields.Char(string='ISBN-13', size=13)
published_date = fields.Date(string='Published Date')
available = fields.Boolean(string='Available', default=True, tracking=True)
notes = fields.Html(string='Notes')
active = fields.Boolean(default=True)
_sql_constraints = [
('isbn_unique', 'UNIQUE(isbn)', 'ISBN must be unique across all books.'),
]
@api.constrains('isbn')
def _check_isbn(self):
for book in self:
if book.isbn and len(book.isbn) != 13:
raise ValidationError('ISBN must be exactly 13 characters.')
Update models/__init__.py to import the new file:
from . import library_book
Step 4 — Security (ir.model.access.csv)
Odoo uses access control lists defined in a CSV file. Every model needs at least one access rule or it will be invisible to non-superusers:
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_library_book_user,library.book user,model_library_book,base.group_user,1,1,1,0
access_library_book_manager,library.book manager,model_library_book,base.group_system,1,1,1,1
The model_id:id column references the auto-generated XML ID for the model (model_ + model name with dots replaced by underscores).
Step 5 — Views
Create views/library_book_views.xml:
<odoo>
<!-- List view -->
<record id="view_library_book_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="title"/>
<field name="author_id"/>
<field name="isbn"/>
<field name="available" widget="boolean_toggle"/>
</list>
</field>
</record>
<!-- Form view -->
<record id="view_library_book_form" model="ir.ui.view">
<field name="name">library.book.form</field>
<field name="model">library.book</field>
<field name="arch" type="xml">
<form string="Book">
<sheet>
<group>
<field name="title"/>
<field name="author_id"/>
<field name="isbn"/>
<field name="published_date"/>
<field name="available"/>
</group>
<field name="notes"/>
</sheet>
<div class="oe_chatter">
<field name="message_follower_ids"/>
<field name="activity_ids"/>
<field name="message_ids"/>
</div>
</form>
</field>
</record>
<!-- Action -->
<record id="action_library_book" 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>
<!-- Menu -->
<menuitem id="menu_library_root" name="Library" sequence="100"/>
<menuitem id="menu_library_books" name="Books" parent="menu_library_root"
action="action_library_book" sequence="10"/>
</odoo>
Step 6 — Data Files
Seed data loaded on every install/upgrade goes in data/library_book_data.xml:
<odoo noupdate="1">
<record id="book_odoo_development" model="library.book">
<field name="title">Odoo Development Essentials</field>
<field name="author_id" ref="base.res_partner_1"/>
<field name="available" eval="True"/>
</record>
</odoo>
noupdate="1" means this record is created on first install but not overwritten on subsequent upgrades — essential for seed data users may have modified.
Step 7 — Model Inheritance
To add a field to an existing model (e.g., mark a partner as an author), use _inherit:
class ResPartnerExtended(models.Model):
_inherit = 'res.partner'
is_author = fields.Boolean(string='Is Author', default=False)
book_count = fields.Integer(
string='Books Written',
compute='_compute_book_count',
store=True
)
@api.depends('name')
def _compute_book_count(self):
for partner in self:
partner.book_count = self.env['library.book'].search_count(
[('author_id', '=', partner.id)]
)
Installing and Testing Your Module
# Install
odoo-bin -d mydb -i library_book --stop-after-init
# Upgrade after changes
odoo-bin -d mydb -u library_book --stop-after-init
# Run tests
odoo-bin -d mydb --test-enable -u library_book --stop-after-init
Deploying Your Module on DeployMonkey
Once your module is tested locally, push it to Git and deploy on DeployMonkey with zero server configuration. The platform handles addons_path, pip dependencies, and module upgrades automatically. Start with the free plan and upgrade as your needs grow. See installing custom modules and Git deployment for the full workflow.
FAQ
What is the difference between _inherit and _name in Odoo models?
Using only _inherit (without _name) modifies the existing model in place — all existing records are affected and no new table is created. Using both _inherit and a new _name creates a new model that copies the parent's fields (prototype inheritance).
Why does my module not appear in the Apps menu?
Run Apps → Update Apps List in developer mode. Also verify the directory contains __manifest__.py and __init__.py, and that the directory is in addons_path.
How do I add a menu item to an existing top-level menu?
Use parent="module_name.menu_xml_id" in your <menuitem> tag, referencing the XML ID of the parent menu from the module that defines it.
What does noupdate="1" do in data files?
Records inside a noupdate="1" block are only written to the database on the first install. Subsequent module upgrades skip them, preserving any changes users made to those records.
Ready to Ship Your Module?
DeployMonkey gives your custom module a production home with Git deployment, S3 backups, and SSL — starting free. Deploy your first module today.