My job on a client's production WordPress site at the end of August was clear on paper: update the outdated plugins, themes and core, then remove the plugins and themes that were not in use. The server had WP-CLI 2.12.0, so the whole job ran over SSH as one-liners. The order was simple as well. Backups came first, and every backup was verified readable before anything was deleted.
At the database backup step, wp db export with the --single-transaction option failed. What made it dangerous was not the failure but what it left behind: an 840-byte stub that still looked like a dump file. A check that only asks whether the backup file exists will answer yes, and a backup like that only shows what is inside it at the worst possible moment, when something actually needs restoring.
A global lock that needs RELOAD
The recorded cause: the database user on that server had no RELOAD grant, so FLUSH TABLES WITH READ LOCK was denied. That statement, usually shortened to FTWRL, closes all open tables and locks every table in every database with a global read lock. RELOAD itself is one of MySQL's administrative privileges, which apply to the whole server rather than to one particular database.
At first glance this sounds backwards, because --single-transaction exists precisely so a dump does not have to lock anything. According to the mysqldump documentation, the option sets the isolation level to REPEATABLE READ and starts a transaction before reading any data, so InnoDB tables are read in a consistent state without blocking the application. The exception is written down in the MySQL 8.0 documentation too: as of 8.0.32, mysqldump with --single-transaction needs the RELOAD or FLUSH_TABLES privilege when gtid_mode=ON and --set-gtid-purged is ON or AUTO, and AUTO is the default. MySQL's public bug reports show that in the 8.0.32 and 5.7.41 releases that requirement briefly applied regardless of GTID, until it was narrowed again in 8.0.33 and 5.7.42. Users who were hit by it reported an error starting with Couldn't execute 'FLUSH TABLES WITH READ LOCK': Access denied.
My notes do not record the mysqldump version on that server or the exact error text, so I am not going to guess which of these paths applied there. If you run into the same symptom, mysqldump --version is the first thing worth checking.
The mysqldump source code also gives a picture of why a failure like this still leaves a file behind. The dump header is written first, FTWRL is only requested after it, and without --force a refused query makes the program exit right away, before it gets to write the closing part. The contents of the 840-byte stub were not recorded, but this order fits a small file being left behind.
--no-tablespaces that aborts the dump
The second trap sits in a flag known as the way out for database users with limited privileges. According to the mysqldump documentation, as of MySQL 8.0.21 the PROCESS privilege is required unless --no-tablespaces is used. On that server, WP-CLI turned --no-tablespaces into tablespaces=, and mysqldump aborted.
The cause is in how WP-CLI parses arguments. Any argument starting with --no- is read as a negation, so --no-tablespaces is stored as a tablespaces argument with the value false. When the extra arguments are assembled into the mysqldump command, that false turns into an empty string.
# simplified, neutral file name
wp db export backup.sql --no-tablespaces
# parsed by WP-CLI as : tablespaces = false
# reaches mysqldump as : --tablespaces= (empty value)The same symptom has been reported in a public issue on the WP-CLI repository, with the message mysqldump: [ERROR] unknown variable 'tablespaces='. The rule in my notes is short: leave the flag off. It does not need to be written anyway. The wp db export documentation says the command already adds --no-tablespaces to mysqldump by default, and offers --include-tablespaces to turn that off. In the same issue, a WP-CLI maintainer said this default has been in place since version 2.5.0, while the WP-CLI on that server was already 2.12.0. To see the exact mysqldump command WP-CLI assembles, the maintainers in that thread suggest adding --debug.
The recipe for that server
The recipe I recorded for that server has two rules: use --skip-lock-tables, then confirm the dump ends with the line -- Dump completed.
# simplified example, neutral file name
wp db export backup.sql --skip-lock-tables
tail -n 1 backup.sql | grep -q '^-- Dump completed' \
&& echo 'dump complete' \
|| echo 'dump NOT complete'--skip-lock-tables turns off --lock-tables, which is on by default through --opt and locks each database's tables before their contents are read. This workaround has a price. Since --single-transaction fails on that server, the dump no longer comes from a single snapshot. Tables are read one after another, so if anything writes to the database while the dump runs, the tables can end up out of step with each other.
The second rule is what separates a finished dump from a stub. According to the mysqldump documentation, the comment -- Dump completed on <date> is written at the end of the dump as long as --comments is on, and --comments is on by default. A dump that stops halfway never reaches that line, so the last line of the file is more trustworthy than its size or its existence. Three small notes. --skip-comments and --compact drop that comment even from a complete dump. For a dump that goes straight into compression, read its last line through zcat. And in the mysqldump source code, --force stops query errors from ending the program, so the closing comment still gets written even when something failed. The check only means something without --force.
What was recorded, and what was not
The pre-update database backup is recorded as prod-db-20260829-preupdate.sql.gz, at 32 MB with 112 tables. Next to it sit an archive of the full plugin folder taken before the updates, 134 MB with 26,524 files, and an archive of the removed plugins, 50 MB with 16,756 files. All of those backups were verified readable before anything was deleted.
There is a limit worth stating plainly. My notes do not record the exact command that produced the 32 MB dump, do not say it was made with --skip-lock-tables, and do not say the -- Dump completed line was checked on it. The only verification written down for it is "readable". The notes also never mention the RELOAD grant being added to the database user. So what is in place on that server is a workaround on the backup command side, not a fix to the database user's privileges.
The updates those backups protected
Those backups were the way back for plugin updates shipped one at a time. In the end 14 updates went out with none rolled back, two others were deliberately held, and each update was verified before the next one shipped. The two riskiest, advanced-custom-fields and permalink-manager, went last and each on its own. Permalink Manager because the theme owns the URLs on that site, so a version jump in a permalink plugin can move every URL. ACF because it was four minor versions behind.
The verification was numbers before and after. ACF went from 6.4.1 to 6.8.9 while field-group objects stayed at 8 and field references stayed at 401. For Permalink Manager 2.5.2.1 to 2.6.0, 50 permalinks across 9 post types were captured before and after, with identical status codes and identical redirect targets, and get_permalink() unchanged for all 50.
A file that exists is not a backup yet
The 840-byte stub passed the laziest question anyone can ask a backup, which is whether the file exists. What tells it apart are questions about content: does the last line read -- Dump completed, how many tables are inside, and do the numbers before and after a change still match. For wp db export on that server, my notes keep three rules: use --skip-lock-tables, do not write --no-tablespaces, and confirm the last line of the dump reads -- Dump completed. The first rule has a price, which is a dump that no longer comes from a single snapshot. The missing RELOAD grant at the root of it was not changed, and that is worth remembering every time the next backup is taken on the same server.