How to Build a Custom Odoo Module from Scratch (Step-by-Step)
Odoo's real power lies in its modular architecture. Whether you want to add a new feature, automate a workflow, or build a product on top of Odoo — knowing how to create a custom module is an essential skill.
In this guide, you'll build a Library Management module from scratch that covers all the key building blocks of Odoo development.
Prerequisites
- Odoo 16 or 17 installed locally
- Python 3.10+ and basic Python knowledge
- A code editor (VS Code recommended)
- Basic understanding of XML
Step 1: Set Up Your Addons Path
Add your custom addons directory to odoo.conf:
[options]
addons_path = /usr/lib/python3/dist-packages/odoo/addons,/home/youruser/custom_addons
Create the custom addons folder:
mkdir ~/custom_addons
Also activate Developer Mode in Odoo:
Settings → General Settings → Developer Tools → Activate the developer mode
Step 2: Create the Module Skeleton
Every Odoo module lives in its own folder. The folder name becomes the technical name of the module.
mkdir ~/custom_addons/library_management
cd ~/custom_addons/library_management
Step 3: Create __manifest__.py
This file is the identity card of your module.
{
'name': 'Library Management',
'version': '1.0.0',
'summary': 'Manage books, members, and borrowing in your library',
'author': 'Your Name',
'website': 'https://yourwebsite.com',
'category': 'Services',
'license': 'LGPL-3',
'depends': ['base', 'mail'],
'data': [
'security/ir.model.access.csv',
'views/book_views.xml',
'views/menu_views.xml',
],
'installable': True,
'application': True,
'auto_install': False,
}
Step 4: Create __init__.py
from . import models
Step 5: Create the Models Directory
mkdir models
touch models/__init__.py
touch models/library_book.py
models/__init__.py
from . import library_book
Step 6: Define the Book Model
# models/library_book.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='Book Title', required=True, tracking=True)
isbn = fields.Char(string='ISBN', size=13)
author_ids = fields.Many2many('res.partner', string='Authors')
publisher = fields.Char(string='Publisher')
publish_date = fields.Date(string='Published Date')
description = fields.Html(string='Description')
category = fields.Selection([
('fiction', 'Fiction'),
('non_fiction', 'Non-Fiction'),
('science', 'Science'),
('history', 'History'),
('technology', 'Technology'),
('other', 'Other'),
], string='Category', default='other')
total_copies = fields.Integer(string='Total Copies', default=1)
available_copies = fields.Integer(
string='Available Copies',
compute='_compute_available_copies',
store=True
)
borrow_ids = fields.One2many('library.borrow', 'book_id', string='Borrow Records')
state = fields.Selection([
('available', 'Available'),
('borrowed', 'Borrowed'),
('unavailable', 'Unavailable'),
], string='Status', default='available', tracking=True)
@api.depends('total_copies', 'borrow_ids.state')
def _compute_available_copies(self):
for book in self:
borrowed = len(book.borrow_ids.filtered(lambda b: b.state == 'borrowed'))
book.available_copies = book.total_copies - borrowed
@api.constrains('total_copies')
def _check_total_copies(self):
for book in self:
if book.total_copies < 0:
raise ValidationError("Total copies cannot be negative!")
def name_get(self):
return [(book.id, f"[{book.isbn or 'N/A'}] {book.title}") for book in self]
class LibraryBorrow(models.Model):
_name = 'library.borrow'
_description = 'Book Borrow Record'
_order = 'borrow_date desc'
book_id = fields.Many2one('library.book', string='Book', required=True, ondelete='cascade')
member_id = fields.Many2one('res.partner', string='Member', required=True)
borrow_date = fields.Date(string='Borrow Date', default=fields.Date.today)
due_date = fields.Date(string='Due Date', required=True)
return_date = fields.Date(string='Return Date')
state = fields.Selection([
('borrowed', 'Borrowed'),
('returned', 'Returned'),
('overdue', 'Overdue'),
], string='Status', default='borrowed')
def action_return_book(self):
for record in self:
record.state = 'returned'
record.return_date = fields.Date.today()
record.book_id._compute_available_copies()
Step 7: Set Up Access Rights (Security)
mkdir security
touch security/ir.model.access.csv
security/ir.model.access.csv
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
access_library_borrow_user,library.borrow.user,model_library_borrow,base.group_user,1,1,1,0
access_library_borrow_manager,library.borrow.manager,model_library_borrow,base.group_system,1,1,1,1
Step 8: Create the Views (XML)
mkdir views
touch views/book_views.xml
touch views/menu_views.xml
views/book_views.xml
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<!-- List View -->
<record id="view_library_book_tree" model="ir.ui.view">
<field name="name">library.book.tree</field>
<field name="model">library.book</field>
<field name="arch" type="xml">
<tree string="Books"
decoration-success="state=='available'"
decoration-danger="state=='unavailable'">
<field name="title"/>
<field name="isbn"/>
<field name="category"/>
<field name="total_copies"/>
<field name="available_copies"/>
<field name="state"/>
</tree>
</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">
<header>
<field name="state" widget="statusbar"
statusbar_visible="available,borrowed,unavailable"/>
</header>
<sheet>
<div class="oe_title">
<h1><field name="title" placeholder="Book Title..."/></h1>
</div>
<group>
<group string="Book Details">
<field name="isbn"/>
<field name="author_ids" widget="many2many_tags"/>
<field name="publisher"/>
<field name="publish_date"/>
<field name="category"/>
</group>
<group string="Inventory">
<field name="total_copies"/>
<field name="available_copies"/>
</group>
</group>
<notebook>
<page string="Description">
<field name="description"/>
</page>
<page string="Borrow History">
<field name="borrow_ids">
<tree>
<field name="member_id"/>
<field name="borrow_date"/>
<field name="due_date"/>
<field name="return_date"/>
<field name="state"/>
</tree>
</field>
</page>
</notebook>
</sheet>
<div class="oe_chatter">
<field name="message_follower_ids"/>
<field name="activity_ids"/>
<field name="message_ids"/>
</div>
</form>
</field>
</record>
<!-- Search View -->
<record id="view_library_book_search" model="ir.ui.view">
<field name="name">library.book.search</field>
<field name="model">library.book</field>
<field name="arch" type="xml">
<search string="Search Books">
<field name="title"/>
<field name="isbn"/>
<field name="author_ids"/>
<filter name="available" string="Available" domain="[('state','=','available')]"/>
<filter name="borrowed" string="Borrowed" domain="[('state','=','borrowed')]"/>
<group expand="0" string="Group By">
<filter name="group_category" string="Category"
context="{'group_by': 'category'}"/>
<filter name="group_state" string="Status"
context="{'group_by': 'state'}"/>
</group>
</search>
</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">tree,form</field>
<field name="search_view_id" ref="view_library_book_search"/>
</record>
</odoo>
views/menu_views.xml
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<menuitem id="menu_library_root"
name="Library"
sequence="10"/>
<menuitem id="menu_library_catalog"
name="Catalog"
parent="menu_library_root"
sequence="10"/>
<menuitem id="menu_library_books"
name="Books"
parent="menu_library_catalog"
action="action_library_book"
sequence="10"/>
</odoo>
Step 9: Final Folder Structure
library_management/
├── __init__.py
├── __manifest__.py
├── models/
│ ├── __init__.py
│ └── library_book.py
├── security/
│ └── ir.model.access.csv
└── views/
├── book_views.xml
└── menu_views.xml
Step 10: Install the Module
Restart your Odoo server:
./odoo-bin -c odoo.conf
Then in Odoo:
- Go to Apps → Update Apps List
- Search for "Library Management"
- Click Install