Skip to Content

How Do You Know If an Odoo Backup Will Actually Restore?

How Do You Know If an Odoo Backup Will Actually Restore?
Benjamin Akboka Apengu
August 24, 2026 · 13 min read


Nearly everyone running Odoo has backups. Almost nobody has a restore.

Those are two different things, and the gap between them is where the bad days come from. A backup job that will give you a working system and one that will give you nothing look exactly the same from outside: same schedule, same green tick, same file landing in the same bucket every night. Nothing anywhere tells you which one you have.

This isn't a guide to setting up Odoo backups. You've done that, and there are plenty of those already. This is the other half: how to prove the thing you've been collecting every night for the last year will actually come back, on a normal Tuesday, in about an hour.

Short answer

You cannot know whether an Odoo backup will restore until you have restored it. There is no property of the backup file, the job log or the storage bucket that tells you. A backup that will produce a working system and one that will produce nothing look identical from outside.

To find out, restore it into an isolated database with `psql -v ON_ERROR_STOP=1` (without that flag a failed restore still exits `0`), confirm the backup's `filestore/` contains every file the restored `ir_attachment` table references, then compare record counts and one financial total against production. Run it quarterly, and record how long it took. That number is your real recovery time.

The full procedure is below, and it takes about an hour the first time.

Two checks to run right now

1. Does your backup contain the files, or only the database?

unzip -l backup.zip | grep -c 'filestore/'

Zero means you have a database backup, not an Odoo backup.

Attachments, uploaded documents, product images, generated PDFs and email attachments are not stored in PostgreSQL by default. The database holds an `ir_attachment` row whose `store_fname` points at a path, and the file itself lives in the filestore directory on disk. Restore the database alone and every record comes back with its documents hollowed out.

That can still be a deliberate choice. Some teams keep attachments in object storage and back them up separately. It should be a choice you made, not one you discover.

2. Are attachments already broken in production?

This is the one that surprises people. Run it against your live database, before any backup question:

DB=odoo_prod
FS=/var/lib/odoo/filestore/$DB

psql -d "$DB" -tAc \
"SELECT store_fname FROM ir_attachment WHERE store_fname IS NOT NULL" \
| LC_ALL=C sort -u > /tmp/db_files.txt
( cd "$FS" && find . -type f -printf '%P\n' ) | LC_ALL=C sort > /tmp/fs_files.txt

echo "attachments whose file is missing: $(comm -23 /tmp/db_files.txt /tmp/fs_files.txt | wc -l)"
echo "orphan files with no attachment row: $(comm -13 /tmp/db_files.txt /tmp/fs_files.txt | wc -l)"

The first number should be zero.

If it isn't, those documents are already gone, and they've been gone a while, because Odoo doesn't announce it. A missing filestore file is caught, logged at INFO level, and comes back as an empty byte string. The attachment keeps its original filename and its recorded size, and downloads as nothing.

This is the reason the whole procedure below exists: checking a restore by opening it and clicking around will pass with every attachment broken. The records, the filenames and the file sizes all look exactly as they should.

The bar: what "tested" actually means

The usual rule of thumb for backups is 3-2-1-1-0: three copies of the data, on two kinds of media, one off-site, one offline or immutable, and zero errors on a verified restore.

Most Odoo setups do reasonably well on the first four numbers and skip the last one entirely. Which is a shame, because the zero is the only one that proves the rest were worth anything. Copies you can't restore are just storage.

There are two more numbers you should be able to say out loud, and only a real restore will give you either one:

  • RPO: how much data you'd lose. Not "we back up nightly at 2am". If the failure lands at 4pm you lose fourteen hours of invoicing. Is that acceptable? That's a business question with a real answer, and the answer is usually different from the schedule someone set once and never looked at again.
  • RTO: how long you'd be down. This is the one that gets guessed, and the guess is generous. A restore test turns it into a measurement: download, restore, filestore copy, module load, checks. On a large database the real number often lands hours away from the one people assume, and it's much better to learn that today.

Everything below is about getting to that zero.

How to test an Odoo backup, step by step

Before you start, get an isolated target: a staging branch, a spare server, or a laptop with Docker. Not production, and nothing sharing a network path with production's mail server, payment providers or bank sync.

Step 1: Fetch the backup from where it actually lives

Download it from the storage bucket, with credentials that are not production's, onto a machine that is not the production server.

A backup you can only retrieve *using* the production server isn't a backup. It's a second copy on the thing you're protecting against. This step is the test of that, and it's the one people skip because it feels like admin rather than engineering.

