Safely Update URLs After WordPress Migration with WP-CLI

by Fahim

Running a raw SQL UPDATE to swap domain names will trash your WordPress database. The second you run a naive REPLACE() query on wp_options or wp_postmeta, your widgets disappear, the customizer resets to default, and your page builder layouts fall apart.

Here is how to properly swap URLs across your entire database using wp search-replace in WP-CLI—dry runs, serialized data handling, RSS GUID gotchas, and the weird edge cases that usually pop up right after DNS flips.

Close-up of a terminal screen showing WP-CLI search-replace command output and database tables
Close-up of a terminal screen showing WP-CLI search-replace command output and database tables

Why Raw SQL Queries Destroy Serialized Data

WordPress stores settings, widget configs, and builder layouts inside the database as serialized PHP strings. A serialized string does not just hold the text value; it stores the exact character count of every string inside an array or object structure.

Here is what an ordinary serialized string looks like in the wp_options table:

a:1:{s:8:"site_url";s:22:"http://staging.local";}

See that s:22: right before the URL? That tells PHP the string is exactly 22 characters long. If you run a standard MySQL REPLACE() query to change http://staging.local to https://example.com, the URL shrinks from 22 characters to 19. Your database record becomes:

a:1:{s:8:"site_url";s:22:"https://example.com";}

The length prefix still says 22, but the string is only 19 characters. When WordPress tries to parse this row with PHP’s unserialize() function, PHP hits an unexpected delimiter, throws an error, and returns false. WordPress assumes the setting is missing or corrupt, silently discarding it and falling back to default values.

The WP-CLI search-replace command avoids this. It pulls each row into memory, unpacks the serialized structure, swaps the string, recalculates the character counts, re-serializes everything, and writes it back cleanly.

Step 1: Dump the Database Before Running Changes

Never touch database records without a snapshot sitting on disk. If you make a typo in the domain or miss a protocol flag, you need a way to roll back in seconds.

SSH into your server, navigate to your web root, and run:

cd /var/www/html
wp db export pre-migration-backup.sql

This generates an uncompressed SQL dump using your active database credentials from wp-config.php. If you are on a shared host or managed VPS, knowing how to restore a WordPress backup via SSH makes this safety net zero stress.

Always verify the file was created and contains content before moving on:

ls -lh pre-migration-backup.sql

If that dump is only a couple of kilobytes on a site that has been around for years, check your database user permissions or make sure your config is not pointed at an empty database. Do not touch search-replace until you have a valid backup.

Step 2: Test the Domain Swap with Dry-Run Mode

The best tool in WP-CLI is --dry-run. It simulates the entire search-and-replace operation across all your tables, counts every match, and writes zero changes to MySQL.

Run a dry run comparing your old staging domain to your production domain:

wp search-replace 'https://staging.devsite.net' 'https://example.com' --dry-run

When the operation finishes, WP-CLI outputs a clean summary table showing where replacements were found:

+------------------+-----------------------+--------------+------+ | Table | Column | Replacement | Type | +------------------+-----------------------+--------------+------+ | wp_options | option_value | 14 | PHP | | wp_posts | post_content | 182 | SQL | | wp_posts | guid | 412 | SQL | | wp_postmeta | meta_value | 68 | PHP | +------------------+-----------------------+--------------+------+ Success: 676 replacements to be made.

Keep an eye on the “Type” column. If it says SQL, WP-CLI used a direct string query. If it says PHP, it safely deserialized and re-serialized the row in memory. That is your serialized data staying intact.

Step 3: Execute the Live search-replace Command

Once the dry run looks right, run it for real. By default, you want to leave the guid column in wp_posts alone.

As the WordPress Codex guide on changing site URLs notes, post GUIDs are global unique identifiers for RSS feed readers. If you update the domain in existing GUIDs, RSS readers will treat every post as brand new, notifying subscribers and flooding feed aggregators with duplicate content.

Skip the GUID column using the --skip-columns flag:

wp search-replace 'https://staging.devsite.net' 'https://example.com' --skip-columns=guid

This replaces links, image sources, embed URLs, and configuration rows while leaving your historical RSS pointers untouched.

If your migration also moves you from plain HTTP to HTTPS, this command knocks out the bulk of protocol mismatches. To clean up any hardcoded insecure script tags or stylesheets that might remain in your theme files, review our guide to fix WordPress mixed content errors.

Step 4: Handling Multisite Networks and Custom Tables

On a WordPress multisite network, running wp search-replace without network flags only touches the active primary site tables. The child site tables (like wp_2_posts, wp_3_options) remain untouched.

To sweep every table in a multisite database, pass the --network flag:

wp search-replace 'https://oldnetwork.com' 'https://newnetwork.com' --network --skip-columns=guid

Some plugins create tables without using your standard WordPress $table_prefix—think custom logging, audit trails, or third-party CRM tables. WP-CLI skips non-standard tables by default to protect arbitrary data.

To force WP-CLI to scan every single table inside your active database schema, add --all-tables:

wp search-replace 'https://oldnetwork.com' 'https://newnetwork.com' --all-tables --skip-columns=guid

Keep in mind that --all-tables takes longer because it inspects overhead tables that rarely contain URLs. On databases larger than 2GB, restrict your scan to default tables unless you know a specific plugin stores absolute paths in custom tables.

