</>CodeWithKarani

What bench migrate actually does, and the rollback you'll wish you had

Karani GeoffreyKarani Geoffrey8 min read

The message you do not want, on a client's system, at 16:40 on a Friday:

Executing your_app.patches.v1_4.backfill_supplier_ledger in dev.client.local (_a1b2c3d4e5f6a7b8)
Traceback (most recent call last):
  ...
pymysql.err.OperationalError: (1054, "Unknown column 'supplier_group' in 'field list'")

Twelve patches were due. Six ran. The seventh died. The instinct is to fix the patch and run bench migrate again, and quite often that works. But it is worth understanding why it sometimes does not, because the reason is structural and it is the single most important thing to know before you automate a deployment: a failed migration is not a migration that did not happen. It is a migration that half happened, and there is no command that undoes it.

This is the one irreversible step in the pipeline that this series builds towards. If you are heading for a deployment to a client server you cannot log in to, then this is the step you cannot fix by hand afterwards, which means the protection has to be in the script.

each patch commits as it finishes and writes a row into the Patch Log, so a mid-migration failure leaves earlier patches permanently applied. Back up with files before every migrate, and know the restore command before you need it.

  • Order is: before_migrate hooks, pre-model-sync patches, schema sync from the DocType JSON, post-model-sync patches, fixtures, after_migrate hooks.
  • A patch that has completed is recorded and will never run again. Editing it afterwards changes nothing.
  • MariaDB does not roll back DDL. An ALTER TABLE inside a patch survives the rollback of everything around it.
  • bench backup --with-files before, set-maintenance-mode on before, and something that turns it off again when the migration fails.

What it does, in order

Under the surface, bench --site dev.client.local migrate constructs a site migration and runs it in a fixed sequence.

The bench migrate timeline, with the point of no return marked A vertical timeline of six stages. The first stage runs the before_migrate hooks. A marked boundary follows it: from that line onward the database is being changed and every patch commits as it completes. Below the line are pre-model-sync patches, schema synchronisation from the DocType JSON files, post-model-sync patches, then fixtures, scheduled jobs, dashboards and customisations, and finally the after_migrate hooks with caches cleared and the search index queued. before_migrate hooks Your app's own code. Keep it read-only and you can still walk away here. Point of no return. Below this line every completed patch is committed and recorded. Pre-model-sync patches Run in patches.txt order, for every installed app. Each one commits on success. Schema sync from the DocType JSON Columns added and altered to match your DocType definitions. This is DDL. Post-model-sync patches Data backfills that need the new columns to exist. All DocTypes are reloaded first. Fixtures, scheduled jobs, dashboards, customisations Runs after the patches, which is why a patch cannot rely on a fixture it needs. after_migrate hooks, caches cleared, search index queued
Only the first and last stages are yours. Everything between them is the framework changing the database on your behalf.

One ordering detail catches people repeatedly: fixtures are synchronised after the patches. If you have a post-model-sync patch that expects a custom field defined in a fixture, that field does not exist yet on the first migrate after you add it. The patch fails, or worse, quietly writes nothing.

patches.txt, and why a patch never runs twice

Your app declares its patches in your_app/patches.txt, an INI-shaped file with two sections:

[pre_model_sync]
your_app.patches.v1_0.rename_customer_ref_field

[post_model_sync]
your_app.patches.v1_4.backfill_supplier_ledger

They run top to bottom within each section. What decides whether a given patch runs at all is a single lookup against a DocType called Patch Log.

From patches.txt to the Patch Log, and why a patch will not re-run On the left, the contents of a patches.txt file with a pre-model-sync section and a post-model-sync section. On the right, a four step flow: patches are read in file order with the pre-model-sync section first, each is checked against the Patch Log for a row with skipped set to zero, a patch that is already logged is skipped silently and permanently, and a patch that is not logged is executed, then a Patch Log row is inserted, then the transaction is committed. A closing note explains that because the commit happens per patch, a later failure cannot undo an earlier success. your_app/patches.txt [pre_model_sync] v1_0.rename_customer_ref v1_2.drop_legacy_index [post_model_sync] v1_3.set_default_branch v1_4.backfill_ledger Order is the file's order Not alphabetical, not by version number. Moving a line moves when it runs, for every site that has not yet run it. 1. Read every installed app's patches, in order pre-model-sync section first, post-model-sync after the schema sync 2. Is there a Patch Log row for it with skipped = 0? One lookup. That is the entire test. 3. Yes: skipped, with no output at all Editing the patch file afterwards changes nothing. It is done, forever. 4. No: execute it, insert the Patch Log row, commit The commit is per patch. By the time patch seven fails, patches one to six are already durable in the database and recorded as applied. There is no transaction wrapping the migration. There are twelve small transactions, and six of them succeeded.
Step 4 is the whole story. Per-patch commits are what make a partial migration permanent.

