Skip to Content

Odoo development: from the estimate to production

September 18, 2026 by

A quote for Odoo development is usually one number: hours. It says nothing about what happens inside those hours, and that is where it gets decided whether the Odoo module still works after the next version bump.

So here is the order of work on my projects. Most of it is scar tissue from versions 15 through 19.

Before any Odoo module exists, two documents do

The first is the plan: what the system should do differently on the day the work is finished, written in the language of the business. "A warehouse worker scans a code and the line closes" is a plan. "Add a field to stock.move.line" is not — that is already an answer, and usually the wrong one. Nine times out of ten the scan needs a barcode nomenclature rule and an override on the existing picking, not a new field anywhere. The plan has to stay in the warehouse worker's language long enough for that to still be visible.

The second is the architecture, and this is where Odoo development stops resembling development in general. An Odoo module does not live alone. It sits inside someone else's code — one major release a year, a patch build most weeks, from a company that does not know you exist. So the architecture answers a narrower set of questions:

Which existing models are involved, and whether inheriting them is enough. What gets added: a field, a method, a view, a route. And what stays untouched. Which core behaviour gets overridden, and whether the override is even the right shape — _inherit extends a model in place, while _inherits quietly adds a foreign key and a delegated record, which is how a system ends up with an extra row per product and nobody remembering why.

Which edition the system runs on, because a plan built around an Enterprise model is not a plan on Community. How many companies, websites and languages one instance has to serve: forty websites in a single Odoo instance is a different architecture from one.

And the question that gets skipped most reliably: where the data comes from, and what the Odoo integration does at three in the morning when the other side stops answering. "Retries twice, then parks the order and emails me" is an architecture. Silence is also an architecture — just not one anybody chose.

You read the architecture before it is built

Once that page exists, you see it. The decisions, not the code: these three models get touched, this one does not, the Odoo integration runs through a job queue so a failed supplier call becomes a retryable record somebody can look at, and anything scheduled runs as an ir.cron with a lock, because a cron that assumes it never overlaps with itself will eventually be wrong.

The review takes about an hour, and it is the last cheap moment to change your mind. Change it after implementation and you pay for a week. Change it after go-live and you also pay for the fortnight the warehouse spent entering things twice.

It is also where I say the thing that costs me money: sometimes the honest answer is Studio, or a module from the app store that somebody else maintains, or nothing at all. Odoo already does it — badly, but adequately — and a custom module would add a year of maintenance to save ten minutes a month. A custom Odoo module is the most expensive of those options and it should win on merit.

Implementation: inherit the Odoo model, do not fork it

The rule is boring: extend, never copy. A forked core file works fine until the upgrade, and then it is yours forever.

There is one honest exception. Some core methods have no seam — nothing to call super() around, nothing to hook. When one has to be replaced wholesale, the copy carries a comment naming the source file, the branch and the version it came from, so the next upgrade has a diff to check instead of a mystery.

In practice: inheritance for models, xpath for views, QWeb for reports, overrides that still call the original. Fields added to a core model carry the module's prefix, so a core field of the same name arriving two versions later does not land on top of mine, and so the field name says which module put it there. The x_ prefix I leave alone — that one belongs to Studio.

Two traps worth naming, because both cost a deploy to find. View inheritance will not take an arbitrary attribute as a selector: an xpath on aria-label is refused outright. And hasclass(...) finds nothing when the parent template builds that class with t-attf-class, because there is no class attribute in the source at all. Both errors appear only when the module installs — which is to say, after the static checks were green. I read the raw XML of the template I am extending, never the rendered HTML, where the classes are already glued together and a broken selector looks correct.

One more thing that clients reasonably assume works the other way: uninstalling a module drops the columns it added, and the data in them. So uninstall gets tested on a copy, and if a field holds something the business will still want afterwards, that is decided before the field is written rather than on the day someone ticks the box.

Most of what makes an Odoo module survive a migration to the next version is decided here, in choices nobody sees from outside and everybody feels two years later.

Tests, and what AI actually does here

Every non-trivial change gets tests: a TransactionCase for the ORM logic, an HttpCase tour when the thing being changed is a screen, both tagged post_install so they run against the modules that will actually be installed next to mine.

I write them with AI, and that phrase has been stretched enough to be worth defining. It is fast at the mechanical half: assembling fixtures, covering the third and fourth branch of a condition, turning "check that a cancelled order releases the reservation" into runnable code. Tests used to be the first thing cut when an estimate got tight. They are not any more, and that is the whole practical difference — not that the tests got better, but that they now get written.

What it cannot do is decide what to test. It does not know that this particular integration fails when a supplier sends a comma as a decimal separator, because that fact does not live in the code — it lives in the last two years of that supplier's exports. Choosing which cases deserve a test is still my job, and I have not found a way to hand it over.