Step 5: Isolate Target Tables to Speed Up Slow Queries

On large WooCommerce or high-traffic sites with hundreds of thousands of rows in wp_posts and wp_postmeta, running an open-ended scan across the entire schema can trigger timeouts or hit PHP memory limits.

You can target specific tables by listing their names right after your domain arguments. Here is how I usually scope it down to options and posts:

wp search-replace 'https://oldurl.com' 'https://newurl.com' wp_options wp_posts --skip-columns=guid

If you need to limit the operation even further, use the --include-columns parameter. This lets you update post content without scanning titles, excerpts, or post dates:

wp search-replace 'https://oldurl.com' 'https://newurl.com' wp_posts --include-columns=post_content

On one client’s WooCommerce database, scoping to post_content dropped execution time from 48 seconds down to 1.8 seconds. When planning large-scale cutovers, pairing targeted WP-CLI commands with a clean transfer strategy helps you migrate WordPress without downtime.

What Broke for Me: The Elementor and Base64 Gotcha

During a migration last month, I ran a full search-replace, checked the front end, and found all Elementor-built landing pages completely missing their background graphics, icons, and layout margins. The database showed the URLs were updated, but the styles refused to render.

Elementor and several modern page builders do two things that bypass standard deserialization:

  1. They compile internal layout instructions and CSS stylesheets into flat files inside wp-content/uploads/elementor/css/.
  2. They sometimes store design data inside JSON-encoded strings or base64-encoded strings within wp_postmeta.

WP-CLI will safely deserialize standard PHP arrays, but it does not automatically unpack base64 strings or decode nested JSON inside post meta by default. If your page builder breaks after a URL update, run the operation with the --precise flag:

wp search-replace 'https://oldurl.com' 'https://newurl.com' wp_postmeta --precise --recurse-objects

The --precise flag forces WP-CLI to scan for matches chunk-by-chunk using a slower, high-fidelity parsing method that catches strings nested inside complex structures. The --recurse-objects flag guarantees that objects nested inside arrays are fully evaluated.

Once the database command completes, you must force Elementor to recompile its static assets. You can do this directly from the command line using Elementor’s custom WP-CLI subcommand:

wp elementor flush-css

If you do not run this flush, the page builder will keep serving old CSS files that reference the obsolete staging URL, causing mixed-content warnings or broken asset links on the client side.

Flush Cache and Validate Live Endpoints

Once the database values are updated, WordPress might continue serving stale cached options from your persistent memory store. If you use Redis, Memcached, or an internal object cache, the old URLs will linger in memory until manually evicted.

Clear your object cache and rewrite rules directly from the CLI:

wp cache flush
wp rewrite flush --hard

The --hard flag forces WordPress to regenerate your server rewrite file (either .htaccess or your custom Nginx routing rules). This step prevents 404 errors on internal pages after the domain change.

Now check your primary endpoints using curl to make sure WordPress is returning a clean 200 OK response without redirect loops:

curl -I https://example.com

Check for the HTTP/2 200 header and inspect the location response header if a 301 redirect occurs. If the site redirects back to the old staging domain, check your wp-config.php file. Hardcoded constants like WP_HOME and WP_SITEURL override the values stored in your database tables. If those constants are set at the top of wp-config.php, update them to match your new production domain.

Frequently Asked Questions

Can I search-replace paths as well as URLs?

Yes. wp search-replace works on arbitrary strings, not just URLs. If your absolute server path changed during migration (for example, moving from /home/user1/public_html to /var/www/example.com/html), run the exact same command using the file system paths:

wp search-replace '/home/user1/public_html' '/var/www/example.com/html' --skip-columns=guid

Do I need to put trailing slashes on my domain strings?

No, avoid trailing slashes unless you have a specific directory path to replace. Using https://olddomain.com without a trailing slash matches both the root site URL and all sub-paths (like https://olddomain.com/wp-content/uploads/photo.jpg). If you include a trailing slash, any database entry that omitted the slash will be missed.

What should I do if WP-CLI runs out of memory during search-replace?

If you receive an error stating Fatal error: Allowed memory size of X bytes exhausted, increase PHP’s memory limit specifically for that single CLI process:

php -d memory_limit=1024M $(which wp) search-replace 'https://old.com' 'https://new.com' --skip-columns=guid

This temporarily raises the memory ceiling to 1GB for the migration job without permanently modifying your server’s php.ini configuration.

Why are my image thumbnails still pointing to the old URL?

If full-size images load but intermediate thumbnails point to your old staging server, your media library metadata was likely cached or your upload directory settings inside wp_options contain a custom upload path. Check the upload_path and upload_url_path options:

wp option get upload_url_path

If this returns an absolute URL pointing to your old server, set it to an empty string so WordPress calculates upload URLs dynamically:

wp option update upload_url_path ''

Next Steps

Once your domain URLs are safely re-indexed, test your post creation workflow and media uploads from the dashboard to confirm all rewrite rules are firing properly. If you are preparing this environment as a permanent development sandbox before pushing changes upstream, read our guide on how to configure a WordPress staging site in Hostinger hPanel to streamline your next deployment cycle.

all_in_one_marketing_tool