Step 2: Prove the file is intact before you use it

unzip -t backup.zip && unzip -l backup.zip | tail -3

`unzip -t` verifies the archive's internal checksums. This catches truncated downloads and half-finished uploads, which matter more than they sound like they should, because a truncated `dump.sql` restores quietly up to the point where it was cut.

Step 3: Read the manifest and compare it to the target

Every Odoo zip dump carries a `manifest.json`, written by `dump_db_manifest` (odoo/service/db.py):

unzip -p backup.zip manifest.json | python3 -m json.tool | head -20

It records the Odoo version, the PostgreSQL major version, and every installed module with its version. Between them they tell you exactly what this dump needs before it can mean anything.

Check three things:

  • version against the target's Odoo version. Same version is a restore. A different one is an upgrade, and upgrades belong in a rehearsal, not in an incident.
  • pg_version against the target's PostgreSQL. Older-to-newer is routine. Newer-to-older is where restores start losing statements.
  • modules against what's installed on the target, including third-party, OCA and your own.

Worth knowing: `restore_db` extracts `dump.sql` and the `filestore/` members and nothing else. The manifest is written on every backup and read by nothing in the codebase. It exists for you.

Step 4: Restore the SQL yourself, with errors switched on

This is the step that separates a test from a hope. Don't use the database manager for it:

createdb restore_test_2026_08
time unzip -p backup.zip dump.sql \
| psql -v ON_ERROR_STOP=1 --dbname=restore_test_2026_08
echo "exit: $?"

-v ON_ERROR_STOP=1 is the entire point of this step. Without that flag, `psql` skips statements that fail, carries on to the end of the file, and exits `0`, so a partial database reports as a successful restore. With it, the restore stops at the first error and exits `3`, and you get the error text: the missing extension, the absent role, the version gap.

Exit `0` now means what you always assumed it meant.

`time` gives you the largest single component of your real RTO. Write it down. You'll want it in step 8.

Step 5: Check the filestore is complete before you copy it

Now that the database is restored, compare what it expects against what the backup actually contains:

unzip -Z1 backup.zip | sed -n 's|^filestore/||p' \
| grep -v '/$' | grep -v '^$' | LC_ALL=C sort > /tmp/zip_files.txt
psql -d restore_test_2026_08 -tAc \
"SELECT store_fname FROM ir_attachment WHERE store_fname IS NOT NULL" \
| LC_ALL=C sort -u > /tmp/db_files.txt

echo "attachments with no file in this backup: $(comm -23 /tmp/db_files.txt /tmp/zip_files.txt | wc -l)"

Zero is the pass. Any other number is telling you exactly how many documents this backup cannot give you back.

Both `LC_ALL=C` and the `grep -v '^$'` matter: sort order has to match on both sides, and `unzip -Z1` emits the bare `filestore/` directory entry, which becomes an empty line and quietly poisons the comparison.

Then copy the files into place as `filestore/restore_test_2026_08` in the target's data directory, and set ownership so Odoo can read and write them.

Step 6: Neutralise before anything opens it

Restored production data arrives with its outbound mail servers, scheduled actions, payment providers and connectors intact. Neutralisation is a separate, opt-in step. It does not happen because you restored.

Run it before Odoo starts, and start Odoo with copy semantics so the restored database gets its own identity rather than production's. What neutralisation covers, what it leaves armed, and why the identity part matters has its own guide *(link the staging post here on publish)*. Read it before running this procedure against real data.

Step 7: Check the data against numbers you already know

No exit code can do this part.

Before you start, run a fingerprint on production and save the output:

SELECT 'account_move'      AS t, count(*), max(id), max(write_date) FROM account_move
UNION ALL SELECT 'account_move_line', count(*), max(id), max(write_date) FROM account_move_line
UNION ALL SELECT 'sale_order', count(*), max(id), max(write_date) FROM sale_order
UNION ALL SELECT 'stock_move', count(*), max(id), max(write_date) FROM stock_move
UNION ALL SELECT 'res_partner', count(*), max(id), max(write_date) FROM res_partner;

Run the same query against the restore and diff the two. Row counts catch missing data; `max(id)` catches a restore that stopped early; `max(write_date)` catches a backup that is older than you think it is.

Add the tables that matter to *your* business. If you live in manufacturing orders or subscriptions, put them in the list.

