Skip to content

Replacing WordPress database URLs with WP-CLI, not raw SQL

Adityo Guni Waluyo

WP_HOME only fools new pages; old URLs live in the database. WP-CLI search-replace with dry-run and skip-columns=guid is the fix.

TL;DR

Moving a WordPress production dump locally means changing WP_HOME and WP_SITEURL won't fix URLs already stored across posts, meta, and serialized options. Raw SQL REPLACE() is risky because it breaks PHP serialized length prefixes and can overflow column limits. Instead, use wp search-replace with a --dry-run first, always skipping the guid column since those permalinks must never change.

Last week: a production dump in a local container

Last week I moved a WordPress production database dump into a local container for debugging. The site opened fine, but every image and internal link still pointed at the production domain. My first guess: just change the WP_HOME and WP_SITEURL constants in wp-config.php. That was very wrong. The constant does override the siteurl option value, but it never touches the old value stored in the database [8] - so only NEW content gets the right address, while every existing post keeps pointing at production.

The old URLs live in many places: post_content in wp_posts, wp_postmeta, widget options, theme settings. And some of them are wrapped in PHP serialized format, which stores each value together with an explicit string-length prefix like s:22:"https://olddomain.com" [9]. That 22 is a byte-length promise. Change the content without updating the number, and anything that unserializes the row trips over inconsistent data.

Two traps of raw SQL

The shortcut many old tutorials teach is UPDATE ... REPLACE() straight against the database. I almost took that road, and two things talk me out of it every time.

First, REPLACE() is blind to serialized structure. It swaps the URL text without recalculating the length prefix, so s:22 can end up holding a 28-character string. The WordPress docs themselves warn that a database-wide search and replace can break serialization, because themes and widgets store values with the URL length marked [7].

Second, column limits. VARCHAR storage depends on the actual value length plus prefix bytes [10]; if the replacement URL is longer than the original, the row can fail to store depending on the server's strict mode. For tight columns that is not a theoretical risk.

The right way and the GUID golden rule

WP-CLI search-replace was built for exactly this: it handles PHP serialized data correctly and leaves primary key values alone [6]. The flow is always the same in my experience.

Rehearse first: run wp search-replace 'https://olddomain.com' 'http://wordpress.test:8080' --all-tables --dry-run --skip-columns=guid - you get the report without a single row changing [6]. If the replacement counts look sane, run it again without --dry-run.

The --skip-columns=guid flag is not decoration. WordPress stores permalinks as GUIDs, and the rule is absolute: never change the contents of the GUID column, because it is the post's permanent identity for feed readers [7]. Raw SQL knows nothing of that rule; wp-cli bakes it in.

One small habit saves hours: dry-run, read the numbers, then execute. A good migration feels boring, and that is the point.

Sources

  1. WP-CLI, wp search-replace [6]
  2. WordPress docs, Changing The Site URL [7]
  3. WordPress developer docs, wp-config.php [8]
  4. PHP manual, serialize() [9]
  5. MySQL 8.0 manual, Storage Requirements (as archived 2026-09-18) [10]

Related articles