Why modules are the whole story
Odoo is not a monolith with a few plug-in slots on the side. It is a monolith made of plug-ins. Everything you see when you log in — CRM, Sales, Inventory, the settings app itself — is a module, and your own custom functionality follows exactly the same rules as the built-in ones. Building your first module is therefore about learning the native language of the platform: directories, manifests, models, views, menus, and the security file that decides who gets to see any of it.
This article walks through a complete minimal module for Odoo 17, using a small Library Management app as a running example. It tracks a library.book model with a name, an author, a publication year, and an availability flag. By the end, you will know what each part of a module does, why the pieces have to load in a particular order, and which first-module mistakes silently cost you an afternoon.
The minimal directory layout
Odoo discovers modules by scanning the directories listed in addons_path in odoo.conf. Any subdirectory of those paths that contains a __manifest__.py file is treated as a module. The module's directory name must be snake_case — no hyphens — because Python imports it as a package, and hyphens are not valid in Python identifiers.
A minimal module has this shape:
library_management/├── __init__.py├── __manifest__.py├── models/│ ├── __init__.py│ └── book.py├── security/│ └── ir.model.access.csv└── views/ ├── book_views.xml └── library_menus.xmlOdoo.s official scaffolding also shows optional controllers/, demo/, and static/ directories. You do not need them to start; the layout above is complete and installable.
Each part has one job:
__manifest__.py— the module's declaration: name, version, dependencies, and which data files to load.__init__.pyfiles — import the Python code so Odoo's registry actually sees it.models/book.py— the ORM model: tables, fields, constraints.security/ir.model.access.csv— access control lists: which user groups can read, write, create, or delete records of each model.views/book_views.xml— actions, list views, and form views.views/library_menus.xml— the menu entries that make it all reachable from the top navigation.
The __init__.py chain (and the silent failure)
Python packages need __init__.py files to import their contents, and Odoo modules follow the same rule one level deeper. The root __init__.py imports the subpackage:
from . import modelsAnd models/__init__.py imports each model file:
from . import bookThis is the single most common way a first module fails: forget the import, and Odoo installs the module happily, creates no error, logs nothing suspicious — and the model simply does not exist. The registry never sees the class because Python never imported it. If a model or field is mysteriously absent after an install, check the __init__.py chain before anything else.
The manifest, key by key
__manifest__.py is a Python dictionary, not JSON — trailing commas are legal and bare True/False are used. For our library module:
{ 'name': 'Library Management', 'version': '17.0.1.0.0', 'summary': 'Track books, authors, and availability', 'description': 'A small library management module.', 'author': 'Your Name', 'license': 'LGPL-3', 'category': 'Productivity', 'depends': ['base'], 'data': [ 'security/ir.model.access.csv', 'views/book_views.xml', 'views/library_menus.xml', ], 'demo': [], 'installable': True, 'application': False, 'auto_install': False,}Key by key:
name— the human-readable title shown in the Apps list.version— the format is{odoo_major}.0.{major}.{minor}.{patch}, so Odoo 17 modules start with17.0. Bump it when you want upgrades of your data files to take effect.license—LGPL-3is the standard for modules intended to be shared; Odoo's own modules use it.depends— modules loaded before yours.['base']is the absolute minimum; most real modules also list things likemailorcontacts.data— XML and CSV files loaded in the order listed. Ordering matters: security files first, menus last (see below).installable—Truemeans the module can be installed. Set itFalsefor abstract modules that exist only as dependencies.application—Truemarks the module as a standalone app, which promotes it in the Apps dashboard. Utility modules leave itFalse.auto_install— whenTrue, Odoo installs the module automatically once all its dependencies are present. Leave itFalseunless you are building glue between two other modules.
The model file
The model defines the database table through Odoo's ORM. A model class names its table via _name and declares fields as class attributes:
from odoo import fields, modelsclass LibraryBook(models.Model): _name = 'library.book' _description = 'Library Book' name = fields.Char(string='Title', required=True) author = fields.Char(string='Author') published_year = fields.Integer(string='Publication Year') is_available = fields.Boolean(string='Available', default=True)_name = 'library.book' is the technical identifier used everywhere else: in views, in actions, in the security CSV. _description is the human label that shows up in error messages and some UI spots. The name field is special — Odoo uses it as the display name of records by default, which is why required=True on it is almost always a good idea. The ORM creates the underlying library_book table on install, plus standard audit fields — you never declare those yourself.
Security: the CSV nobody reads until it bites
Odoo's default security posture is deny-by-default: a new model with no access control line is invisible to normal users — only the Administrator sees its records. This is the second classic first-module surprise, and it looks like “my views are broken” when the views are fine.
Access rights live in security/ir.model.access.csv, with exactly these columns:
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlinkaccess_library_book_user,library.book.user,model_library_book,base.group_user,1,1,1,0access_library_book_manager,library.book.manager,model_library_book,base.group_system,1,1,1,1Reading column by column:
id— a unique external ID (xid) for the access line. Each (group, model) pair needs its own line, and each line needs its own unique xid.model_id:id— references the model via an auto-generated external ID. The convention ismodel_plus the model name with dots replaced by underscores:library.bookbecomesmodel_library_book.group_id:id— which group the line applies to.base.group_usercovers all internal users;base.group_systemis administrators.perm_read,perm_write,perm_create,perm_unlink— 1 or 0. In the example above, ordinary users can read, write, and create books but not delete them; administrators can do everything.
For a first module, two CSV lines against the built-in groups are enough.
XML files in data load strictly in listed order, and references must resolve at load time. That is why the manifest lists the security CSV first, views next, and menus last: menus reference actions, so the actions must already be loaded. Loading a menu before its action produces a broken entry or an install-time error.
A view file names itself after its model, per Odoo's guidelines — book_views.xml here. It defines an action, a list view, and a form view:
<?xml version="1.0" encoding="utf-8"?><odoo> <!-- Window action: the entry point to the model --> <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> <!-- 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> <field name="name"/> <field name="author"/> <field name="published_year"/> <field name="is_available"/> </list> </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> <sheet> <group> <field name="name"/> <field name="author"/> <field name="published_year"/> <field name="is_available"/> </group> </sheet> </form> </record></odoo>Note view_mode="list,form" — since Odoo 17 the list view is declared as <list>, not <tree>. The window action is what the menu will open.
Menus live in their own file by convention (library_menus.xml here, or <module>_menus.xml):
<?xml version="1.0" encoding="utf-8"?><odoo> <menuitem id="menu_library_root" name="Library" sequence="10"/> <menuitem id="menu_library_books" name="Books" parent="menu_library_root" action="action_library_book" sequence="10"/></odoo>A top-level menu entry has no parent; children point at a parent and an action. The child's action="action_library_book" is exactly why this file must load after the view file that defines that action — keep menus in the last data entry and you never have to think about it again.
Installing and upgrading
With the module directory placed in an addons_path directory, the flow is:
- Enable developer mode (Settings → scroll down → Activate the developer mode). This exposes the module tooling.
- Go to Apps → click Update Apps List → confirm. Odoo re-scans
addons_pathfor the new module. - Search for “Library Management” and click Install.
From then on, two different kinds of change need two different responses. New Python files or new models/fields require a server restart, because the Python process loads them at startup; upgrading alone will not import a file the process has never seen. XML changes (views, menus, actions, CSV tweaks) need no restart; in Apps, open the module and click Upgrade to reload the data files.
Common first-module mistakes
- Missing import in
__init__.py. Nothing errors and nothing is created — check the import chain first. - No access-control line for the new model. Deny-by-default means regular users see nothing until you add one row per (group, model).
- Menus loaded before actions. Put security files first and menus last in
data, and ordering bugs disappear. - Hyphens in the module directory name. The directory name is a Python package name:
library_management, neverlibrary-management. - Forgetting the module isn't in the apps list. New modules appear only after Update Apps List in developer mode.
- Confusing restart and upgrade. New Python code needs a restart; XML changes need only an upgrade.
Further reading
- First module tutorial (Odoo.sh docs) — the official scaffolded walkthrough this article's structure mirrors.
- Odoo 17 development guide (odoo-skills) — manifests, models, views, and security in one reference.
- Odoo module contributor notes (iris) — conventions for module layout and metadata.
- Odoo coding guidelines — file-naming conventions (
_views.xml,_menus.xml,_groups.xml) and style rules.






