WordPress search-replace: updating URLs after migration or domain change

The complete guide to updating URLs inside a WordPress database after a migration or domain change: why a raw SQL REPLACE breaks serialised data, which tool to use, the Gutenberg JSON-escaped URL trap that needs a second pass, and how to verify the result.

You migrated WordPress to a new host or switched the site to a new domain, and now half the images 404, the page builder renders empty boxes, menu links point to the old URL, and the contact form returns an error after submit. The cause is almost always the same: the database still contains the old URL hardcoded in dozens of places, and the tool that updated it did the wrong kind of replacement, or missed tables, or corrupted serialised data. This article is the dedicated reference for the search-replace step itself. It covers three tools in order of preference, the Gutenberg JSON-escaped URL trap that almost every tutorial misses, and the dry-run and verification workflow that catches problems before they reach production.

The migration walkthrough that surrounds the search-replace step (file copy, DNS switch, testing on the new host) lives in a separate article. If you need the full picture, start with how to migrate WordPress to a new host or domain and come back here for the search-replace detail.

Goal

Run a URL search-replace against a WordPress database so that every hardcoded reference to the old URL, including URLs stored inside serialised PHP and URLs escaped inside Gutenberg block JSON, becomes the new URL, without corrupting data.

Prerequisites

  • A fresh backup of the database. Not yesterday's scheduled backup. A fresh dump, taken right before you start, and stored somewhere outside the server you are about to touch. A search-replace rewrites rows across many tables at once, and a bad run is faster to undo from a backup than from manual corrections.
  • The exact old URL and the exact new URL, with the protocol (https:// vs http://) and no trailing slash. https://example.dev and https://www.example.dev are two different strings and need two separate runs.
  • Write access to the database. Either through WP-CLI (which reads wp-config.php), through WordPress admin access for the plugin method, or through database credentials for the standalone script method.
  • Access to a staging environment or a pre-launch environment where you can run the replace and verify before touching production. Search-replace on a live site is the last resort, not the default.

Why a plain SQL REPLACE() corrupts WordPress

Before the step-by-step, the one thing every WordPress developer needs to internalise: you cannot run UPDATE wp_posts SET post_content = REPLACE(post_content, 'old', 'new') on a WordPress database. The official WordPress migration documentation says it explicitly: "If you do a search and replace on your entire database to change the URLs, you can cause issues with data serialization."

The reason is that WordPress stores a lot of its configuration as serialised PHP, which is the text output of PHP's serialize() function. Widget settings, theme mods, menu structures, page builder layouts (Elementor, Beaver Builder, Divi), and most plugin options are serialised PHP blobs in wp_options, wp_postmeta, wp_termmeta, and similar tables. A serialised array looks like this:

a:2:{s:4:"home";s:22:"https://old-domain.com";s:7:"siteurl";s:22:"https://old-domain.com";}

The s:22 prefix means "the next string is 22 bytes long". If you run REPLACE('https://old-domain.com', 'https://new-domain.com') through SQL, the string value becomes 22 bytes long when the old one was, coincidentally, also 22. Lucky. But https://staging.example.dev to https://example.com changes the byte count. The s:N prefix does not update. PHP's unserialize() reads the prefix, tries to read N bytes, gets a different payload than expected, and returns false. The widget disappears. The menu empties. The page builder renders an empty div. The plugin resets to defaults.

The Delicious Brains article on WordPress migrations and David Coveney's original explanation of the serialisation fix, written by the author of the interconnect/it script, both walk through the exact mechanism in detail.

The fix is to use a tool that unserialises the data, performs the string replacement, recalculates the byte-count prefix, and re-serialises. Three tools do this correctly, listed here from most preferred to least.

WP-CLI wp search-replace is the default choice for anyone with SSH access to the server. It is the fastest, the most flexible, and the one that handles the widest range of edge cases. It also has a proper dry-run mode, which the other tools offer but WP-CLI executes more transparently.

Step 1: Dry-run the replacement

Always run the dry-run first. It shows you which tables and columns would be touched and how many replacements would happen, without writing anything. Compare the count against your expectations before committing.

# Dry-run: reports what would change without writing.
# --skip-columns=guid is mandatory, see below.
# --all-tables-with-prefix covers plugin and HPOS tables.
wp search-replace 'https://old-domain.com' 'https://new-domain.com' \
  --all-tables-with-prefix \
  --skip-columns=guid \
  --dry-run

Expected output: a table listing every column processed, with a replacement count per column, ending with a line like Success: 847 replacements to be made. If the count is zero, the old URL is not in the database (check the exact string, including protocol and any www. prefix). If the count is suspiciously high, something else matches the old URL pattern and you should investigate before committing.

Step 2: Commit the replacement

Only after the dry-run count looks right, run the real command by dropping --dry-run:

# Commit: actually writes the changes.
wp search-replace 'https://old-domain.com' 'https://new-domain.com' \
  --all-tables-with-prefix \
  --skip-columns=guid

Expected output: the same table as the dry-run, ending with Success: 847 replacements to be made. The count should match the dry-run exactly. If it does not, something changed in the database between the two commands (another user writing, a cron job, a plugin running) and you should restore the backup and investigate before a second attempt.

The three flags that matter

--skip-columns=guid is non-negotiable. The WordPress migration documentation is explicit: "Never, ever, change the contents of the GUID column, under any circumstances." GUIDs in wp_posts are permanent identifiers for RSS feed readers so they do not re-display old posts as new. Changing them makes every RSS subscriber see every post as new. The flag exists specifically to protect you from accidentally including it.

--all-tables-with-prefix replaces in every table that starts with the WordPress table prefix (wp_ by default), including plugin-created tables that are not registered with the WordPress $wpdb object. This is the flag that catches WooCommerce HPOS tables (wp_wc_orders, wp_wc_order_addresses, wp_wc_order_operational_data, wp_wc_orders_meta), Yoast indexable tables, Wordfence scan tables, and similar. Without this flag, WP-CLI only processes tables it knows about from $wpdb, and your HPOS orders keep pointing at the old domain.

--precise (optional but recommended for migrations) forces WP-CLI to use PHP-based processing instead of the default SQL fast path. The SQL path auto-switches to PHP only when it detects serialised data, and the detection is based on column-type heuristics. --precise removes the heuristic and processes every row through PHP, which is slower but correct in every case. For databases under a few gigabytes the time penalty is invisible.

# Migration-safe variant with --precise.
wp search-replace 'https://old-domain.com' 'https://new-domain.com' \
  --all-tables-with-prefix \
  --skip-columns=guid \
  --precise

Step 3: Explicitly update siteurl and home

The siteurl and home rows in wp_options control where WordPress thinks it lives. The search-replace updates them, but a common gotcha is that they were cached in an object cache or set in wp-config.php as constants, in which case the database row is not the source of truth. Update them explicitly and flush the cache so the state is unambiguous:

# Belt-and-suspenders: update the two rows explicitly after the replace.
wp option update home 'https://new-domain.com'
wp option update siteurl 'https://new-domain.com'

# Flush any object cache that might still hold the old values.
wp cache flush

Expected output: Success: Updated 'home' option. and Success: Updated 'siteurl' option. If either prints Success: Value passed for 'home' is unchanged., the search-replace already covered it, which is the normal case.

Method 2: Better Search Replace plugin (when SSH is not available)

When the host only provides a web-based file manager and no SSH access, the Better Search Replace plugin is the right tool. It is maintained by WP Engine, has more than a million active installations, and was tested with WordPress 6.9.4 at the time of writing. Version 1.4.5 (January 2024) added allowed_classes => false to the unserialize call after a Wordfence security disclosure, so keep the plugin updated.

  1. Install Better Search Replace from Plugins > Add New and activate it.
  2. Go to Tools > Better Search Replace.
  3. In Search for, enter https://old-domain.com. In Replace with, enter https://new-domain.com. Do not include a trailing slash.
  4. In Select tables, select every table in the list. Click the first, scroll to the last, shift-click the last to select all.
  5. Leave Case-Insensitive? unchecked. URLs are case-sensitive on the network level; enabling this flag produces unpredictable replacements.
  6. Leave Replace GUIDs? unchecked. Same reason as the --skip-columns=guid flag in WP-CLI.
  7. Check Run as dry run? and click Run Search/Replace. The plugin returns a summary: number of tables searched, number of cells examined, number of cells changed. Verify the counts look reasonable.
  8. Uncheck Run as dry run? and click Run Search/Replace again to commit.

Verify the result: the plugin summary should show the same number of changes as the dry-run. If it shows more or fewer, a change happened in the database between the two runs and you should restore the backup before investigating.

Better Search Replace handles PHP serialised data correctly. What it does not document is whether it handles Gutenberg JSON-escaped URLs, which leads directly to the next section.

Known caveat in Better Search Replace

A February 2026 review on WordPress.org reported unexpected replacement of unrelated content in a WooCommerce installation when searching for a short string. The lesson is the same as with every search-replace tool: search for the full URL including the protocol, not a fragment, and always dry-run first. Searching for example will match every row in the database that contains the word example, which is rarely what you want.

Method 3: Search Replace DB script (last resort)

The interconnect/it Search Replace DB script is a standalone PHP script that connects directly to the database and runs the replacement. It was the original tool both WP-CLI's search-replace and Better Search Replace derive their logic from. Version 4.1.4 was released on April 11, 2025, and added PHP 8.x compatibility. The source lives on GitHub.

Use this script only when WP-CLI is not available (the site is broken and WordPress will not bootstrap at all, or WP-CLI is not installed on the server) and Better Search Replace cannot be installed (no WordPress admin access, because the admin is broken). Those conditions are rare.

The security warnings are non-negotiable:

  • The script provides web-accessible database credentials input and full read/write access to every table. Anyone who finds the URL can brute-force it or scrape credentials.
  • Upload it to a randomly named directory (/xy7kq3z9/, not /srdb/ and not the site root). The directory name is the only thing stopping random scanners from finding it.
  • Delete the entire script directory immediately after use, on every public-facing server, without exception. The official page requires you to check a box acknowledging this before downloading.
  • Run it over HTTPS so database credentials are not sent in plain text. If HTTPS is not available, change the database password immediately after the replacement completes.
  • For maximum security, interconnect/it recommends running the script on a separate server and piping the database connection over SSH, rather than uploading to the target host.

The script's interface is a web form: enter the search string, the replace string, the database credentials if the script cannot read them from wp-config.php, select tables, and run. It has a Dry Run button, a Live Run button, and a Delete me button that removes the script directory when you are done. Click the delete button when the replacement is complete, then verify over SSH or FTP that the directory is actually gone.

The Gutenberg JSON-escaped URL trap

This is the single most common production-breaking edge case in WordPress search-replace, and it is almost never in tutorials. It is worth reading even if you have done a hundred migrations with WP-CLI and never hit it.

Why it happens

The Gutenberg block editor, default since WordPress 5.0 (December 2018), stores block content in wp_posts.post_content as HTML with comment delimiters that contain JSON block attributes. An image block looks like this in the database:

<!-- wp:image {"id":42,"url":"https:\/\/old-domain.com\/image.jpg","alt":"hero"} -->
<figure class="wp-block-image"><img src="https://old-domain.com/image.jpg" alt="hero"/></figure>
<!-- /wp:image -->

Notice two URL forms in the same block. The src attribute inside the rendered HTML contains the plain URL: https://old-domain.com/image.jpg. The JSON block attribute inside the HTML comment contains the JSON-escaped URL: https:\/\/old-domain.com\/image.jpg. The forward slashes are escaped as \/ because that is how PHP's json_encode() escapes them by default.

A normal wp search-replace 'https://old-domain.com' 'https://new-domain.com' replaces the plain form. It does not replace the escaped form. The image renders correctly on the front end (the src attribute is fixed), but when you open the post in the block editor, Gutenberg reads the JSON attributes, sees the old URL, and silently falls back to unstyled placeholders. Any block attribute with a URL in it, including links inside a button block, embed blocks, cover blocks with a background image, and gallery blocks, is affected.

The WP-CLI issue #5293 tracked this as a feature request. It was closed in October 2023 as "not planned", meaning wp-cli will not handle this automatically. The workaround is a second explicit pass.

The second pass

After the first search-replace, run a second one for the escaped form:

# Second pass: JSON-escaped URLs inside Gutenberg block attributes.
# Double quotes in bash so the backslashes are literal, not escape characters.
wp search-replace "https:\/\/old-domain.com" "https:\/\/new-domain.com" \
  --all-tables-with-prefix \
  --skip-columns=guid

Expected output: Success: N replacements to be made. where N is the number of block-attribute URLs in the database. On a site with many Gutenberg posts, this can be several hundred. On a site that uses only Classic Editor, it is usually zero, and the command is a no-op you can safely skip.

The shell quoting matters. Use double quotes in bash so the shell passes the literal backslashes to WP-CLI. Single quotes work the same way here because bash does not interpret backslashes inside single quotes either, but double quotes are the safer default.

If the site uses an uncommon URL form like https:\/\/ (escaped) inside a shortcode or a Custom Field, a third pass for that specific form may be needed. Search the database for \/\/ with a dry-run first to see whether anything would be matched:

# Diagnostic dry-run to find escaped URLs you did not know about.
wp search-replace 'old-domain.com' 'new-domain.com' \
  --all-tables-with-prefix \
  --skip-columns=guid \
  --dry-run \
  | grep -i "domain"

Does Better Search Replace handle this automatically?

As of version 1.4.10 (January 2025), the Better Search Replace plugin's documentation does not claim to handle JSON-escaped URLs in Gutenberg block attributes. The plugin's serialisation logic targets PHP serialised strings, not JSON-encoded strings inside HTML comments. In testing on a WordPress 6.7 site with Gutenberg content, the plugin left \/ escaped URLs in place after a single run, which is the same behaviour as WP-CLI without the second pass. If you are using Better Search Replace on a Gutenberg-heavy site, run a second pass searching for the escaped form.

Tables that need updating

A common misconception is that only wp_options needs updating. The table below lists every default WordPress table that can contain hardcoded URLs, plus the common plugin tables that WooCommerce installs. The --all-tables-with-prefix flag covers all of these automatically.

Table Contains URLs? What kind
wp_options Yes siteurl, home, widget settings, theme mods, plugin options (often serialised).
wp_posts.post_content Yes Content body: image embeds, links, Gutenberg block attributes, Classic Editor markup.
wp_posts.guid Yes, but never replace Permanent identifier for RSS. Skipped by --skip-columns=guid.
wp_postmeta Yes Custom fields, featured image URLs, page builder layouts (Elementor, Beaver Builder, Divi), SEO plugin metadata. Heavy serialised content.
wp_commentmeta Rarely Introduced in WordPress 2.9 (December 2009). Can contain plugin-stored URLs but usually does not. Still covered by --all-tables-with-prefix.
wp_termmeta Rarely Introduced in WordPress 4.4 (December 2015). Taxonomy term metadata; some SEO plugins store URLs here.
wp_usermeta Rarely User profile data. Some plugins store avatar URLs.
wp_links Yes Legacy link manager (disabled by default since WordPress 3.5, still in the schema).
wp_wc_orders Yes WooCommerce HPOS order table, default since WooCommerce 8.2 (October 2023). May contain URLs from order metadata.
wp_wc_order_addresses Rarely Billing and shipping addresses. URLs not typical.
wp_wc_order_operational_data Rarely Order operational state. URLs not typical.
wp_wc_orders_meta Yes HPOS order meta. Plugins can store download URLs, payment gateway return URLs, refund links here, often serialised.

The older claim that wp_commentmeta and wp_termmeta were introduced in WordPress 5.3 is wrong and appears in several older tutorials. wp_commentmeta has been in the schema since WordPress 2.9 in 2009. wp_termmeta arrived in WordPress 4.4 in 2015. Both long predate WordPress 5.3, which was released in November 2019. If a tutorial claims otherwise, it is out of date on other facts too.

The wp_posts.guid warning, one more time

The GUID column deserves its own paragraph because the warning is repeated for a reason. It is a permanent identifier that RSS feed readers use to decide whether a post is new. Once a GUID is set for a post, it never changes, even if the post's URL changes. The WordPress migration documentation is explicit: "Never, ever, change the contents of the GUID column, under any circumstances." Skipping the column with --skip-columns=guid is how you avoid breaking RSS for every existing subscriber. A search-replace that includes the GUID column will cause every RSS reader in the world to display every post of the affected site as new.

Verifying the result

After the replace, do not trust the success message on its own. Verify that the database actually contains the new URL and nothing of the old URL:

# Count remaining occurrences of the old URL across the database.
# --search prints matching rows; combine with --format for a count.
wp db search 'old-domain.com' --all-tables-with-prefix | wc -l

Expected output: zero, or close to zero. Any remaining occurrences are either in the guid column (fine, expected, left alone on purpose) or in a table that was not covered by the run. If the count is nonzero, run the command without wc -l to see which rows contain the old URL and decide whether they need a targeted second pass.

Then check a few URL-critical spots through the UI:

  • Load the homepage. Images should load; menus should point to the new domain.
  • Log into wp-admin. The address bar should show the new domain. Settings > General should show the new URL in both WordPress Address and Site Address.
  • Open a post in the block editor. Every block should render with its content, not an empty placeholder. This is the test that catches the Gutenberg JSON-escaped trap.
  • For WooCommerce: open WooCommerce > Orders, click into a recent order, and verify that any stored URLs (customer-facing order URL, download links) point to the new domain.
  • Visit Settings > Permalinks and click Save Changes once without editing anything. This regenerates the .htaccess rewrite rules, which do not need changing but sometimes get stuck referencing the old domain after a migration.

If any of these fail, the most likely causes are the Gutenberg second pass, an object cache holding stale values (wp cache flush and a browser hard reload), or a plugin that stores URLs outside the database in a file on disk (rare, but Elementor's file cache is one example: delete wp-content/uploads/elementor/css/ after a URL change and let it regenerate).

Common myths worth burning

Three things tutorials and forums say that are wrong, for the record:

"phpMyAdmin's REPLACE() is fine for small sites." It is not. The serialised-data problem does not depend on site size. The first row with a serialised widget breaks, and you will not notice until the widget fails to render an hour later. Use WP-CLI or Better Search Replace, even on a five-page brochure site.

"Only wp_options needs replacing." It does not. The wp_posts.post_content column contains every image embed, link, and Gutenberg block URL in every post. wp_postmeta holds Elementor layouts, Beaver Builder data, Advanced Custom Fields values, and Yoast SEO metadata, all full of URLs. WooCommerce HPOS puts order data in its own tables. Replacing only wp_options leaves a broken site behind.

"Staging-to-production push does not need search-replace if both environments have the same URL." Almost always wrong. The common case is that staging runs on staging.example.com and production runs on example.com, and the URLs differ. The only exception is when staging uses a local hosts-file entry to share the exact production domain, which is unusual. Even then, transients should be cleared and siteurl and home verified. The WordPress migration documentation recommends DELETE FROM wp_options WHERE option_name LIKE '%\_transient\_%' after any migration, same-URL or not.

Complete final configuration

Here is the complete sequence for a WP-CLI migration from https://old-domain.com to https://new-domain.com, including every step in order. This is the reference you paste into a runbook.

# 1. Back up the database before touching anything.
wp db export /tmp/pre-search-replace-$(date +%Y-%m-%d).sql

# 2. Dry-run the primary search-replace.
wp search-replace 'https://old-domain.com' 'https://new-domain.com' \
  --all-tables-with-prefix \
  --skip-columns=guid \
  --dry-run

# 3. Commit the primary search-replace.
wp search-replace 'https://old-domain.com' 'https://new-domain.com' \
  --all-tables-with-prefix \
  --skip-columns=guid \
  --precise

# 4. Second pass: Gutenberg JSON-escaped URLs.
wp search-replace "https:\/\/old-domain.com" "https:\/\/new-domain.com" \
  --all-tables-with-prefix \
  --skip-columns=guid

# 5. Explicit option updates (belt-and-suspenders).
wp option update home 'https://new-domain.com'
wp option update siteurl 'https://new-domain.com'

# 6. Clear transients.
wp transient delete --all

# 7. Flush object cache and rewrite rules.
wp cache flush
wp rewrite flush

# 8. Verify: no remaining occurrences of the old URL (except in guid).
wp db search 'old-domain.com' --all-tables-with-prefix --skip-columns=guid

For multisite, add --network to every wp search-replace command so it covers per-site tables. If the site has non-prefixed plugin tables (rare, but some legacy plugins use them), replace --all-tables-with-prefix with --all-tables and watch the dry-run output carefully, because --all-tables also touches non-WordPress tables that share the database.

For the surrounding migration steps (file copy, database dump, DNS switch, hosts-file testing), the WordPress migration guide covers the full walkthrough. For a deeper look at what the wp-config.php constants in that article mean, see the wp-config.php reference.

Want WordPress hosting that stays stable?

I handle updates, backups, and security, and keep performance steady—so outages and slowdowns don't keep coming back.

Explore WordPress maintenance

Search this site

Start typing to search, or browse the knowledge base and blog.