You can read the record directly:

bench --site dev.client.local mariadb
SELECT patch, skipped, creation
FROM `tabPatch Log`
ORDER BY creation DESC
LIMIT 10;

That table is the source of truth about what has run on this site. It is also the reason for a rule I now treat as absolute: never edit a patch that has shipped. On your machine it will re-run because your Patch Log has no row for it. On the client's site it will not, because theirs does. You will then be debugging a difference that exists only in a table nobody thought to look at.

Why re-running does not undo anything

Two mechanics combine here, and either alone would be survivable.

The first is the per-patch commit above. When a patch throws, Frappe rolls back and re-raises, but the rollback only covers the transaction that was open - the current patch. Everything before it is committed.

The second is that MariaDB does not roll back DDL. If your patch ran an ALTER TABLE and then failed three lines later on a data update, the rollback reverts the data update and leaves the column exactly where the ALTER put it. The schema has moved and the data has not. That is the state that "no amount of re-running fixes", because on the second attempt the patch tries to add a column that now exists and dies differently.

So the honest summary is: re-running bench migrate resumes, it does not retry. That is genuinely useful when the failure was environmental - a missing Python package, a worker holding a lock - and it is no help at all when the failure left the schema somewhere the patch does not expect.

--skip-failing does less than the name suggests

The temptation, mid-incident, is bench --site dev.client.local migrate --skip-failing. Know what it does before you reach for it. It catches the exception, writes a Patch Log row with skipped = 1 and the full traceback, and continues to the next patch.

Because the executed-patches lookup only counts rows where skipped = 0, that patch is not permanently skipped. It is retried on the next migrate. So the flag is really "keep going and keep the traceback", which is a reasonable thing to want and a bad thing to leave in a deployment script. Use it interactively when you are diagnosing. Never put it in automation, because a deploy that reports success while quietly deferring a data backfill is worse than one that fails loudly.

The discipline before the command

Three things, in this order, every time. None of them is optional and all of them belong in the script rather than in your memory.

cd /home/frappe/frappe-bench

# 1. Refuse to start if background jobs are still in flight
bench --site dev.client.local ready-for-migration

# 2. Close the site to users
bench --site dev.client.local set-maintenance-mode on

# 3. Back up the database AND the files, together
bench --site dev.client.local backup --with-files

The first command is underused. It checks for pending background jobs three times, a second apart, and exits non-zero if any are queued. A migration that starts while a worker is midway through writing invoices is a genuinely bad day, and this is a one-line guard against it.

The second matters because bench migrate does not do it for you. Frappe sets an internal flag during migration, but nothing stops a user saving a Sales Order while the schema is being altered underneath them.

The third produces four files, named by timestamp and site:

ls -1t sites/dev.client.local/private/backups | head -4
20260727_083000-dev_client_local-private-files.tar
20260727_083000-dev_client_local-files.tar
20260727_083000-dev_client_local-database.sql.gz
20260727_083000-dev_client_local-site_config_backup.json

--with-files is the part people skip, and it is the part that makes the backup usable. Frappe keeps uploaded documents on disk, not in the database. A database-only restore gives you rows pointing at attachments that are no longer where the rows say they are, and you will not notice until a user opens an invoice next week.

The actual recovery

The restore path from a failed migration A flow starting from bench migrate exiting non-zero at patch seven of twelve. The next box records that patches one to six are committed and logged, patch seven is partly applied, and that DDL cannot be rolled back. The path then branches. Without a pre-migrate backup, the only option is manual repair of a database whose exact state is unknown. With a backup taken before the migration, the recovery is a restore of the database together with the public and private files, followed by checking the app back out at the previous tag, lifting maintenance mode and restarting the processes. bench migrate exits non-zero at patch 7 of 12 Patches 1 to 6 are committed and logged. Patch 7 is partly applied. The site is in maintenance mode and the schema no longer matches either the old code or the new code. No pre-migrate backup Manual surgery on a database whose exact state nobody can describe, against a live business, with the client watching. This is the whole article. Backup taken with files, minutes ago Restore it. The half-applied schema, the six committed patches and their Patch Log rows all disappear together, because they were all in the database you just replaced. bench --site ... restore <db>.sql.gz --with-public-files ... --with-private-files ... git checkout v1.3.0 && supervisorctl restart Then lift maintenance mode.
The right-hand column exists only if you ran one command before you started. That is the entire argument for automating the backup.

