Developing Custom Odoo Modules
Custom Odoo module development is the code-first path: a versioned Python package with __manifest__.py, ORM models, XML views, and security that runs in the same process as core apps. Use it when Studio is too shallow and a store or OCA module will not encode your logic — and design every change to survive major upgrades on Odoo 19 and beyond.
TL;DR — Key takeaways
- Odoo is a modular system: every feature you see — CRM, Inventory, Accounting — is itself an addon (module).
- Odoo's contributing guidelines prescribe a specific directory layout so modules stay consistent across the ecosystem.
- Business objects in Odoo are Python classes that inherit from one of three base classes: models.Model for a persisted database table, models.TransientModel for a temporary, wizard-style table whose records are garbage-collected, and models.AbstractModel for a shared mixin that is not persisted on its own.
- Most business value lives in how models connect.
What a custom Odoo module actually is
Odoo is a modular system: every feature you see — CRM, Inventory, Accounting — is itself an addon (module). The official developer documentation describes Odoo as a multitier application whose server tier is a complete ORM on top of PostgreSQL, and everything above that ORM is delivered as modules. A custom module is simply a new addon you write yourself, built from the exact same building blocks Odoo's own apps use: Python business objects, XML views and data, and security rules.
Structurally, a module is a Python package (a folder with an __init__.py) that carries a sibling __manifest__.py describing it. That manifest is a Python dictionary that registers the package as an installable Odoo module. Once the module's folder is on Odoo's addons path and installed into a database, its models, views, security records, and data live alongside Odoo's own and behave identically. There is no second-class 'plugin' runtime — your code runs in the same process, against the same ORM and the same database, as the core apps.
This matters because it sets custom module development apart from three things it is often confused with. It is not Odoo Studio: Studio is a no-code Enterprise editor that lets non-developers add fields, views, menus, and automations from inside the UI, and its output is a customization layer (which can be exported as a module). It is not an OCA or App Store download: those are third-party modules you install and maintain as dependencies. And it is not a generic script: a module is a first-class citizen with its own version, dependencies, and upgrade lifecycle. When a need is too complex, too stateful, or too integration-heavy for Studio — and no maintained community or commercial module fits — the documented escalation path is exactly this: write a custom Python module.
The anatomy of a module: folders and __manifest__.py
Odoo's contributing guidelines prescribe a specific directory layout so modules stay consistent across the ecosystem. A well-formed module contains subdirectories for each kind of artifact — models/, views/, controllers/, data/, wizard/, report/, static/, and tests/ — with predictable naming conventions. Model files are named after their primary model (e.g. estate_property.py), view files carry _views.xml or _templates.xml suffixes, and security files split into ir.model.access.csv, <module>_groups.xml, and <model>_security.xml. Following these conventions is what lets other developers — and Odoo's own tooling — navigate your code.
The manifest, __manifest__.py, is the single required declaration and the gateway that makes the folder an installable module. It is a Python dictionary. The developer reference documents its standard keys: name, version, summary, description, author, website, license, depends (the list of other modules that must be installed first), data (the XML and CSV files loaded at install, in load order), demo (data loaded only when demo data is enabled), and application (true when the module is a top-level app rather than a dependency of one). The depends list is load-bearing: it defines both install order and the inheritance graph — you cannot extend a model whose module you do not depend on.
A minimal manifest declares a name, a version, the modules it depends on, and the data files to load. The version string matters more than people expect: Odoo parses it to decide whether to run data migrations, and on Odoo.sh bumping the version in __manifest__.py is what triggers an automatic module update after a production push. Versioned modules (e.g. 19.0.1.0.0 for Odoo 19) signal cleanly to the upgrade tooling. Choosing a license (LGPL-3 for Community-compatible modules, Odoo Proprietary for Enterprise-only, Other proprietary for commercial App Store modules) is a decision you make once at the top of this file.
Defining models and fields in Python
Business objects in Odoo are Python classes that inherit from one of three base classes: models.Model for a persisted database table, models.TransientModel for a temporary, wizard-style table whose records are garbage-collected, and models.AbstractModel for a shared mixin that is not persisted on its own. Each model declares _name (the technical dotted identifier, e.g. estate.property), _description (a human label), and class-level attributes that become database columns — the fields. The ORM introspects these classes at module load and creates or alters the underlying PostgreSQL tables automatically.
Fields are the schema. Odoo's ORM exposes a declarative set of field types, each mapping to a Python type: Char, Text, and Html map to str; Boolean to bool; Integer to int; Float and Monetary to float; Date to datetime.date; Datetime to datetime.datetime; and the relational fields Many2one, One2many, and Many2many. A Selection field stores a choice from a fixed list of (value, label) pairs. Each field takes attributes that shape its behaviour — required=True, help= for tooltips, default= for an initial value, string= for the user-facing label, compute= for a derived value, and groups= to restrict visibility to specific user groups.
Defaults can be static values or lambda-computed functions evaluated per record, and computed fields are declared with a compute method and optionally store=True to persist the result in the database for querying and sorting. The discipline that separates maintainable models from fragile ones is treating the model as the single source of truth: business rules, constraints (Python @api.constrains and SQL-level checks), and default logic all belong on the model class, not scattered across views or controllers. Get the model right and the UI largely writes itself.
Because the ORM is the contract between your code and the database, the developer reference is the authoritative source for every field option and model attribute (_name, _order, _rec_name, _inherit, _inherits). Reading the ORM reference before designing a non-trivial model is not optional homework — it is the difference between a model that ages well and one that fights you on every later change.
| Field type | Python type | Typical use |
|---|---|---|
| Char | str | Short text (name, code) |
| Text / Html | str | Long-form notes, rich content |
| Boolean | bool | Flags and toggles |
| Integer | int | Counts, quantities |
| Float / Monetary | float | Measurements, currency amounts |
| Date | datetime.date | Calendar dates |
| Datetime | datetime.datetime | Timestamps with time |
| Selection | str | Fixed list of choices |
| Many2one | recordset | Foreign key to one record |
| One2many | Sequence[Command] | Reverse list of related records |
| Many2many | Sequence[Command] | Many-to-many relation table |
Relating models to one another
Most business value lives in how models connect. A Many2one field is a foreign key — a property belongs to one owner, an order line belongs to one order — and it renders as a dropdown in the UI and an indexed column in the database. A One2many is the reverse direction: it is not a separate column but a computed view of all the child records that point back at the parent via their Many2one. A Many2many creates an implicit relation table so two models can reference each other freely, like tags applied to many records or skills shared across employees.
Every relational field names a comodel — the target model — and Odoo keeps both sides consistent automatically. This consistency is what makes relational design cheap to change early and expensive to change late: rename a Many2one and every dependent One2many, view, and domain filter follows. The practical guidance is to model relations the way your data actually works (one property genuinely has one owner; an order genuinely has many lines) rather than the way a single screen happens to display it, because views are easy to rebuild and data migrations are not.
Relational fields also carry domains — filters that restrict which records can be selected — and ondelete rules that define what happens to children when a parent is deleted (cascade, restrict, set null). Picking the right ondelete policy at field-definition time prevents the two most common relational bugs: orphaned child records and blocked deletes that mystify end users.
Inheritance: the three ways to extend Odoo
Inheritance is the heart of customizing Odoo without forking it, and the framework offers three distinct mechanisms, each with a different purpose. Classical inheritance creates a new model by copying and extending an existing one: you set both _name and _inherit, and Odoo produces a brand-new model that derives from the parent. Use it when you want a separate object that reuses another model's fields and behaviour without touching the original.
Extension (also called prototype or in-place inheritance) modifies an existing model directly. You set _inherit to an existing model's name and omit _name, and every field and method you declare is merged into that existing model in place. This is how you add a custom field to sale.order or override a method on res.partner without changing Odoo's source. Because multiple modules can extend the same model, Odoo composes them in dependency order — which is exactly why a correct depends list in the manifest is non-negotiable.
Delegation inheritance (_inherits, with an s) embeds one model inside another through a Many2one link: the wrapper model transparently exposes the embedded model's fields without copying data, keeping a single source of truth. The canonical example is res.users delegating to res.partner so a user inherits all contact fields. On top of these three, Odoo ships reusable mixins — abstract models you add through a single _inherit — the best-known being mail.thread, which grafts a full chatter (messages, followers, attachments) onto any model with one line and a <chatter/> element in the form view.
| Mechanism | Declaration | What it does |
|---|---|---|
| Classical | _name + _inherit | Creates a new model derived from a parent |
| Extension | _inherit (no _name) | Modifies an existing model in place |
| Delegation | _inherits (dict) | Embeds another model via Many2one, sharing its fields |
| Mixin | _inherit an AbstractModel | Reuses shared behaviour (e.g. mail.thread) |
Views: the XML that renders the UI
Odoo's user interface is data-driven and entirely declarative. Views are XML records of type ir.ui.view, and the developer reference documents twelve view types: Form, List (tree), Search, Kanban, QWeb, Graph, and Pivot in Community, plus Calendar and the Enterprise-only Cohort, Gantt, Grid, and Map. A form view lays out fields and notebooks for editing one record; a list view shows many records in columns; a kanban view groups records into drag-and-drop stages; a search view defines the filters, group-bys, and default ordering in the control panel.
Views are composable through inheritance just like models: a child view uses XPath expressions to inject, replace, or hide elements inside a parent view, so your module can add a tab to the standard partner form without rewriting the whole form. The model-to-view mapping is explicit — each model declares which views exist and in what order — and a window action (ir.actions.act_window) ties a model and its views to a menu entry. Menus, actions, views, and even reports are all loaded from the same kind of XML data files, wrapped in an <odoo> root element with <record> definitions.
The payoff of this architecture is that the entire UI is version-controllable text and upgrade-safe: your view changes live in your module's XML, survive a database restore, and are reviewed like any other code. The cost is that views are verbose and have their own inheritance semantics, so the view reference is required reading before you touch a production form. Reports follow the same pattern — a QWeb template rendered to PDF and registered as an ir.actions.report record.
Security: access rights and record rules
Security in Odoo is data-driven and has two layers, both of which link to users through groups (res.groups). The first layer is access rights, stored in ir.model.access.csv: they grant create, read, update, and delete (CRUD) permissions on an entire model and are additive across groups, so a user's effective permission on a model is the union of every group they belong to. If your custom model has no access rights row, nobody — not even an administrator viewing as a regular user — can read it, which is the single most common reason a freshly-built module shows 'AccessError' on first use.
The second layer is record rules, defined as ir.rule records: they apply row-level filters so the same model shows different records to different users. A record rule is a Python domain expression evaluated against the current record, the user context, and the variables uid, today, and now — for example, restricting salespeople to only their own opportunities, or limiting a multi-company user to records in their permitted companies. Record rules compose by group, so layering a regional rule on top of a team rule just narrows the visible set further.
A third, finer layer is field-level access: the groups attribute on a field hides it from users outside a group, and the optional groups attribute on a view element does the same for UI pieces. Together these three layers mean security is designed into the module from the first model, not bolted on before go-live. The discipline is to write the ir.model.access.csv the moment you create a model and to add record rules the moment a model holds data that should differ by user or company — because retrofitting security onto a model that already has production records is a data-classification project, not a configuration task.
| Layer | Artifact | Granularity |
|---|---|---|
| Access rights | ir.model.access.csv | CRUD on an entire model, additive across groups |
| Record rules | ir.rule (XML) | Row-level filters via Python domains |
| Field access | groups= on fields/views | Hide fields or UI elements per group |
Data files, sequences, and migration scripts
Because Odoo is greatly data-driven, a module ships not just code but records: menus, views, security rules, demo data, sequences, report templates, and default configuration are all declared as <record> elements inside an <odoo> block and listed in the manifest's data key in load order. Master data (a default tax, a starter set of stages) uses noupdate=True so it is seeded once and then left for users to edit, while demo data lives in the demo key and loads only when demo mode is on. Treating configuration as data — rather than one-off clicks in the UI — is what makes a module reproducible across databases.
When a model's schema changes, the ORM adds columns automatically on module update, but anything that cannot be inferred — renaming a field, splitting one model into two, transforming existing rows — requires a migration script. Odoo's upgrade reference specifies these as Python files containing a migrate(cr, version) function, placed at $module/migrations/$version/{pre,post,end}-*.py, where $version is higher than the currently installed version. Pre-scripts run before the new fields are applied, post-scripts run after, and they execute only during a module update, never on a fresh install.
Writing migration scripts is the unglamorous half of custom development and the half that determines whether your module survives a major-version upgrade. The rule of thumb is that every schema or data-shape change that affects existing records gets a migration script committed alongside it, in the same pull request, with a test. Modules that accumulate field renames without scripts become unmaintainable within two or three versions — which is precisely why the version-string discipline in the manifest exists.
Development workflow: scaffold, test, and ship
The day-to-day loop is driven from the command line with odoo-bin. You point Odoo at your modules with --addons-path, select a database with -d, install your module with -i <module>, and apply changes after editing with -u <module> (update). A configuration file (-c) persists these flags so you are not retyping them. The first install loads models, views, and data; subsequent updates re-run the loader, apply schema changes, and execute any pending migration scripts. Developers quickly learn to update the specific module they changed rather than the whole database, because a full re-init is slow and re-runs every module's data.
Tests live in the tests/ directory next to your models. Each test file name should start with test_ and be imported from tests/__init__.py — but you should not import the tests package from the module root __init__.py. Odoo builds on Python unittest: most server tests extend odoo.tests.common.TransactionCase, use setUpClass for shared records, and assert outcomes with helpers such as assertRecordValues. Run them with --test-enable, --test-file, or --test-tags (for example --test-tags=/my_module or post_install tags). Prefer tests that are independent of demo data, never call cr.commit in a test, and tag with @tagged('post_install', '-at_install') when behavior depends on a full module graph.
Debugging leans on the framework's transparency: the server logs the SQL it runs, the ORM exposes recordsets you can inspect in a shell (odoo-bin shell), and developer mode in the UI reveals the views, actions, and fields behind any screen. On Odoo.sh, development branches run unit tests by default on each push; staging and production do not run the suite (they lack demo data). The combination of a version-controlled module, a fast update cycle, and a real test suite is what turns Odoo development from guesswork into engineering — and the clearest signal that a customization has outgrown Studio and earned its own module.
Studio vs OCA vs App Store vs custom module
Before writing a module, the honest first question is whether one already exists — and if so, under which governance model. The Odoo Apps Store hosts thousands of third-party modules alongside Odoo's own apps, browseable by category and Odoo version. For common needs — a barcode variant, a payment acquirer, a country-specific report — a maintained store module is faster and cheaper than building from scratch. The store is also where Studio-exported modules and commercial verticals live.
The Odoo Community Association (OCA) is a separate open-source ecosystem: non-profit, AGPL-oriented modules under shared quality standards, with roughly 20,000 modules across more than 15 versions and annual collaboration events such as OCA Days 2026 (Liège, 21–23 September). OCA modules are free to use but not free to own: you take on Git submodule management, dependency graphs (queue_job, connector frameworks, localization stacks), and the annual port to the next major. Teams that standardize on OCA reuse for stock, accounting localization, and HR plumbing routinely cut custom scope — and still plan upgrade effort every major.
The trade-off is total cost of ownership, and the dominant cost is version compatibility. Odoo releases a major version roughly yearly, provides standard support for each major for three years (helpdesk, bug fixes, security updates), and on Odoo Online forces upgrades onto supported versions; active major branches in 2026 include 17.0, 18.0, and 19.0. Every module — bought, OCA, or built — must be re-validated against each target version, because ORM, view, and security APIs change between majors (Odoo 19 alone adds GROUPING SETS for pivots, dynamic dates in domains, and deprecates record._cr / record._uid / odoo.osv patterns still common in older custom code).
The decision rule that holds up in practice: Studio for field-and-view tweaks and simple automations; OCA when a maintained community module already solves a generic process; App Store when a vendor will maintain a commercial fit across versions; custom Python when the need encodes genuine differentiation, deep proprietary integration, or logic no vendor will own. The mistake to avoid is treating 'build' as free because you own the code — owning the code means owning every upgrade and every migration script.
| Need | Recommended approach | Why |
|---|---|---|
| Field, view, menu, simple automation | Odoo Studio (Enterprise no-code) | Fast iteration; exportable as a module later |
| Generic process already solved by community | OCA module (Git submodule) | Free, reviewed, shared upgrade culture — you still port majors |
| Generic, vendor-maintained (payment, vertical) | Buy from the App Store | Vendor owns cross-version maintenance for a fee |
| Genuine business logic / competitive flow | Custom Python module | Version control, tests, upgrade scripts under your control |
| Proprietary system integration | Custom Python module | No vendor or OCA repo will maintain your private API |
| One-off calculation, throwaway | Studio or server action | Avoid committing to a module you will not maintain |
Upgrade-safe customization checklist for Odoo 19
Custom modules fail upgrades for predictable reasons: core edits, brittle XPath, renamed standard fields, missing migration scripts, and Studio/UI-only changes that never entered Git. Odoo's own guide for upgrading a customized database treats a custom module as any extension of standard code that was not built with Studio, and it expects a freeze of new feature work while you port. The goals of every upgrade pass stay the same — stay supported, get features and performance, reduce technical debt, and pick up security fixes — but the work is source-code work, not a button click.
The non-negotiable anti-pattern is editing Odoo core (or enterprise) files in place. Core edits are overwritten on the next pull or platform rebuild and cannot be replayed by the upgrade platform. Extension inheritance (_inherit without _name), view inheritance via XPath in your module, and data in your module's XML/CSV are the supported surfaces. If a view breaks during upgrade, Odoo may disable it and report it; recovery should be an upgrade script or a fixed inherited view in your module — not a hand-edit in the production UI that never lands in Git.
A practical Odoo 19 port checklist, drawn from the official upgrade-how-to and the ORM changelog: (1) freeze feature development and challenge custom code against standard features added since your current version; (2) make each custom module install cleanly on an empty Odoo 19 database before touching production data; (3) fix dependency, assets, OWL/attrs, renamed models/fields, and broken XPath during empty-DB install; (4) run your tests and the standard tests of your depends; (5) only then run upgrade scripts on an upgraded dump — rename models/fields/xmlids, recompute stored fields, recover data from tables Odoo merged or removed (the classic sale.subscription → sale.order case); (6) re-enable or replace disabled views; (7) rehearse on a fresh upgraded dump the day before production. Community teams often lean on OCA OpenUpgrade tooling for self-hosted majors; Odoo Enterprise customers use the official upgrade platform (upgrade.odoo.com) integrated with Odoo.sh staging.
| Pattern | Upgrade-safe? | Notes |
|---|---|---|
| Extension inheritance (_inherit, no _name) | Yes | Composes in dependency order; survives source updates |
| View inheritance with XPath in your module | Yes | Re-test XPath targets each major; fix in Git |
| Migration scripts under migrations/<version>/ | Yes | Required for renames, splits, data transforms |
| Bump __manifest__ version on schema/UI change | Yes | Triggers -u / Odoo.sh production module update |
| Odoo Studio fields and simple views | Mostly | Export or document; complex Studio + code mixes hurt upgrades |
| Edit core Python or XML in odoo/addons | No | Lost on rebuild; blocks supported upgrades |
| UI-only config never written to XML/data | No | Cannot be merged via Git on Odoo.sh |
| Direct SQL that assumes old table names | No | Breaks when standard models merge or rename |
Odoo.sh branching: development, staging, production
If you host custom modules on Odoo.sh, the Branches view is the release train. The platform defines three stages — Development, Staging, and Production — and you change a branch's stage by drag-and-drop (with hard limits: only one production branch per project).
Development branches create databases from scratch with demo data and run the unit test suite by default on each push so regressions fail before anyone demos. Emails are intercepted by a mail catcher; scheduled actions stay quiet while the database is idle. Development databases are short-lived (on the order of three days) and are not backed up. Use them to prove install, tests, and module list — not to validate production data.
Staging branches create neutralized duplicates of production: scheduled actions, real outgoing email, IAP, and live payment/shipping connectors are disabled or forced into test mode so you can exercise custom modules against real volumes without spamming customers or charging cards. Unit tests are not run on staging (they expect demo data). Staging databases are deleted after about a month unless rebuilt; you can restore a production backup into staging for recovery drills. Configuration you discover in staging only becomes durable if you encode it as XML/data in the branch (and bump the module version) — merging staging into production merges source code, not ad-hoc database clicks.
Production is a single branch. Pushing a commit updates and restarts the production server. If the change needs a module update (for example a form view change), increase the module version in __manifest__.py so the platform runs an automatic update (equivalent to -u); failed updates roll the code and database back. Production does not load demo data and does not run unit tests (to limit downtime). Odoo.sh keeps automatic production backups (seven daily, four weekly, three monthly) including dump, filestore, logs, and sessions. The recommended merge path is development → staging (validate on production data) → production; merging development straight to production is allowed but riskier.
| Stage | Database source | Tests | Backups | Typical use |
|---|---|---|---|---|
| Development | Empty + demo data | Yes (default) | None | Code, install, CI feedback |
| Staging | Neutralized production copy | No | Manual only; DB ~1 month | UAT on real data |
| Production | Live production | No | Auto 7d/4w/3m | Customer-facing system |
Testing and CI expectations for production modules
A production custom module is unfinished until it has automated tests and a defined path into production. Odoo's unit-test tutorial frames tests as regression insurance, scope definition, living examples, and technical documentation — not optional polish. Minimum bar for any module that touches money, stock, or access rights: TransactionCase coverage for constraints, compute methods, and state machines; assertRaises for forbidden transitions; Form helpers when onchange behavior matters.
On Odoo.sh, treat development-branch green builds as the merge gate: keep Validate the test suite on new builds enabled, install your modules (and critical dependencies) via branch settings, and use test tags when the full suite is too slow. Staging is for human UAT and data-shape checks, not a substitute for unit tests. Production pushes should only carry commits that already passed development tests and staging rehearsal — especially when the commit bumps module versions or requirements.txt (those trigger automatic update backups on the platform).
For multi-version or multi-repo shops, add your own CI outside Odoo.sh if you develop against Community, on-premise, or heavy OCA stacks: run odoo-bin -i your_module --test-enable on a clean DB in GitHub Actions or equivalent, lint manifests, and block merges on red. When porting to Odoo 19, re-run both custom tests and standard tests of your depends; failing standard tests usually mean you changed a workflow the core still assumes. Clean code before you invent more tests: remove features that standard Odoo now covers, drop dead commented blocks, and refuse new work that cannot state its test.
Cost model: build vs buy vs OCA reuse
The license line on a store module or the zero price of an OCA repo is never the full cost. Realistic TCO for custom Odoo work has four buckets: (1) initial build or purchase/integration; (2) every major-version port (standard support is three years per major — plan at least one forced Online upgrade cycle inside that window); (3) ongoing bugfix and dependency maintenance (OCA submodules, payment acquirers, Python libs in requirements.txt); (4) opportunity cost when upgrade freezes block other projects.
Buy or OCA-reuse when the process is commodity and someone else will share the upgrade burden. Build when the module is a thin expression of how you win work — pricing engines, industry workflows, private WMS/TMS links — and you can staff the annual port. Hybrid is normal: OCA for stock helpers and connectors, App Store for a payment method, custom for the order orchestration that sits on top. Track module inventory the way you track technical debt: owner, Odoo version pin, last port date, test coverage, and whether production still needs it.
If you cannot name who will port the module to the next major and how it will be tested on Odoo.sh (or equivalent), do not start the build. Studio and configuration often deliver 80% of the value with a fraction of the upgrade surface. Custom module development pays off when the remaining 20% is the business — and when you treat the module as software under CI, not as a one-off script that happened to land in addons_path.
When a custom module is the right answer
A custom module is the right answer when a customization needs to behave like software rather than configuration. The signals are consistent across mature Odoo codebases: the logic involves multiple models in a single transaction (a custom fulfilment flow that touches sales, inventory, and accounting); it integrates with an external system (a proprietary warehouse, a telecoms API, a data warehouse); it carries constraints and computations that must be tested and reviewed; or it will be deployed to more than one database and must be reproducible. In each of these, a module gives you what Studio cannot — version control, code review, automated tests, and a clean upgrade path through migration scripts.
Governance is what keeps a growing set of modules healthy. Each module should declare its dependencies precisely, ship its ir.model.access.csv and record rules from day one, carry tests for its constraints and compute methods, version its manifest so upgrades are traceable, and never patch core. Modules that follow these rules survive major-version upgrades as a matter of routine; modules that do not become the technical debt that eventually blocks an entire database from upgrading — a pattern practitioners still hit when unowned custom code breaks on Monday after a quiet weekend deploy.
Finally, custom development does not mean going it alone. Prefer OCA and App Store building blocks for commodity problems, connect new models cleanly into the wider Odoo graph (payments, fulfilment, analytics, telephony), and plan the upgrade path before you ship. Design the model first, secure it as you build it, test it as you change it, and rehearse the major-version port the same way you rehearse go-live.
Frequently asked questions
Sources & methodology
16 citedEvery pricing figure and statistic on this page is traced to a primary or vendor source with a verification date. Where partner pages are cited, their platform bias is disclosed in-line.
- 01
- 02
- 03
- 04
- 05
- 06
- 07
- 08
- 09
- 10
- 11
- 12
- 13
- 14
- 15
- 16
Related services & solutions
Building a custom Odoo module the right way
Custom modules pay off when they are modeled well, secured from day one, tested under CI, and built to survive Odoo 19+ upgrades. Flectic designs, writes, tests, and maintains Odoo modules as version-controlled assets — and knows exactly when a need is better served by Studio, OCA, or the App Store. If you have logic that does not fit standard Odoo, we will scope it with you.