Then, in the interface:

  • One financial total you can verify. Current-year revenue, or a bank balance. One number that's either right or wrong.
  • Open three attachments from different months, including one from this week, and check the downloaded file size, not just that something downloaded. This is the only place the empty-file behaviour shows up.
  • Load a screen from each custom module. A module missing on the target leaves its tables restored and its data unreachable, and the interface is where you see it.

Step 8: Record it, then destroy the test database

Write down four things: the date, which backup you used, the total elapsed time, and what failed.

The elapsed time is your RTO: measured, not guessed. Four of these records and you have a trend, which is the moment this stops being a chore and becomes evidence. It's also the number to bring when somebody asks whether the hosting is adequate, because it answers the question they're actually asking.

Then drop the test database and remove the filestore copy.

What a pass looks like

  • `unzip -t` clean
  • Manifest matches the target on Odoo version, PostgreSQL version and module list
  • `psql -v ON_ERROR_STOP=1` exits `0`
  • Zero attachments missing from the backup
  • Fingerprint matches production on counts, max id and max write date
  • Three attachments open at the right file size
  • Every custom module's screen loads
  • Elapsed time recorded

Anything short of that is a finding, and a finding today is worth a great deal more than the same finding during an outage.

How often, and who

Quarterly is the floor. Also run it after anything that changes the shape of the system: a hosting change, a PostgreSQL upgrade, an Odoo version upgrade, a new module going live, or a change to where backups are stored.

One named person owns it. Not "the team". Backup testing sounds like something anyone could do, which is why it usually ends up being done by nobody. It takes an hour a quarter. Put it in a calendar with a name on it.

Keep the records together, in whatever your company already uses. Four dated entries with elapsed times is a disaster-recovery position. Zero entries is a hope, however good the backup configuration is.

What to automate, and what stays human

Automate the objective, boring parts: a restore that stops on error, an archive integrity check, a filestore completeness comparison, a size and checksum record for every backup, and an alert when a backup comes in more than a few percent smaller than the previous one. None of that needs judgement, and all of it is scriptable.

What doesn't automate is step 7. "Complete" is defined by numbers only your business knows: last month's revenue, the invoice count, whether the document your accountant needs opens. No platform can check that for you, and any platform that says it fully verifies your restores is using the word to mean something smaller.

So: automation should make a failed restore impossible to miss, and a human still checks that the contents are right. The second part is half an hour a quarter.

What this actually is

Backups aren't neglected. They run, they go green, and almost everyone has them. The gap is between a backup being *taken* and a backup being *restorable*, and it stays open because both states look identical from outside. A backup that will never restore looks exactly like one that will, right up to the day you need it. Which is the worst possible day to find out.

That's the difference between Odoo running and Odoo running properly, in the most expensive form it takes. Nothing dramatic. Just a file nobody ever opened, for a year.

And it costs you something every day before that. Nobody quite trusts the backups, so nobody quite trusts making changes either. Upgrades get put off, module updates get put off, and the system drifts further from anything you'd want to restore anyway.

Restores that are tested because they happen

The reliable version of this isn't a better checklist. It's a restore path that runs often enough to have been proven, and that fails loudly when it fails.

On Skysize Odoo hosting, the restore runs `psql -q -v ON_ERROR_STOP=1`, the same flag as step 4, so a statement-level failure aborts the restore and fails the job instead of producing a partial database with a clean exit code. The lines that would otherwise abort a legitimate restore (newer-only server settings from a more recent `pg_dump`, provider-managed extension DDL) are stripped deliberately, and the number removed is written into the job log. Removed on purpose and counted, rather than skipped and discarded.

Restores are blue-green: a new database and a new container come up on a new port while the previous deployment keeps serving, and the new one has to start and pass a health check before anything cuts over to it. Only then is the old deployment dropped. If any step fails, the half-built deployment is torn down and the old container is never touched. You're exactly where you were, still serving. And staging branches are neutralised automatically when production data lands, which makes restoring into staging to *check* a backup an ordinary afternoon rather than a risk.

What none of that does, and we'd rather say so: it doesn't check that your invoice totals are right. Our health check proves the restored deployment boots and answers. It cannot prove last Thursday's invoices are in there. Step 7 stays yours, on any platform.

Built by ex-Odoo engineers, ISO 27001 certified. Talk to an Odoo hosting expert about what your recovery actually looks like.

If you'd rather run it on your own machine, in your own country, Skysize BYOS puts the same platform on your server. Connect your server.

How Do You Know If an Odoo Backup Will Actually Restore?
Benjamin Akboka Apengu
Writes about Odoo infrastructure at Skysize, a managed Odoo hosting provider based in Belgium serving businesses and agencies worldwide.