Traffic Dropped After a Website Redesign or Migration: How to Diagnose and Recover
Traffic fell after the new site launched. First prove the drop is real, not broken tracking, then find which pages and queries lost out and why.
Move WordPress without a migration plugin: dump the database, copy the files, import, run a safe search-replace, test through your hosts file, then switch DNS.
Migration plugins time out on big sites, choke on archives over a few gigabytes, or are blocked by the new host. Moving WordPress by hand avoids all that, and it is the method professionals fall back on when a plugin fails. Done carelessly, though, it leaves a site that bounces visitors back to the old domain, loses widget and page builder settings, or shows the old host’s content for days.
Under the hood a move is four jobs: copy the files, copy the database, point the new copy at its new database, and, if the domain changes, rewrite every stored address without breaking serialized data. Then you test the new server privately before the world sees it, and only then change DNS. This guide walks through each job with the commands, for anyone comfortable with SSH or willing to learn.
WordPress keeps its address in the database, not in the files. The siteurl and home rows in wp_options decide where WordPress thinks it lives, and it redirects visitors there. Full URLs are also stored inside post content, image links, menus, widget settings and page builder data. Many of those values are PHP serialized, a format that records the length of every string:
s:23:"https://old-example.com";
The s:23 means a string of 23 characters. If a plain SQL REPLACE() or a text editor swaps the domain for one of a different length, the count is wrong, PHP cannot read the value, and WordPress silently throws that whole setting away. That is why widgets, theme options and builder layouts vanish after careless moves. wp search-replace unpacks serialized values, edits them and repacks them with correct lengths.
| Part | Where it lives | How it moves |
|---|---|---|
| Core, themes, plugins, uploads | The site folder | tar or rsync |
Hidden files (.htaccess, .user.ini) |
The site folder | tar or rsync (zip tools often skip them) |
| Content, settings, users, orders | The database | wp db export and wp db import |
| Database connection details | wp-config.php |
Edited by hand on the new server |
| Domain to server mapping | DNS | Changed last |
wp, tar, rsync or mysql run in an SSH terminal on the server named in each step, from the site folder (the one with wp-config.php). Some hosts call WP-CLI wp-cli instead of wp. Commands starting with dig or curl run on your own computer: macOS and Linux include them; on Windows use WSL, or curl.exe and Resolve-DnsName in PowerShell.Check versions first. Over SSH on each server:
wp core version
php -v
mysql --version
No placeholders. You want the new server’s PHP and database versions to be the same or newer. If the old site runs on MySQL 8 and the new one on MariaDB, expect the collation error covered in FG-59, and fix it during import. If the old PHP is much older, check that your theme and plugins support the new version before you move.
Next, lower the DNS TTL so the final switch is fast. On your computer:
dig +noall +answer example.com A
Replace example.com with your domain. +noall +answer trims the output to the answer lines. The number after the name is the TTL in seconds, for example example.com. 14400 IN A followed by the IP. Set it to 300 in your DNS panel, then wait at least the old TTL (here four hours) before cutover, so every resolver picks up the short value.
Over SSH on the old server, in the site folder:
wp db export ../site.sql --single-transaction --quick --default-character-set=utf8mb4
gzip ../site.sql
No placeholders; site.sql is just a file name. Writing to ../ keeps the dump out of the public web folder. wp db export reads the credentials from wp-config.php and passes extra flags to mysqldump: --single-transaction takes a consistent snapshot of InnoDB tables without locking the live site, --quick streams rows instead of loading whole tables into memory, and --default-character-set=utf8mb4 keeps emoji and accented characters intact. Good output is Success: Exported to '../site.sql'. and gzip then produces ../site.sql.gz.
No WP-CLI? Use mysqldump directly:
mysqldump -u DB_USER -p --single-transaction --quick --no-tablespaces --default-character-set=utf8mb4 DB_NAME | gzip > ../site.sql.gz
DB_USER and DB_NAME are the DB_USER and DB_NAME values in the old wp-config.php. -p prompts for the password so it does not land in your shell history. --no-tablespaces avoids the Access denied; you need (at least one of) the PROCESS privilege(s) error that MySQL 8 raises for normal users. The pipe into gzip compresses on the fly. A dump that ends with -- Dump completed on (check with zcat ../site.sql.gz | tail -1) is complete.
The cleanest method is to pull from the new server with rsync, which copies directly between servers and can be rerun later to send only what changed. Over SSH on the new server, in the new, empty site folder:
rsync -avz --exclude 'wp-content/cache/' -e ssh OLD_USER@OLD_HOST:/OLD/PATH/TO/SITE/ ./
rsync -avz -e ssh OLD_USER@OLD_HOST:/OLD/PATH/TO/site.sql.gz ../
OLD_USER and OLD_HOST are your SSH login and server name or IP on the old host. /OLD/PATH/TO/SITE/ is the full path of the old site folder (run pwd there to see it). -a keeps permissions, timestamps and symlinks, -v lists files as they copy, and -z compresses in transit. The trailing slash on the source matters: with it, rsync copies the folder’s contents into ./; without it, you get an extra folder level. Excluding the cache folder avoids copying stale cached pages. Good output ends with a total size is line. Permission denied (publickey) means the new server cannot log in to the old one; add its SSH key or copy through your computer instead.
If the servers cannot talk to each other, make an archive on the old server with tar -czf ../files.tar.gz ., download it and upload it to the new one, then unpack with tar -xzf ../files.tar.gz in the new site folder. Either way, check that .htaccess arrived with ls -la.
On most hosting, use the control panel’s database section: create a database, create a user with a strong password, and give that user all privileges on that database. On your own server you can do it in SQL, logged in to mysql as an admin user:
CREATE DATABASE NEW_DB_NAME CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_520_ci;
CREATE USER 'NEW_DB_USER'@'localhost' IDENTIFIED BY 'NEW_DB_PASSWORD';
GRANT ALL PRIVILEGES ON NEW_DB_NAME.* TO 'NEW_DB_USER'@'localhost';
NEW_DB_NAME, NEW_DB_USER and NEW_DB_PASSWORD are names and a password you choose. 'localhost' limits the user to connections from the same machine; if the database runs on a separate server or container, your host will tell you which host value to use. Good output is Query OK for each line.
Over SSH on the new server, in the site folder:
wp config set DB_NAME NEW_DB_NAME
wp config set DB_USER NEW_DB_USER
wp config set DB_PASSWORD 'NEW_DB_PASSWORD'
wp config set DB_HOST DB_HOST_VALUE
wp config get table_prefix
The first three values are from Step 4. DB_HOST_VALUE is usually localhost; some hosts give a hostname, an IP or a socket path instead, shown in the control panel. Single quotes around the password stop the shell from interpreting characters like $. wp config set edits the constants in wp-config.php without loading WordPress, so it works before the database exists. The last command prints the table prefix: it must match the prefix in your dump (look for CREATE TABLE `PREFIX_options` with zcat ../site.sql.gz | grep -m1 "CREATE TABLE").
While you are in wp-config.php, look for things tied to the old server: hard-coded WP_HOME and WP_SITEURL constants (update or remove them), a Redis or Memcached object-cache.php file in wp-content that expects a cache server the new host may not have, and absolute paths in .user.ini or security plugin settings such as auto_prepend_file.
Over SSH on the new server, in the site folder:
gunzip -c ../site.sql.gz | wp db import -
wp option get home
No placeholders. gunzip -c writes the uncompressed SQL to the pipe without creating a big file, and the - tells wp db import to read from that pipe. Good output is Success: Imported from 'STDIN'., then the old address. Errors such as MySQL server has gone away, Unknown collation or Row size too large are covered in the large import guide (FG-59).
If the domain is not changing (a pure host move), skip to Step 8.
Always start with a dry run. Over SSH on the new server:
wp search-replace 'https://old-example.com' 'https://example.com' --all-tables-with-prefix --skip-columns=guid --precise --dry-run --report-changed-only
old-example.com is the old domain exactly as wp option get home showed it, and example.com is the new one. --all-tables-with-prefix includes plugin tables that WordPress does not register, such as form or builder tables. --skip-columns=guid leaves post GUIDs alone; they are permanent identifiers that feed readers use to tell old items from new, so rewriting them on an established site can make subscribers see every post as new. On a brand new site that has never had a feed, changing them does no harm. --precise forces the slower PHP path for every column instead of fast SQL, which handles awkward serialized data more reliably. --dry-run reports without saving, and --report-changed-only lists only tables with matches. WP-CLI also replaces the JSON-escaped form (https:\/\/old-example.com) automatically.
Good output is a table of tables and columns with replacement counts, ending in a Success: line that gives the total replacements to be made. Zero replacements usually means a typo or a protocol mismatch (http versus https, www versus no www). When the counts look right, run the same command without --dry-run. Then catch leftovers:
wp search-replace 'http://old-example.com' 'https://example.com' --all-tables-with-prefix --skip-columns=guid --precise
wp db search 'old-example.com' --all-tables-with-prefix --stats
The first line fixes old http:// links and upgrades them in one pass; repeat it for www. variants if the old site used them. wp db search lists remaining matches with a count per table. Look at what is left before replacing the bare domain: it may be email addresses such as info@old-example.com that should change only if the mailbox moved too.
On multisite, add --network and also update the domain columns in wp_blogs and wp_site and the DOMAIN_CURRENT_SITE constant; that is a larger job than this guide covers.
Over SSH on the new server:
wp rewrite flush --hard
wp cache flush
wp transient delete --all
No placeholders. --hard also rewrites .htaccess on Apache and LiteSpeed. wp cache flush empties the object cache, and the transient command clears cached values that may hold old URLs. If you use a page builder, run its own regenerate CSS or clear cache tool too, because builders write CSS files that contain absolute URLs. On Nginx, pretty permalinks need a try_files rule in the server config; if every page but the home page 404s, see FG-37.
Your hosts file makes only your computer resolve the domain to the new server, so you can test the real address before anyone else sees it. Add one line:
NEW_SERVER_IP example.com
NEW_SERVER_IP www.example.com
NEW_SERVER_IP is the new server’s IPv4 address from the new host. The file is /etc/hosts on macOS and Linux (edit with sudo nano /etc/hosts) and C:\Windows\System32\drivers\etc\hosts on Windows (open Notepad as administrator). Flush your DNS cache afterward: ipconfig /flushdns on Windows, sudo dscacheutil -flushcache; sudo killall -HUP mDNSResponder on macOS. Browsers can keep their own cache, so test in a fresh private window. Remove the lines when you are done.
A quicker check that needs no file edits, on your computer:
curl -sI --resolve example.com:443:NEW_SERVER_IP https://example.com/
--resolve forces curl to connect to NEW_SERVER_IP for that name and port while still sending the right hostname. HTTP/2 200 is good. A 301 with a location: header pointing to the old domain means a missed replacement or a hard-coded constant (see FG-40). SSL certificate problem means the new server has no valid certificate for the name yet; many hosts can only issue a Let’s Encrypt certificate once DNS points at them, so add -k to skip verification for this test and ask the host about issuing the certificate at cutover.
In the browser, click through pages, log in, submit a form, check images and run a test checkout.
A record (and www) to the new IP. If there is an AAAA record for the old server’s IPv6 address, update or delete it, or IPv6 visitors keep reaching the old host.MX, SPF, DKIM and DMARC records alone unless email is moving too.dig +short example.com A @1.1.1.1 and dig +short example.com A @8.8.8.8. When both return the new IP, most visitors are on the new server.For the redirect side of a domain change, use the dedicated guides rather than improvising: FG-38 builds the 301 map from old URLs to new ones, and FG-40 fixes a site that keeps sending visitors to the old domain. Mixed content warnings after the move are covered in FG-28.
On large sites you can skip the intermediate dump file entirely and pipe the database straight from server to server. Over SSH on the new server, in the site folder:
ssh OLD_USER@OLD_HOST "cd /OLD/PATH/TO/SITE && wp db export - --single-transaction --quick | gzip" | gunzip | wp db import -
The placeholders are the same as in Step 3. The part in quotes runs on the old server and writes a compressed dump to standard output, which travels over SSH and is unpacked and imported on the new one. Good output is the import success line. If the old host lacks WP-CLI in non-interactive shells, replace it with the full path from which wp.
For a near-zero downtime cutover on a busy site: do the full copy a day early and test it, then at cutover put the old site in maintenance mode, rerun the streamed import and rsync (which now moves only the day’s changes), run search-replace, and switch DNS. Keep the old site in maintenance mode, or have it serve a notice, so late visitors on stale DNS cannot place orders on a server that is no longer the source of truth.
Open a ticket with the new host when you cannot create a database, the import is rejected for size or privileges, SSH between servers is blocked, or a certificate will not issue after DNS has switched. Send the domain, the new server IP, the exact command and full error text, UTC timestamps, your database server type and version on both sides, and the output of dig +short example.com A. If DNS is correct but you still see the old site, include the response headers from curl -sI. Use our diagnostic tools to confirm DNS and SSL from outside your own network.
Did this fix it?
Still stuck? Start again from what you can see in the Emergency Room.
Still stuck?
Print a free one-page checklist to keep, or open the Emergency Room if the symptom does not match.
Get the free checklistsKeep reading
Search the field manual
Or jump straight to a symptom
Press / to search from any page, Esc to close