Hosting

How to Migrate WordPress Without Downtime, and the Two Steps That Break It

Migrating a WordPress site is not difficult. It is a sequence of about eight steps with two genuine traps, and the official documentation covers the sequence thoroughly while barely covering the traps.

The traps are: doing the URL replacement with a naive SQL statement, and cutting DNS over without accounting for caches you do not control. Both are recoverable. Both cost hours if you meet them unprepared.

The documented sequence

WordPress.org’s own migration guide is more conservative than most tutorials, and worth following as written. It applies to a single installation — multisite is explicitly out of scope, which is a limitation worth noticing before you plan a day around it.

The published order:

  1. Back up the database and the files
  2. Change the URLs in Settings → General on the source site
  3. Re-download the files so the export is consistent
  4. Re-export the database
  5. Edit wp-config.php to match the destination
  6. Upload the files to the destination
  7. Import the database at the destination
  8. Verify

Two things about that order are load-bearing. Step 2 happens on the source site, before the export — because the URL values live in the database, and changing them in the admin interface is the safe way to do it. And step 3 exists because files can change between the database dump and the file copy; re-downloading closes the window.

If your site is small and nobody is editing it, that window is trivial. If it is an active commerce site, the window is where you lose an order.

Trap 1: replacing URLs with SQL

This is the one that produces silent, strange corruption.

URLs in a WordPress database are not stored as isolated strings. Many are inside serialised PHP data structures — plugin settings, widget configurations, theme options — where the string length is encoded alongside the value. A plain SQL REPLACE changes the text but not the length prefix, and the result is a value that PHP cannot unserialise. The symptom is not an error message. It is a settings page that renders blank, or a theme option that quietly reverts.

WordPress.org’s own guidance is explicit: “If you do a search and replace on your entire database to change the URLs, you can cause issues with data serialization.” Note the framing — as a hazard, not as an instruction with a safe alternative attached.

The safe tool is WP-CLI, and its documented behaviour is exactly the property you need: search-replace “intelligently handles PHP serialized data, and does not change primary key values.”

# Always look first
wp search-replace 'https://old.example' 'https://new.example' --dry-run --all-tables

# Then do it
wp search-replace 'https://old.example' 'https://new.example' --all-tables

Three flags worth knowing:

  • --dry-run reports what would change without changing it. There is no reason to skip this.
  • --precise forces PHP-based replacement instead of SQL. The documentation describes it as “more thorough, but slower” — slower is the correct trade here.
  • --skip-columns=guid is documented as an option, and it exists because of the next point.

The column you must never touch

Both WordPress.org guides are unusually emphatic about one thing, and the emphasis is telling:

“Never, ever, change the contents of the GUID column, under any circumstances.”

GUIDs look like URLs, which is why people include them in a search-replace. They are not URLs. They are permanent identifiers, and WordPress uses them in feed generation. Changing them causes duplicate feed entries and unpredictable behaviour in subscribers’ readers — and because nothing visibly breaks on your own site, you will not discover it for weeks.

The documented consequence of leaving them alone is benign: “references to the old domain name or location will remain in the database, and that can cause issues with links or theme display.” That is the trade the documentation is recommending — leave the identifiers stale, keep the system correct.

Testing before you cut over

There is a documented way to verify the new installation before DNS moves, and it is underused.

WP_HOME and WP_SITEURL in wp-config.php override the database values for home and siteurl. They do not write to the database — remove the lines and the site reverts to whatever is stored. That is precisely the property you want for testing: point WP_HOME at the staging URL to inspect the site, and let the stored values be the ones that go live.

Because they do not persist, they are not a migration mechanism. They are a temporary override, and something like the RELOCATE constant is the documented way to actually change the stored value.

Working around the read lock

If you have ever had mysqldump fail with a permission error about LOCK TABLES, the cause is documented. mysqldump runs --opt by default, which expands to include --lock-tables and --quick. On a shared host that withholds the LOCK TABLES privilege, that combination fails.

--single-transaction is the documented alternative: it issues a BEGIN before dumping, removing the LOCK TABLES requirement. For InnoDB tables it gives you a consistent snapshot without locking anything — which also means the site stays up during the dump.

Trap 2: the DNS cutover

Here is where the official documentation stops. WordPress.org gives a change-order checklist. There is no official zero-downtime cutover playbook, because the part that decides downtime is not WordPress at all — it is DNS and its caches.

The mechanics that matter:

TTL is the control you have. The record’s time-to-live determines how long resolvers keep your old value after you change it. Lower it to something short well before the migration — days before, not minutes — so that when you flip, the old value expires quickly everywhere.

Negative answers are cached too. This is the part most guides omit. When a resolver receives NXDOMAIN or NODATA, that negative answer is cached, with a TTL derived from the zone’s SOA record — and the caching window is typically recommended in the range of hours. So a record that briefly failed during your migration keeps failing for resolvers that asked at the wrong moment, even after you have fixed it. Plan for the possibility of a longer tail than the TTL suggests.

TTL counts down. A resolver holding your answer does not restart the clock when you change the record upstream. It serves the remainder of the original TTL. This is why you lower the TTL before you need it to be low.

Verify against resolvers, not your laptop. Query public resolvers explicitly, and then test the actual site:

dig @1.1.1.1 new.example.com A +short
dig @8.8.8.8 new.example.com A +short
curl -sI https://new.example.com/ | head -5

Test the oldest thing people use. If your email is on the same domain, send a message in and out before you consider the migration complete. See the DNS and email article for the ordering rules that make that safe.

When to not do this yourself

If the site takes orders or handles logins, the cost of a botched migration is measured in customers, not hours. The sequence above is not complex, but step 1 — a verified backup you have actually restored once — is the step that separates a recoverable migration from an unrecoverable one.

Practice the restore. A backup you have never restored is a hypothesis.


Written by TestedHost. Every recommendation on this site comes from running the setup described, on a live deployment — not from a vendor spec sheet. Spotted something out of date? Tell us.