The short version
- Restoring a dump neutralises nothing. Neutralisation is a separate step, and it is off by default in every entry point Odoo ships
- Neutralisation is a per-module opt-in. For each installed module, Odoo looks for <module>/data/neutralize.sql and runs it as raw SQL. A module without that file contributes nothing, silently.
- Core’s coverage is good: mail servers, crons, webhooks, payment providers, bank sync, delivery carriers, IAP tokens and more:
- Your custom modules are not covered unless you wrote the file. Neither are most third-party and OCA modules.
- Automated actions of type code stay armed, and on-create/on-write triggers do not need a cron to fire.
- Run this before you trust anything: odoo neutralize -d staging_db
What neutralisation actually is
The whole mechanism is one short module. This is it, in substance:
def get_neutralization_queries(modules):
for module in modules:
filename = f'{module}/data/neutralize.sql'
with suppress(FileNotFoundError):
with file_open(filename) as file:
yield file.read().strip()
A module that has nothing to say and a module that was never asked produce identical output: nothing. No warning, no log line, no list of modules that contributed no queries. When the run finishes it logs "Neutralization finished" regardless of whether it neutralised forty modules or four.
That has three consequences, all of them direct readings of the code above.
It is opt-in, per module. Coverage is a list maintained by whoever wrote each module. Odoo maintains a good one for its own. Nobody maintains one for yours.
It only sees installed modules. State must be installed, to upgrade or to remove. Reasonable, and worth knowing.
It is raw SQL on a cursor, not the ORM. No Python, no create/write hooks, no computed fields recalculated. Whatever needs switching off has to be expressible as SQL against tables in that database.
One more detail that is not obvious from the code but follows from it: get_installed_modules runs a SELECT with no ORDER BY, so the order in which module files execute is unspecified. Never write a neutralize.sql that depends on another module’s having run first.
What core actually switches off
Base’s file is short enough to read in full, and it is the best evidence in this post:
-- deactivate mail servers
UPDATE ir_mail_server
SET active = false;
-- insert dummy mail server to prevent using fallback servers specified using command line
INSERT INTO ir_mail_server(name, smtp_port, smtp_host, smtp_encryption, active, smtp_authentication)
VALUES ('neutralization - disable emails', 1025, 'invalid', 'none', true, 'login');
-- deactivate crons
UPDATE ir_cron SET active = false
WHERE id NOT IN (
SELECT res_id FROM ir_model_data
WHERE model = 'ir.cron' AND name = 'autovacuum_job' AND module = 'base');
-- neutralization flag for the database
INSERT INTO ir_config_parameter (key, value)
VALUES ('database.is_neutralized', true)
ON CONFLICT (key) DO UPDATE SET value = true;
-- deactivate webhooks
UPDATE ir_act_server
SET webhook_url = 'neutralization - disable webhook'
WHERE state = 'webhook';
The dummy mail server deserves attention, because it is careful work. It is inserted active, pointing at host invalid on port 1025. It is there so that an SMTP server passed on the command line cannot be picked up as a fallback once the real servers are deactivated. Sends fail loudly instead of escaping quietly. That is the correct trade, and it is the reason a neutralised database is genuinely safe o, the mail path.
Note also the cron clause: only base.autovacuum_job survives. Everything else scheduled is deactivated.
There are 76 neutralize.sql files in Community, plus more in Enterprise. Enterprise’s are where the expensive integrations live. A sample, again read from source: account_online_synchronization sets account_online_link.client_id = 'duplicate', which is bank synchronisation; whatsapp replaces the account token with 'dummy_token'; social_facebook nulls the Facebook access token; delivery_ups_rest sets the UPS credentials to 'dummy'; voip puts the provider in demo mode; sale_amazon replaces the seller key and refresh token; l10n_be_hr_payroll replaces res_company.onss_expeditor_number, which is what Belgian Dimona filings are submitted under.
This is better coverage than most people assume, and it is worth saying so plainly. The problem is not that Odoo does this badly. The problem is that coverage is a list, and your list is longer than theirs.
The defaults are the sharp edge
Neutralisation is a distinct step from restoring, and it is off unless you turn it on.
So the database manager’s restore, the CLI’s restore, and the duplicate path all produce a fully armed production database unless somebody actively asked otherwise. And a plain pg_restore or createdb plus restore neutralises nothing at all, because it never touches Odoo’s code path in the first place. There is no version of “restoring a dump” that is safe by itself.
Two things follow. First, if you restore by hand, the neutralisation is your step to remember, every time, including the restore you did at 22:40 because something urgent came up. Second, if it fails, it says so:
"An error occurred during the neutralization. THE DATABASE IS NOT NEUTRALIZED!"
The CLI logs that and exits 1. Which also means the failure of any single module’s file aborts the whole run, including your own, if you ship one with a typo in it.
What it doesn’t cover
Automated actions stay armed, and this is the sharpest item on the list. addons/base_automation/ ships no data/neutralize.sql, its data/ directory contains only base_automation_data.xml and digest_data.xml. Base disables server actions where state = 'webhook', which covers exactly the webhook type and nothing else. A server action of type code, the kind that calls a partner’s API, posts to a Slack channel, notifies a fulfilment provider, pushes a record to a warehouse system, is untouched.
The time-based automations are, in effect, stopped, because their trigger runs off base_automation.ir_cron_data_base_automation_check, and the cron cull deactivates it. But on-create and on-write triggers do not need a cron. They fire the moment somebody edits a record. Which is the entire reason you built a staging database.
Custom modules are neutralised only if you wrote the file. Nothing generic covers them. If your module holds an API key, opens an outbound connection, or posts to a webhook, get_neutralization_queries looks for your_module/data/neutralize.sql, does not find it, and moves on without a word.
Third-party and OCA modules: the same. Some ship one. Most don’t. Check the ones that talk to the outside world, rather than assuming.
Personal data is untouched. Nothing in any neutralize.sql anonymises a customer name, an email address, a phone number, a salary or a bank account. More on that below.
The filestore is not part of this. Neutralisation is SQL against the database. Attachments arrive by a separate route and are unaffected by any of it.
The two-line fix
The good news is genuinely good: the fix for your own modules is a file, and once it is in the repository it applies on every future clone, forever, without anyone remembering anything.
Create your_module/data/neutralize.sql. It does not go in __manifest__.py, get_neutralization_queries builds the path from the module name and opens it directly, so the file existing at that path is the entire registration step.
What goes in it is raw SQL, executed on a cursor. That means:
- Multiple statements in one file are fine. Base’s has five.
- UPDATE, INSERT, DELETE and TRUNCATE are all in use in core. The plan-level folklore that neutralize.sql is UPDATE-only is wrong
- No Python, no ORM. No create/write overrides, no compute methods, no @api.constrains, no environment.
- Nothing outside the database. It cannot rotate a key at your provider, delete a file from the filestore, or unregister a webhook at the far end. It can only make this database stop being able to use them.
- It must not raise. A failure aborts the run for every module, not just yours.
A realistic file for a module with an outbound integration:
-- your_module/data/neutralize.sql
-- 1. point the connector at something that cannot resolve, and disable it
UPDATE your_module_connector
SET endpoint_url = 'https://neutralized.invalid',
api_key = 'neutralized',
active = false;
-- 2. clear the queue of anything waiting to be sent
DELETE FROM your_module_outbound_queue;
-- 3. disable the code server actions this module ships
UPDATE ir_act_server
SET code = 'pass # neutralized'
WHERE state = 'code'
AND id IN (SELECT res_id
FROM ir_model_data
WHERE model = 'ir.actions.server'
AND module = 'your_module');
Adapt the table and column names to your schema, and check them against the version you are running before you rely on the third statement. The pattern is what transfers: disable the switch, invalidate the credential, and drain anything already queued. Invalidating the credential matters as much as disabling the switch, because the switch is exactly what a developer flips back on to test something.
Test it by running odoo neutralize -d <copy> --stdout and confirming your file appears in the output. Then run it for real against a throwaway copy and check the rows.
Pre-flight: before anyone opens the staging database
Run this in order. It takes ten minutes and it is the same ten minutes every time.
- Confirm neutralisation actually ran. Check database.is_neutralized in ir_config_parameter. If it is absent, nothing ran, whatever anyone remembers doing.
- Look for the banner. web activates web.neutralize_banner; website activates website.neutralize_ribbon. A neutralised database should be visibly marked in the interface. If it isn’t, treat that as the answer.
- Read the SQL that ran. odoo neutralize -d <db> --stdout. Compare the modules represented in the output against your installed module list. The difference between those two lists is your exposure, and it is the only place in this procedure where you learn something you didn’t already suspect.
- Mail. All rows in ir_mail_server inactive except the dummy one on host invalid. mail_template.mail_server_id null. fetchmail_server inactive, so nothing is pulling mail in either.
- Crons. Only base.autovacuum_job active in ir_cron.
- Server actions. Every row in ir_act_server with state = 'webhook' should carry the neutralisation placeholder in webhook_url. Then list the ones with state = 'code' and read them, because base did not touch those. Pay attention to the ones bound to on-create and on-write triggers.
- Providers and carriers. payment_provider states, delivery_carrier rows, iap_account tokens, and, if you run Enterprise, bank synchronisation, WhatsApp, VoIP and the social connectors. All of these have core coverage when the module is installed. Confirm rather than assume, because they are only covered if the module that owns them was installed at the time.
- Your own estate. Walk the installed-module list for anything custom, OCA or paid that talks to the outside world, and check each one for a data/neutralize.sql. This is the list that grows every quarter and never shrinks.
- The filestore. It came across with real attachments in it: signed contracts, ID documents, payroll PDFs. Nothing in neutralisation applies to it.
- Access. Decide who can log into this database before you tell anyone it exists.
The part neutralisation cannot help with
Neutralisation stops the database reaching people. It does not stop it containing them.
Every customer name, email address, phone number, delivery address, invoice line, employee record, salary and bank detail in production is now also in staging, unchanged. That copy is a second full set of personal data, it sits inside your record of processing, and it falls under the same obligation to secure it as production does.
It usually gets less. Production has restricted access, an audit trail and a change process. Staging has a shared login, a broad developer group and a URL somebody pasted into a chat two months ago, because it’s only staging. The data does not know that.
Nothing here is a reason not to restore production data, realistic data is why the environment is useful, and this is not legal advice. It is a reason to give staging the same access control and the same retention discipline as production, and to delete the copies you stopped using.
What this actually is
Neutralisation is a good mechanism doing exactly what it says. The gap is between “neutralisation ran” and “everything dangerous is off”, and it stays open because both states look identical from the outside: same log line, same banner, same absence of errors. Nothing in your instance is going to tell you that the connector one of your developers wrote in 2023 was never on the list.
That is the difference between Odoo running and Odoo running properly, again. It is rarely dramatic. It is a file that was never created, and it stays that way until somebody goes looking.
Staging that arrives neutralised, every time
The reliable version of this is not a better checklist. It is not having to remember.
On Skysize Odoo hosting, odoo neutralize runs on the staging branch automatically on every path where fresh production data lands: a restore into an existing container, a restore into a new one, a blue-green restore, and a staging clone. It runs inside the container, against the database that just arrived, before you get to it.
It deliberately does not re-run on an ordinary code push. That is the detail worth more than any adjective: if you re-enabled a test payment provider or pointed a connector at a sandbox after the clone, the next commit does not undo your work. Neutralisation happens when production data arrives, and only then. Backups can also be taken pre-neutralised for download, neutralised through a throwaway copy so the source is never modified.
What it does not do, and we would rather say so: it does not write neutralize.sql for your custom modules. Core’s coverage is core’s coverage. That file is yours to ship, it takes an afternoon, and it is the single highest-value thing you can do with what you have just read.
Built by ex-Odoo engineers. Try for free today.