wp search-replace 'old' 'new' --dry-run shows what would change, and dropping --dry-run makes the change. That is the whole command.
What the ranking guides mostly skip is what it leaves behind, so finish every run with wp db search 'old' --all-tables-with-prefix --stats and treat anything it finds as unfinished work.
The report search-replace prints is a list of what it changed. It is not a list of what it skipped, and some of the skips are silent.
Everything below comes from reading the command's source on GitHub (the wp-cli/search-replace-command package, read 2026-09-11), not from the docs.
The basic command
# See what would change, touching nothing
wp search-replace 'https://old-cdn.example.com' 'https://cdn.example.com' --dry-run --report-changed-only
# Do it
wp search-replace 'https://old-cdn.example.com' 'https://cdn.example.com' --report-changed-only
# Flush the object cache, or pages keep serving old values
wp cache flush
By default it searches only the tables WordPress has registered in $wpdb. Plugin tables that are not registered are ignored unless you add --all-tables-with-prefix.
The docs describe --dry-run as "Run the entire search/replace operation and show report, but don't save changes to the database". That is accurate, and it is why a dry run cannot see anything the real run would skip: it skips the same things.
The number in the report is rows, not replacements
A post containing the old URL forty times counts as 1.
In the source, the SQL path counts matching rows with SELECT COUNT(...) WHERE ... LIKE BINARY, and the PHP path adds one per changed row.
That matters when you compare runs. A smaller number after a second pass does not mean fewer matches remained in each row.
It picks SQL or PHP per column, from one row
The docs say the command "uses fast SQL queries, but automatically switches to PHP for columns containing serialized data". The check that decides this is a single query per column:
SELECT * FROM <table> WHERE <column> REGEXP '^[aiO]:[1-9]' LIMIT 1
If any value in the column starts like a serialized array, integer or object, the whole column goes through PHP, which unserializes, replaces and reserializes. If not, it runs a plain SQL REPLACE(), which does not touch byte counts.
In core tables this is fine, because wp_options.option_value and every meta column contain at least one serialized array.
The risk is a custom plugin table where every value is a serialized string (s:52:"...", which is what double-serialized data looks like). No row matches the pattern, the column gets SQL, and every length prefix is now wrong.
Our serialized data post explains why that damage is silent.
The fix here is --precise, which forces PHP for every column. Use it whenever --all-tables-with-prefix pulls in plugin tables you did not write.
What it skips
Tables with no primary key
The source comment says it plainly: "Tables without a primary key are skipped." The command needs a key to write back each row, so without one it prints No primary keys for table '<name>' and moves on.
Core tables all have one. Old plugin log and cache tables sometimes do not.
The warning scrolls past in a long run, so read the output rather than only the final line.
Serialized objects it cannot load
When a serialized value holds an object of a class PHP does not know, unserializing it gives a __PHP_Incomplete_Class, and the replacer does not guess.
It prints Skipping an uninitialized class "<ClassName>", replacements might not be complete. and leaves the value exactly as it was.
This happens most often with data from a plugin that is deactivated, because its classes are never loaded. In the package's current main branch it is broader still: unserialize() is called with allowed_classes => ['stdClass'] by default, so any other class is skipped with that warning unless a search_replace_unserialize_options hook allows it.
Rows it could not reserialize
This one prints nothing. If the value that comes back from the replacer has a different type from the value that went in, the source comment says "a needed re-serialization was unsuccessful", and the row is skipped with no warning and no count.
You only find these rows by searching for the old string afterwards. That is the main reason the leftovers check below is not optional.
user_pass, views, and anything you excluded
user_pass is always added to the skip list ("never mess with hashed passwords"), and database views are skipped because they cannot be updated. Both are correct.
They are listed here so a leftovers search that finds them does not alarm you.
It is case-sensitive, and your search is not
Both the SQL and PHP paths match with LIKE BINARY, which compares bytes. https://Old-CDN.example.com in a hand-typed link is not a match for https://old-cdn.example.com, and it stays in the database.
Hostnames are case-insensitive, so browsers follow those links to the old host. To catch them in one pass, use the regex flags the docs show, and accept that they are slow ("about 15-20x longer"):
wp search-replace 'https://old-cdn\.example\.com' 'https://cdn.example.com' --regex --regex-flags='i' --dry-run
JSON-escaped URLs depend on which WP-CLI you have
Block attributes, page builder data and some plugin settings store URLs as JSON, where / is written \/. The stored text is https:\/\/old-cdn.example.com, and a search for https://old-cdn.example.com does not match it.
The command learned to handle this in March 2026 ("Handle JSON-encoded URLs in search-replace", merged 2026-03-11), and now also searches for the JSON-encoded form.
But the newest WP-CLI bundle release, 2.12.0 from May 2025, ships search-replace-command v2.1.8 according to its composer.lock. That version does not contain the change.
So if you installed the standard wp-cli.phar, assume JSON-escaped URLs are not replaced and run a second pass:
wp search-replace 'https:\/\/old-cdn.example.com' 'https:\/\/cdn.example.com' --precise --dry-run
Single quotes keep the backslashes literal in the shell. WordPress VIP's docs make the same point in their section on serialized content in JSON format.
The GUID column
The command's own examples pass --skip-columns=guid, including the production-to-development example. guid is meant to be a permanent identifier for each post, not a link.
If you follow the examples and skip it, expect the leftovers search to find the old host in wp_posts.guid. That hit is expected, not a failure.
Check that it actually got everything
Search for the bare hostname, not the full URL. That catches http:// and https://, protocol-relative //old-cdn... links, and the JSON-escaped form, because the hostname contains no slashes:
wp db search 'old-cdn.example.com' --all-tables-with-prefix --stats
wp db search is documented as "case-insensitive by default", which is the opposite of search-replace. Here that is what you want: it finds the mixed-case leftovers that the replace missed.
Go through every hit and put it in one of three groups:
- Expected:
guidif you skipped it, and values you excluded on purpose. - Skipped with a warning: tables with no primary key, incomplete classes. Fix these one at a time or with the plugin active.
- Skipped silently: everything else. These are the rows that failed to reserialize or were not matched, and the replace report never showed them.
A result of zero hits outside the expected group is the pass condition. "The homepage looks right" is not, because most of what gets missed is in settings, widgets and builder data you will not see on a homepage.
Rehearse on a copy, not on production
A dry run on production tells you how many rows would change. It does not show you the skipped rows, the leftovers, or what the site looks like afterwards, because it writes nothing.
The faster way to learn those things is to run the real command on a disposable copy. Then run the leftovers search, click through the pages that matter, and throw the copy away.
With SandyWP, copy the live site into a sandbox with the free Cloner plugin, then run WP-CLI in it over SSH from the SandyWP CLI:
sandywp ssh my-site --cmd "wp search-replace 'https://old-cdn.example.com' 'https://cdn.example.com' --all-tables-with-prefix --precise --report-changed-only"
sandywp ssh my-site --cmd "wp db search 'old-cdn.example.com' --all-tables-with-prefix --stats"
SSH needs a ready sandbox you own on a paid plan.
One thing to check first: the copy runs at its own address, not the live domain, so rehearse a string the copy still holds, such as an old CDN or asset hostname. Our cloning guide covers what else a copy carries over that no search-replace will touch.
If you cannot use a copy, --export=changes.sql writes the changed rows to a file instead of the database. You can read that file before you apply anything.
When this is overkill
On a small brochure site with core tables only, no page builder and a current WP-CLI, one dry run, one real run and one wp db search is enough. You do not need a rehearsal copy for that.
The copy is worth the time when the site has plugin tables, a page builder, WooCommerce, or a history of deactivated plugins. Those are the sites where the skip list is long, and that list is the part the report does not show you.
