← Back to blog

How to restore WordPress from a backup, and the five things that break when you do

The SandyWP team 7 min read

Restoring WordPress is two operations: copy the files back into place, then import the database dump. The files are the easy half, because a wp-content directory is the same on any server.

The database is where restores go wrong. It carries the old site's URL, its table prefix, its security salts and its cron schedule, and each of those fails in a way that looks like something else.

Here is the order that works, then the five mechanisms that cause almost every failed restore.

The order: files first, database second, wp-config last

Upload the files before importing the database. If the database arrives first, WordPress starts serving requests against tables whose plugins are not on disk yet, and you get fatal errors while you are still working.

Then import the dump into an empty database. Importing over existing tables leaves rows from both installs, because a dump written by mysqldump usually has DROP TABLE IF EXISTS for the tables it knows about and nothing at all for a table the old site did not have.

Edit wp-config.php last, and only four values: DB_NAME, DB_USER, DB_PASSWORD and $table_prefix. Do not restore the old wp-config.php wholesale onto a new server, because its database credentials are for a database that no longer exists.

The two rows that decide whether the site loads at all

WordPress stores its own address twice, as the siteurl and home rows in wp_options. If those still say the old domain, every request redirects there, and you get a loop or a site that is apparently fine but is not yours.

Two things make this harder than it looks. The URL is also written into post content, into widget and theme-mod option values, and into attachment metadata, so changing two rows fixes the redirect and leaves broken images behind.

The second is that WP_HOME and WP_SITEURL in wp-config.php do not change those rows. They are read filters, so they hide the stale value instead of correcting it, which is a useful way to get back into wp-admin and a bad way to finish a restore.

We wrote that mechanism up in changing a WordPress site URL.

Why a find and replace in SQL corrupts the site

The obvious fix for the old domain is UPDATE wp_options SET option_value = REPLACE(...), or a find and replace in your editor on the dump file. That corrupts every serialized value it touches.

PHP serializes a string as its length followed by the text, like s:19:"https://old-site.com". Replace the text with a domain of a different length and the number is now wrong, so maybe_unserialize() fails and the option comes back as a raw string instead of an array.

The symptom is not an error. A theme's customizer settings or a plugin's option page simply comes back empty, because the array it expected did not unserialize, and nothing in the logs connects that to the restore you did an hour ago.

WP-CLI exists for exactly this. wp search-replace "intelligently handles PHP serialized data", using SQL for ordinary columns and automatically switching to PHP for serialized ones, with --precise to force PHP everywhere and --dry-run to see the count before you commit:

wp search-replace 'https://old-site.com' 'https://new-site.com' --all-tables --dry-run

More on the flags that matter in our WP-CLI search-replace guide.

A different table prefix silently removes every role

If the dump's tables are named wp_abc123_posts and wp-config.php says $table_prefix = 'wp_', WordPress sees no tables and offers to install itself. That failure is loud, and people fix it in a minute.

The quiet one is user roles. WP_User::_init_caps() builds the meta key it reads roles from out of the prefix:

$this->cap_key = $wpdb->get_blog_prefix( $this->site_id ) . 'capabilities';

So a user's role lives in a wp_usermeta row whose meta_key is wp_capabilities, or wp_abc123_capabilities on a site with a custom prefix. Restore the rows under one prefix and run the site under another and every role disappears with no error at all: people log in successfully and then have no permissions, including you.

Set $table_prefix to match the dump, rather than renaming tables to match wp-config.php. The same trap is why we treat a prefix change as its own test in testing a WordPress table prefix change.

New salts log everyone out, old salts bring old sessions back

Auth cookies are signed with a key derived from the salt constants in wp-config.php. In wp_validate_auth_cookie() the expected hash is built as:

$key  = wp_hash( $username . '|' . $pass_frag . '|' . $expiration . '|' . $token, $scheme );
$hash = hash_hmac( 'sha256', $username . '|' . $expiration . '|' . $token, $key );

If that hash does not match, the cookie is rejected. So writing fresh salts during a restore signs everyone out, which is the right outcome if the restore is a response to a compromise.

The reverse matters more and nobody mentions it. Session tokens are stored per user in wp_usermeta, so restoring an old database restores the sessions that were valid when the backup was taken.

If you are restoring because the site was hacked, keep the old database rows but write new salts, and the attacker's cookies stop working. Our post on WordPress salts covers which constants to regenerate.

The restore brings back the old cron schedule, and it fires immediately

This is the one that surprises people, and it is the reason a restore can send email you did not expect.

WordPress keeps its whole schedule in a single wp_options row called cron, read by _get_cron_array(). So a database restore restores the schedule exactly as it was when the backup ran, including every event that was due in the days since.

Those events are not skipped for being late. wp_get_ready_cron_jobs() walks the array in timestamp order and stops only when it reaches an event in the future:

if ( $timestamp > $gmt_time ) {
    break;
}

Everything before that point is due now. The first page load after a week-old restore can therefore publish a batch of scheduled posts, run a backup plugin's upload to a remote destination, and send whatever digest emails the site sends, all at once.

If that matters, disable WP-Cron with define( 'DISABLE_WP_CRON', true ); before the first page load and look at wp cron event list before you let it run. Related: why WordPress misses its schedule.

What actually stops a large dump importing

phpMyAdmin imports go through an HTTP upload, so they hit upload_max_filesize and post_max_size long before the database notices. That is a PHP limit, not a MySQL one, and raising the upload size is the fix.

The command line avoids that, and then hits a different ceiling. MySQL's manual on packet too large states the server's max_allowed_packet default as 64MB, and the mysql client's own default as 16MB.

Both sides have their own value, and the client's is the lower one. So a dump with a single multi-row INSERT over 16MB fails on import even though the server would have accepted it, and the error you get is ER_NET_PACKET_TOO_LARGE or a bare "Lost connection to MySQL server during query" that reads like a network fault:

mysql --max_allowed_packet=256M -u user -p dbname < dump.sql

wp db import shells out to the same client, so pass the flag there too if the import dies partway.

The .maintenance file is probably not your problem

A backup taken while WordPress was mid-update contains a .maintenance file, and plenty of restore guides tell you to delete it. Usually there is nothing to delete.

wp_is_maintenance_mode() loads the file, reads the $upgrading timestamp it sets, and gives up on maintenance mode if that timestamp is old:

if ( ( time() - $upgrading ) >= 10 * MINUTE_IN_SECONDS ) {

A restored file carries the timestamp from when the backup was taken, so after ten minutes it is inert. If your site is genuinely stuck, the cause is current, not restored, and being stuck in maintenance mode covers it properly.

Restore into a copy first, then over the live site

Every mechanism above is cheap to discover on a throwaway copy and expensive to discover on the live site, because a restore overwrites the evidence of what was wrong.

This is what a disposable sandbox is for. Restore the backup into one, confirm the roles survived, click through the pages that use serialized options, check wp cron event list, then do it again on production knowing what to expect.

SandyWP does this with the Cloner plugin: it packages a site you control, uploads it, and SandyWP restores it into a new sandbox and rewrites the URLs for you. Archives can be up to 24 GB, and the dashboard shows the import moving through uploading, uploaded and restoring states so a long one is not a black box.

Once it is up, SSH and SFTP give you a shell with WP-CLI preinstalled, which is where wp search-replace --dry-run, wp db query and wp cron event list belong.

One honest limit: this verifies that a backup restores and that your post-restore steps are right. It does not prove your backups are complete, because a sandbox restores what the archive contains and cannot tell you what the archive left out.

For that, see testing that a backup actually restores, which is a different job from this one.