Two rules around it. A generated test has to fail before it passes: if I cannot break the code and watch it go red, it is testing nothing, and generated tests are very good at testing nothing convincingly. And fixtures are invented records, not an export of your customers.

Staging on real data, then production

A change goes to staging first, on a copy of the real database. Demo data is polite. It has no customers with two spaces in the name, no products imported years ago under rules nobody wrote down, no invoices in a currency that was discontinued. It also has no volume: a search that answers instantly on demo data took eight seconds on a hundred and ten thousand products.

A copy of your database is still your database, with your customers in it, so it gets neutralised before anyone works on it: outgoing mail off, scheduled jobs off, payment providers on test credentials. Otherwise the first re-run of a workflow on staging emails three hundred real people from a server that is not supposed to exist.

On staging the work is checked by the person who asked for it, on their own records. Then production, in a written order: which module gets updated, which migration script runs before the update and which after, and what happens to data that is already in the database. Published content is the usual surprise — a record loaded with noupdate is not re-imported no matter what you do, so text that is already live gets changed by writing to the record in a migration, or not at all. And a rollback I have actually run at least once. An assumed rollback is not a rollback.

The defects that only appear in production

Here is the part of Odoo development that estimates never mention. Some defects need a real cache and a real visitor before they exist at all, and a test database has neither. I have written separately about what holds and what breaks in a live Odoo system; these three are from one week on this very site.

The consent banner wrote the visitor's answer in the wrong format. "Only essential" stored proper JSON; "Accept" stored the string true, because a full consent restarts the page's interactions and the modal's close handler ran on a fresh instance still holding its default value. On the server, the check that reads that cookie expects a dictionary and deletes anything else — a compatibility branch doing exactly what it was written to do. So everyone who clicked "accept" was asked again on the next page, and the analytics behind it stayed at zero while looking perfectly installed.

The second one hides in a settings screen. Whether a tracker may load is decided by a method that begins with "if the cookie banner is switched off, consent is granted" — the reasoning being that an owner who turned the banner off built consent some other way. Untick that box and every visitor silently counts as consenting, for an hour at a time, because the rendered page is cached. Nothing in that settings screen looks dangerous.

The third: a conversion event was attached to a moment in the page lifecycle that had already passed by the time the script loaded, because the script ships in a deferred bundle that starts after the page is done loading. The form worked, the lead arrived, the number stayed at zero.

All three were found the same way — open the live site, click both buttons, read the cookie and the network tab instead of the code. That is fifteen minutes, and it is the only place those bugs exist. I bill it as part of the work.

What the next version costs

The article would be dishonest if it told you to ask about upgrades and then dodged the question. Upgrading the database is Odoo's job and they are good at it. Upgrading your custom code is mine, and the price depends on one thing: how much of the module sits on top of core behaviour that moved.

A module that adds its own model, some fields and a few views usually survives a major version with an afternoon of fixes. A module that overrides create, patches a report and inherits four core templates is a week. Worth knowing before you own it: create is @api.model_create_multi and receives a list of records, not one, so an override written for a single set of values does not fail loudly — it quietly handles the first row of a batch import and ignores the rest.

And upgrades go version by version. Odoo skips nothing, so custom code is carried across each step rather than once at the end.

Rights are part of the feature

Every model I add ships with its access rules, and with a record rule when the data is not for everyone — written at the same time as the model. Rights retrofitted onto a finished module is how a salesperson ends up reading another region's margins.

The thing I look hardest for in anyone's code, including my own, is sudo(). It switches off every rule at once, it is the fastest way to make a permissions bug disappear, and in a public controller it is a data leak with a friendly URL.

Polish after the launch

The module is finished about two weeks after go-live, when the list of small frictions comes back: this field should default to something, this screen needs one fewer click, this report is read every morning at eight, so it should be a menu item and not a filter that somebody re-applies by hand and gets wrong on Mondays.

That pass is short. It is also the difference between a module that passes its tests and one that people stop routing around. Skip it and you get a technically correct feature with a spreadsheet next to it doing the real job.

The one question worth asking

You will not remember any of this when you are sitting across from a developer. So here is the question I would ask in your place:

What happens to this module when Odoo releases the next version?

If the answer names a specific risk — this override sits on a method Odoo touches often, so it gets re-checked at every upgrade — the person has done an Odoo migration before. "It will be fine" can also be the true answer, for a module that adds two fields and a list view. What you are listening for is whether they can tell you which one yours is.

That question works on anyone you are considering, and the answer tells you more than a badge does — which is most of the difference between an Odoo partner and an independent developer.

If you have a system that runs and a change you are not sure how to make, write to me. The first conversation is about what should be different when the work is done, not about hours.

Odoo partner or independent developer: what the difference actually is
What the Ready, Silver and Gold levels are counted from, and who gets paid when you buy more seats.