Moving a WordPress site to a new domain looks simple on paper: copy the files, import the SQL dump into the new database and change the site and home URLs. In practice a plain SQL find-and-replace corrupts the database. WordPress stores options, widget settings and page builder data as serialized PHP strings, and those strings carry their own length markers. This guide explains why that breaks naive replacements, which WP-CLI flags do what according to the official command reference, how WP_HOME and WP_SITEURL behave, and what Google documents about redirects and the Change of Address tool.
Learn more about our website migration services to Astro and Next.js if the move is also a rebuild.
1. Why SQL REPLACE breaks WordPress serialized data
WordPress stores many configuration options, widget layouts and theme properties in the wp_options table (and in wp_postmeta, wp_usermeta and wp_termmeta) as serialized arrays or objects. A serialized PHP string declares the data type and the byte length of every string value.
How a serialized PHP string stores length
Consider this serialized string storing a domain setting: s:22:"https://old-domain.com";
In this structure:
sdeclares the type as a string.22says the value is exactly 22 bytes long."https://old-domain.com"is the value itself.
A theme options array with a logo URL looks like this once serialized: a:2:{s:11:"theme_color";s:4:"blue";s:8:"logo_url";s:28:"http://dev.site.com/logo.png";}
Every key and every value carries its length: theme_color is 11 bytes, logo_url is 8, and the URL is 28.
Why a wrong string length makes WordPress drop the option
If you run a standard SQL REPLACE to change the domain, for example from old-domain.com to my-new-company-domain.com, the query rewrites the value but leaves the length marker alone: s:22:"https://my-new-company-domain.com";
The new value is 33 bytes long, but the marker still says 22. When WordPress reads the row it passes it through maybe_unserialize(). PHP reads 22 bytes (https://my-new-company), expects the closing quote, finds a hyphen instead, and unserialize() returns false. The option your theme or plugin expected comes back as false, so the theme falls back to its defaults, widgets disappear and some plugins ask for their license keys again.
The same happens in reverse. Replacing dev.site.com with site.com in the logo example produces a 24-byte URL under a marker that still says 28, and the whole array becomes unreadable, not only the logo.
A replacement only survives a plain SQL REPLACE when the old and new strings have exactly the same length, which is a coincidence, not a method.
Serialized objects and __PHP_Incomplete_Class
If a serialized string represents an object: O:31:"WPPoland\Database\CustomObject":1:{...}
PHP maps the stored class name to a class loaded at runtime. If the class is not loaded when the value is unserialized (for example because the plugin that defines it is inactive, or the migration renamed its namespace), PHP creates a __PHP_Incomplete_Class placeholder instead. Its methods cannot be called, and code that expects the real object fails. Serialization-aware tools unserialize and re-serialize such values; they do not rename classes.
2. How to change the WordPress domain with WP-CLI
WP-CLI ships a search-replace command. Its official reference states that it “intelligently handles PHP serialized data, and does not change primary key values” (WP-CLI command reference). This is the standard method for updating URLs in the WordPress database.
Step 1: Export a database backup
Before touching any rows, export a full SQL backup:
# Export the database to a SQL file
wp db export backup-pre-domain-migration.sqlStep 2: Run a search-replace dry run
Run the command with --dry-run first. According to the reference, it will “run the entire search/replace operation and show report, but don’t save changes to the database.”
# Dry run: report what would change, write nothing
wp search-replace 'https://old-domain.com' 'https://new-domain.com' --all-tables-with-prefix --skip-columns=guid --dry-runThe report is a table with the columns Table, Column, Replacements and Type, one row per column that contains a match, followed by a line such as Success: 245 replacements to be made. (the numbers depend entirely on your site). The Type column shows SQL or PHP: which engine handled that column.
What —precise actually does
The reference describes --precise as: “Force the use of PHP (instead of SQL) for all columns.” By default the command “uses fast SQL queries, but automatically switches to PHP for columns containing serialized data.” So you do not need --precise to protect serialized values; WP-CLI already detects them. Use --precise when you want PHP processing everywhere, for example when you do not trust the detection on a column with unusual content, and accept that the run takes longer.
Which tables: default, —all-tables and —all-tables-with-prefix
By default the command uses only the tables registered to the $wpdb object, which means core tables plus any plugin tables registered there. Two flags widen the scope:
| Flag | Scope according to the WP-CLI reference |
|---|---|
| none | Tables registered to $wpdb |
--all-tables-with-prefix | Any table matching the table prefix, even if not registered on $wpdb |
--all-tables | ALL tables in the database, regardless of the prefix |
Many plugins create their own tables without registering them on $wpdb, so a default run can miss URLs stored there. --all-tables-with-prefix is the safer way to include them. Use --all-tables only when the database belongs to this one site: if two WordPress installs share a database with different prefixes, --all-tables rewrites the other site’s URLs too.
Why to skip the guid column
WordPress documentation on moving a site is unambiguous about the guid column in wp_posts: “Never, ever, change the contents of the GUID column, under any circumstances” (Moving WordPress, developer.wordpress.org). The same page explains that feed readers use the GUID to know whether they have already shown an item, so “changing the GUID will mean that many feedreaders will suddenly display your content” again. The WP-CLI reference uses exactly this case as its example for skipping a column: --skip-columns=guid.
Step 3: Run the replacement
If the dry run report looks right, run the same command without --dry-run. --skip-columns also accepts table-qualified names (table.column), and --skip-tables excludes whole tables, which helps on large databases with log tables:
# Execute, keep GUIDs, include unregistered plugin tables with the site prefix
wp search-replace 'https://old-domain.com' 'https://new-domain.com' --all-tables-with-prefix --skip-columns=guidTo produce a migrated SQL file without touching the live database, use --export, which writes “transformed data as SQL file instead of saving replacements to the database”:
wp search-replace 'https://old-domain.com' 'https://new-domain.com' --skip-columns=guid --export=migrated.sqlStep 4: Fix mixed content after moving to HTTPS
If the site also moves from HTTP to HTTPS, run a second pass for hardcoded insecure references:
# Replace legacy HTTP links with HTTPS links on the new domain
wp search-replace 'http://old-domain.com' 'https://new-domain.com' --all-tables-with-prefix --skip-columns=guidStep 5: Flush permalinks and the object cache
After a successful run, WP-CLI itself prints “Please remember to flush your persistent object cache with wp cache flush” when an object cache is active. Do that, regenerate rewrite rules and confirm the stored URLs:
wp rewrite flush
wp cache flush
wp option get home
wp option get siteurlWordPress multisite domain change
On multisite, add --network to run through “all the tables registered to $wpdb in a multisite install”. The Moving WordPress guide adds manual checks the command does not replace: “manually review both wp_site and wp_blogs regardless”, and review every per-site options table (wp_2_options, wp_3_options and so on) for the home, siteurl and fileupload_url values. The domain column in those network tables holds a bare host name, so a search for https://old-domain.com does not match it. Also update the network constants that the Network Setup screen asked you to add to wp-config.php. The guide’s own directions for changing the site URL “are for single installs of WordPress only”.
3. Set WP_HOME and WP_SITEURL in wp-config.php
If the database still holds the old domain but you need to load the site under the new one, you can override both values in wp-config.php. The wp-config.php documentation states that WP_SITEURL “overrides the wp_options table value for siteurl” and that WP_HOME overrides the value for home “but does not change it in the database”. The home value is the address visitors type; siteurl is the address where the core files reside. Both should include https:// and have no trailing slash.
Temporary wp-config.php URL overrides on staging
The override changes routing and asset URLs, not the stored rows. Links inside post content, post meta and plugin settings still point to the old domain until you run a search-replace. The documentation also warns that “the URL will revert to the old database value if this line is ever removed from wp-config”. Treat the constants as a way into the dashboard, then run the full replacement before launch:
// Override the stored URLs while the database still holds the old domain
define( 'WP_HOME', 'https://new-domain.com' );
define( 'WP_SITEURL', 'https://new-domain.com' );While the constants are defined, the two URL fields on Settings > General cannot be edited, because the values are hardcoded.
Set the site URL from the current hostname
The documentation shows a dynamic form based on $_SERVER['HTTP_HOST'], useful when one codebase runs on local, staging and production hosts:
define( 'WP_HOME', 'https://' . $_SERVER['HTTP_HOST'] );
define( 'WP_SITEURL', 'https://' . $_SERVER['HTTP_HOST'] );It comes with a warning: HTTP_HOST “is created dynamically by PHP based on the value of the HTTP HOST Header in the request, thus possibly allowing for file inclusion vulnerabilities.” When Apache runs with UseCanonicalName On, SERVER_NAME comes from the server configuration instead, and the documentation calls it the safer choice in that case. On a public production server, prefer a fixed value or a host allow-list.
Other ways to change the site URL: wp option update, RELOCATE and functions.php
The WordPress Moving WordPress guide lists several ways to change the two stored values:
- WP-CLI:
wp option update home 'https://example.com'andwp option update siteurl 'https://example.com'. This writes the values towp_optionsand changes nothing else. - functions.php: temporarily add
update_option( 'siteurl', ... )andupdate_option( 'home', ... )to the active theme, load the site a few times, then remove the code. The guide says plainly: “Do not leave this code in the functions.php file.” - RELOCATE: add
define( 'RELOCATE', true );towp-config.php, log in throughwp-login.phpon the new address, check both fields on Settings > General, then remove the line. The guide calls leaving it in place insecure and notes that it updates only the site URL, so other references still need a search-replace. - Direct database edit: after a backup, edit the
siteurlandhomerows in the options table in phpMyAdmin (the table prefix may differ fromwp_).
None of these touch URLs inside content, which is why the search-replace in section 2 remains the main step.
4. Search-replace without SSH: Better Search Replace and Search Replace DB
The WordPress migration guide names two alternatives to WP-CLI for hosts without shell access.
Better Search Replace plugin
If you can still reach the dashboard, the Better Search Replace plugin lists “Serialization support for all tables” among its features and adds its page under the Tools menu:
- Install and activate the plugin, then open Tools > Better Search Replace.
- Enter the old URL in the search field and the new URL in the replace field.
- Select the tables to process.
- Keep the dry run option on for the first pass (it has been the default since version 1.0.1) and review how many fields would change.
- Turn the dry run off and run it again.
It runs inside PHP requests, so on very large tables it can hit the host’s execution time limit, and it is of no use when the dashboard already redirects to the old domain.
Search Replace DB script by interconnect/it
When neither WP-CLI nor the dashboard is available, the guide points to the interconnect/it script “to safely change all instances on your old domain or path to your new one”, with the warning to “only use this option if you are comfortable with database administration”. Upload it to a randomly named folder, run the dry run, then the replacement, and delete the folder immediately afterwards: anyone who finds it can read and rewrite the database.
5. PHP search-replace script for serialized data
If you need the replacement inside your own PHP code (for example in a deployment pipeline without WP-CLI), this class recursively walks arrays and objects, replaces strings and re-serializes, so the length markers are recalculated by PHP itself.
Create it as scripts/class-serialization-replace.php:
<?php
declare(strict_types=1);
namespace WPPoland\Database\Migrator;
class SerializationAwareReplacer {
private string $search;
private string $replace;
public function __construct( string $search, string $replace ) {
$this->search = $search;
$this->replace = $replace;
}
/**
* Entry point to recursively search and replace strings in data.
*/
public function process( $data ) {
if ( is_string( $data ) ) {
// Check if string is serialized
if ( $this->is_serialized_string( $data ) ) {
$unserialized = unserialize( $data );
if ( false !== $unserialized ) {
return serialize( $this->process( $unserialized ) );
}
}
return str_replace( $this->search, $this->replace, $data );
}
if ( is_array( $data ) ) {
$cleaned = [];
foreach ( $data as $key => $value ) {
$cleaned[ $key ] = $this->process( $value );
}
return $cleaned;
}
if ( is_object( $data ) ) {
$cleaned = clone $data;
foreach ( get_object_vars( $data ) as $key => $value ) {
$cleaned->$key = $this->process( $value );
}
return $cleaned;
}
return $data;
}
/**
* Checks if a string is serialized.
*/
private function is_serialized_string( string $string ): bool {
$string = trim( $string );
if ( 'N;' === $string ) {
return true;
}
if ( strlen( $string ) < 4 ) {
return false;
}
if ( ':' !== $string[1] ) {
return false;
}
$last_char = substr( $string, -1 );
if ( ';' !== $last_char && '}' !== $last_char ) {
return false;
}
$token = $string[0];
switch ( $token ) {
case 's':
if ( '"' !== substr( $string, -2, 1 ) ) {
return false;
}
return true;
case 'a':
case 'O':
return (bool) preg_match( "/^{$token}:[0-9]+:/s", $string );
case 'b':
case 'i':
case 'd':
return (bool) preg_match( "/^{$token}:[0-9.E-]+;\$/", $string );
}
return false;
}
}get_object_vars() returns the properties accessible from the calling scope, which here means public ones only, so private and protected properties of objects pass through unchanged. Inside WordPress, core’s is_serialized() and maybe_unserialize() do the detection for you; WP-CLI remains the tested option.
6. Redirect the old domain with 301 rules
Google’s documentation on site moves with URL changes asks for permanent server-side redirects, “such as 301 and 308”, from each old URL to its new counterpart. Its redirects documentation explains that for these permanent codes “the indexing pipeline uses the redirect as a signal that the redirect target should be canonical”; temporary codes (302, 303, 307) do not send that signal.
Nginx 301 redirect that keeps the request URI
Keep the path and query string with $request_uri. Google’s site move guide advises against sending many old URLs to one unrelated page such as the new homepage; a page-to-page map is what the rule below produces:
server {
listen 80;
listen 443 ssl;
server_name old-domain.com www.old-domain.com;
# ssl_certificate and ssl_certificate_key for the old domain go here
# Permanent redirect preserving path and query string
return 301 https://new-domain.com$request_uri;
}The old domain still needs a valid certificate: an HTTPS request to it fails during the TLS handshake before Nginx can send the redirect.
Apache .htaccess 301 redirect to a new domain
Add these rules at the top of the .htaccess file on the old domain:
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteCond %{HTTP_HOST} ^(?:www\.)?old-domain\.com$ [NC]
RewriteRule ^(.*)$ https://new-domain.com/$1 [R=301,L]
</IfModule>The Apache mod_rewrite documentation states that “by default, the query string is passed through unchanged”, so ?page=2 survives the redirect.
Cloudflare Worker redirect at the edge
If the old domain is proxied through Cloudflare, a Worker can answer the redirect without reaching the origin. This follows the module syntax of Cloudflare’s own redirect example:
export default {
async fetch(request) {
const base = "https://new-domain.com";
const { pathname, search } = new URL(request.url);
return Response.redirect(`${base}${pathname}${search}`, 301);
},
};7. SEO migration checklist and Google Search Console setup
How to use the Search Console change of address tool
Google’s Change of Address tool help page sets these conditions:
- Ownership: “You must be an owner of both the old and new properties in Search Console.”
- Domain level only: the tool “can be used only on properties at the domain level”, and it “does not move any subdomains below the specified domain (including www)”. Submit separate requests for www and non-www variants that are in use or were used in the past.
- Redirect first: set up a 301 redirect from the old homepage to the new homepage, and from the other pages. The tool checks that you own both sites and that a few pages return 301 redirects.
- Run it: open the old property, go to Settings, choose Change of address and select the new property.
- Sitemaps: submit the new XML sitemap. Google’s site move guide says warnings about redirecting URLs in the old sitemap are expected while both are submitted.
The tool is not needed for an HTTP to HTTPS move, for switching between www and non-www on the same domain, or for path changes within one domain.
What else to update on the new domain
The site move guide lists the on-site changes that go with the redirects:
- Update internal links so they point to the new URLs directly instead of through a redirect.
- Give each new URL a self-referencing canonical tag.
- If you use hreflang, “be sure to update the annotations to use the new URLs”.
- Check that
robots.txton the new domain does not still carry a stagingDisallow: /.
8. How long to keep redirects after a WordPress domain change
Google gives two figures. The Change of Address help page says the forwarding of signals continues “for 180 days after you start migration in Search Console” and asks you to keep the redirects for at least 180 days, “longer if you still see any traffic to them from Google Search”. The site move guide goes further: keep redirects for as long as possible, generally at least one year. Renewing the old domain registration is part of that, because redirects stop the day the domain lapses.
On timing, the same guide says a medium-sized site “can take a few weeks or more” before Google gradually shows the new URLs, and larger sites take longer. Ranking fluctuation in that window is expected, so judge the move by Search Console data over weeks, not days.
9. Why WordPress still redirects to the old domain after migration
If the site keeps sending you to the old address after the replacement, check these places in order:
- wp-config.php: a forgotten
WP_HOMEorWP_SITEURLconstant with the old value overrides the database. - wp_options: run
wp option get homeandwp option get siteurl, or read the two rows in phpMyAdmin. - .htaccess and server config: some security and redirect plugins write redirect rules there, and an old
serverblock may still exist. - Caches: page cache, object cache (
wp cache flush), a CDN and the browser’s own cache of an earlier 301 can all serve the old target.
10. WordPress domain change checklist
Use this checklist for your WordPress domain migrations:
- Back up the database and the full filesystem, including
wp-content/uploads. - Configure the new host and install SSL certificates for the new domain, and keep a valid certificate on the old one.
- Import the database and update the credentials in
wp-config.php. - Run
wp search-replacewith--dry-runand--skip-columns=guid, and review the report. - Run the replacement for the HTTPS and HTTP variants.
- Remove temporary
WP_HOME,WP_SITEURLorRELOCATElines that are no longer needed. - Flush rewrite rules and caches, then check
homeandsiteurl. - Set up page-to-page 301 redirects on the old domain.
- Update canonical tags, hreflang annotations and internal links.
- Submit the Change of Address in Search Console and the new sitemap.
- Keep the redirects and the old domain registration for at least a year.
Need help migrating your WordPress database or configuring server-level redirect rules? Our WordPress development team can audit your database, run the migration and set up Search Console. Contact us to discuss your project.







