wp-cli/search-replace-command
WP-CLI command for safe search-and-replace across a WordPress database. Update URLs, domains, or any string in all tables with dry-run previews, regex, case-insensitive matching, and familiar WP-CLI syntax—ideal for migrations and environment changes.
wp search-replace is the WP-CLI command for rewriting strings across every row of the WordPress database. It ships bundled with WP-CLI 2.x, so in most environments there is nothing to install — the standalone repo exists so the command can be versioned independently and so the underlying WP_CLI\SearchReplacer class can be reused. The low star count reflects that reality: it is infrastructure you already have, not a package you adopt.
Orient yourself in this order:
wp --info # confirm WP-CLI version and PHP binary
wp help search-replace # the canonical option list
wp search-replace --help | less
Then look at two files in the repo: src/SearchReplacer.php (the PHP engine that walks serialized arrays/objects and rewrites them safely) and README.md (option semantics, especially --precise, --all-tables*, and --export).
The first real use case is almost always a domain swap during a staging push:
# 1. Always have a restore point
wp db export backup-$(date +%F-%H%M).sql
# 2. Dry run and read the per-column report
wp search-replace 'https://staging.example.com' 'https://example.com' \
--skip-columns=guid --dry-run --report
# 3. Execute
wp search-replace 'https://staging.example.com' 'https://example.com' \
--skip-columns=guid --report
# 4. Drop stale values out of the object cache
wp cache flush
Treat --dry-run as mandatory. The report tells you which tables and columns will change and how many rows each contains, which is how you catch a needle that is about to match far more than you intended.
Runbook-style migrations. Keep the sequence fixed: wp db export → --dry-run --report → real run (optionally table-by-table so a timeout is resumable) → wp cache flush → update WP_HOME/WP_SITEURL and $table_prefix in wp-config.php by hand → smoke test. search-replace never touches files, so config constants, .htaccess, and hardcoded paths in theme code are your responsibility.
Chain replacements for protocol and escaped-slash variants. A single pass rarely covers a modern site:
for pair in \
'http://old.test|https://new.com' \
'https://old.test|https://new.com' \
'//old.test|//new.com' \
'old\.test|new\.com' # JSON-escaped slashes from page builders
do
wp search-replace "${pair%%|*}" "${pair##*|}" --skip-columns=guid --report
done
The escaped-slash form (http:\/\/old.test) is stored verbatim inside JSON blobs by Elementor, WPBakery, and similar builders, and the plain URL will not match it.
Scope the target. Default behavior is the current site's tables. Use --all-tables-with-prefix to include every table sharing the site prefix, --all-tables for literally every table in the schema (dangerous if the database is shared with another app), --tables=<t1,t2> to narrow, and --include-columns / --skip-columns to fine-tune. In multisite, --network covers all sites and --url=<site> pins the run to one subsite.
Skip GUIDs. --skip-columns=guid is the near-universal convention: wp_posts.guid is an immutable identifier, not a permalink, and rewriting it breaks feed readers and internal references. Some teams also exclude wp_options rows for _transient_* and _site_transient_* since those are disposable.
Generate a SQL patch for environments without WP-CLI. When production has no shell access, build the migration on staging and ship the SQL:
wp search-replace 'https://staging.example.com' 'https://example.com' \
--skip-columns=guid --export=migration.sql --export_insert_size=500
Then import migration.sql through phpMyAdmin or your host's tooling.
Audit trail. --log=changes.log --log-changed-only records every old→new pair, and --report-changed-only trims the console table. In regulated environments this is the artifact reviewers ask for.
Reuse the engine in custom code. WP_CLI\SearchReplacer is instantiable, which is the extension point for anything the CLI flags don't cover — regex-adjacent work, custom tables, or value transforms:
$replacer = new WP_CLI\SearchReplacer( 'old.test', 'new.com', false );
$new_value = $replacer->run( $raw_value );
Wrap that in a wp eval-file script or a small custom command when you need conditional logic (for example, replacing only in rows matching a post type).
CI guard. In deployment pipelines, run the dry run first and fail the build if the changed-row count exceeds a threshold — a cheap way to catch a needle that is suddenly matching the whole wp_options table.
--precise trades speed for safety. The default path uses SQL REPLACE() where it can, which is fast but oblivious to serialized length prefixes; --precise forces the PHP engine everywhere. Use it on tables you know contain serialized or nested data, and avoid it on large wp_postmeta tables unless you can afford the runtime.
Serialization handling has limits. The replacer unserializes, recurses, and re-serializes, so ordinary wp_options and wp_usermeta blobs are safe. It cannot repair data that was already double-serialized (legacy plugin bugs) or serialized objects whose class isn't loaded at runtime — those become __PHP_Incomplete_Class and may pass through untouched. Grep for suspicious s: prefixes before a big run.
Short needles cause collateral damage. Replacing example.com instead of https://example.com will also hit email addresses, license keys, and API endpoints. Replace the longest unambiguous string first, then broaden. Use --case-insensitive only on recent WP-CLI versions and only deliberately.
No regex. If you need a pattern match, either use MySQL 8's REGEXP_REPLACE via wp db query, or write a small script around SearchReplacer that pre-filters rows.
Cache layers outlive the replacement. Redis/Memcached object caches, page caches, and CDNs will happily serve the old domain after a successful run. Sequence wp cache flush immediately after, and purge the CDN separately.
Plugin bootstrap can interfere. A plugin that filters the_content or short-circuits DB reads during CLI bootstrap can corrupt results or throw fatals mid-run. --skip-plugins --skip-themes isolates the operation from application code — usually the right call for a pure data migration.
Large tables time out. Rather than one monolithic run, iterate table by table (or use --export plus a batched import) so a failure is resumable. Watch --export_insert_size when generating dumps for constrained importers.
It is data-only. Theme/plugin files, wp-config.php, .htaccess, and cron entries stored outside the DB are untouched. Likewise, changing the table prefix requires editing $table_prefix and renaming tables — search-replace only rewrites values.
Verify, don't assume. Re-run the dry run with the old string afterward; a clean report (zero matches) is your proof of completion. Follow with a spot check of wp option get home, wp option get siteurl, and one page render.
How can I help you explore Laravel packages today?