Odoo ERP Odoo Development Custom Odoo Modules Python Business Automation Backend

How to Build a Custom Odoo Module from Scratch (Step-by-Step)

Learn how to create a fully functional custom Odoo module with models, views, security, and menus. A complete developer guide for Odoo 16/17.

15 min read

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:

  1. Go to Apps → Update Apps List
  2. Search for "Library Management"
  3. Click Install

✅ Done

Found this helpful?

We write about what we build. If you need similar solutions for your business, let's talk.