The restore itself, in full. Run it as frappe, from the bench directory:

cd /home/frappe/frappe-bench
B=sites/dev.client.local/private/backups/20260727_083000-dev_client_local

bench --site dev.client.local restore "${B}-database.sql.gz" \
  --with-public-files "${B}-files.tar" \
  --with-private-files "${B}-private-files.tar"

Then put the code back where the data expects it, and reopen the site:

cd apps/your_app
git checkout --force v1.3.0
cd /home/frappe/frappe-bench
bench build --app your_app
bench --site dev.client.local clear-cache
bench --site dev.client.local set-maintenance-mode off
sudo supervisorctl restart frappe-bench-web: frappe-bench-workers:

Four practical notes on that restore, all learned by being surprised.

It drops and recreates the database, so it needs database root credentials. If root_password is not in common_site_config.json, it prompts, and a prompt in an unattended script is a hang rather than an error. Pass --db-root-password explicitly, or accept that restore is a human step. I have deliberately kept it a human step.

If the backup was taken by a newer version of the app than the code currently checked out, restore detects the downgrade and asks for confirmation, aborting unless you pass --force. That is a good safety net and another reason this does not belong inside automation.

Paths are resolved relative to the site's backup directory and the bench's parent as well as the current directory, so passing just the filename usually works. Passing the full relative path always works, which is what a script should do.

And if backups are encrypted, restore reads the key from the site config automatically, or takes --encryption-key. If the site config is the thing you lost, that key is what stands between you and an unreadable backup. Store it somewhere that is not the server.

What this costs

Taking a backup with files before every migration is not free. On a site with several years of scanned delivery notes, the tar is large and the backup adds minutes to every deploy, on a disk that has to have room for it. Teams notice this and start skipping it for "small" changes, which is exactly when it bites, because small changes are the ones nobody reads carefully.

It is also worth being honest that a nightly bench backup is a recovery point, not a recovery strategy. If losing a day of transactions is unacceptable to this client, the answer is binlogs, and why bench backup is not a backup strategy covers point-in-time recovery for ERPNext properly.

The compromise I have settled on: always take it, keep the last three, and delete older ones on a schedule the client controls rather than from the deploy script. A deploy script that deletes backups is one bad glob away from being the incident.

The other cost is honesty with the client. A deployment that includes a migration is not zero downtime, and saying so up front is much better than discovering it together. Maintenance mode is on for the duration, and on a real ERPNext site with a few hundred thousand rows that is usually seconds to a couple of minutes, not hours.

All of this assumes you know which directory the migration is even running in. If apps/, sites/ and env/ are not yet a clear picture, the anatomy of a Frappe bench is the prerequisite. And the deploy script that wraps every one of these commands, including the trap that lifts maintenance mode when the migration fails, is in the full no-access deployment walkthrough.

Frequently asked questions

Can I re-run bench migrate after it fails?
You can, and it will skip every patch that already completed, because those were recorded in the Patch Log and committed. It resumes from the one that failed. That is helpful if the failure was environmental, such as a missing Python package, and useless if the failed patch left the schema in a state the patch itself cannot handle on a second attempt.
Does bench migrate put the site in maintenance mode by itself?
No. Frappe sets an internal in_migrate flag, but nothing stops users writing to the site while patches run. If you want the site protected during a migration, you have to run bench --site dev.client.local set-maintenance-mode on yourself, and make sure something turns it off again even when the migration fails.
What does --skip-failing actually skip?
It catches the exception, records the patch in the Patch Log with skipped set to 1, stores the traceback, and moves on. Because the executed-patches lookup only counts rows where skipped is 0, that patch is retried on the next migrate. So it does not permanently skip anything, it defers it and keeps the traceback for you.
Is a database backup on its own enough to roll back?
Only if nothing outside the database changed. Frappe stores uploaded documents on disk under the site's public and private directories, so a database-only restore gives you rows referencing files that are no longer where the rows say they are. Take the backup with files, every time, and restore both together.
#Frappe#ERPNext#Bench#Deployment#MariaDB
Keep reading

Related articles