Importing, Exporting and Migrating

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

This sub-section’s WordPress bibliography lists the reference material consulted while preparing these pages.

WordPress has two different ways of moving content. Exporting and importing copies content (posts, pages, custom post types, comments, terms, custom fields and authors) as an XML file from one site into another, and leaves everything else behind. Migrating moves a whole site, files and database, to another server, directory or domain. Site owners use the first to bring a blog in from another platform or to merge sites; administrators use the second to change hosts, go from staging to a new production server, or rename a domain. This page explains both for the Lumen Press site, including the step that breaks most hand-made migrations: replacing URLs inside PHP-serialized data.

This page was written against WordPress 7.1.3 (released 2026-10-06; 7.1.4, a security release of 2026-10-28, is the version to run in production), PHP 7.4 minimum and 8.3+ recommended, WP-CLI 2.12.0 and the WordPress Importer plugin 0.9.7, and checked on 2026-10-28. Every command output below comes from two containers (wordpress:7.1.3-php8.4-apache sites sharing a mariadb:11.8 server, driven by wordpress:cli-2.12.0-php8.4): an "old" Lumen Press site at https://lumenpress-books.test moved to a "new" one at https://lumenpress.test. WordPress 7.2 (in beta, planned for 2026-12-08) does not change export, import or the URL settings so far. Setting up such containers is described in Local Development Environments, which also shows the short version of a production-to-staging copy.

Choosing the right tool

Task Tool What moves What does not move

Bring posts in from Blogger, Tumblr, an RSS feed or another WordPress site

Tools > Import (an importer plugin), wp import

Posts, pages, custom post types, comments, terms, custom fields, authors (as new users or mapped to existing ones), optionally attachments

Settings, themes, plugins, widgets, user passwords and roles, plugin tables

Copy a selection of content (one post type, one author, a date range)

Tools > Export, wp export

The same, filtered

The same

Move the whole site to another server, keeping the domain

Files plus wp db export/wp db import

Everything

Nothing, if wp-config.php and server rules are adapted

Move the whole site and change its URL

The above plus wp search-replace

Everything, with the URLs rewritten

Nothing; the GUIDs stay as they were on purpose

Combine several sites into one

WXR export of each, import into the target

Content and authors

Settings and widgets of the sources

Keep a backup

wp db export plus a copy of wp-content (see Backups and Disaster Recovery)

Everything

Nothing

A WXR file is not a backup. The comment WordPress writes at the top of every export file says so: settings, user accounts, themes, plugins, the media files themselves and any custom table are missing from it.

Exporting content (WXR)

Tools > Export

Tools > Export (wp-admin/export.php, capability export, which Administrators have) generates a WordPress eXtended RSS (WXR) file: an RSS 2.0 document with extra elements in the http://wordpress.org/export/1.2/ namespace. The screen offers:

  • All content: posts, pages, comments, custom fields, terms, navigation menus and every post type registered with can_export (the default of register_post_type()), including attachments and block-theme objects stored as posts

  • Posts: filtered by category, author, date range and status

  • Pages: filtered by author, date range and status

  • Media: attachment records, filtered by date range

  • one radio button per custom post type that has show_ui and can_export, such as Books for lp_book

Download Export File streams the XML to the browser. Large sites may hit the PHP max_execution_time or memory limit here, which is when wp export is the better tool.

The exported file for the two Lumen Press books starts like this (shortened; the date line and IDs depend on the site):

<?xml version="1.0" encoding="UTF-8"?>
<!-- generator="WordPress/7.1.3" created="2026-10-28 10:34" -->
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"
  xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:wp="http://wordpress.org/export/1.2/">
  <channel>
<wp:wxr_version>1.2</wp:wxr_version>
<wp:base_site_url>https://lumenpress-books.test</wp:base_site_url>
<wp:base_blog_url>https://lumenpress-books.test</wp:base_blog_url>
<wp:author>
  <wp:author_id>2</wp:author_id>
  <wp:author_login>tom.author</wp:author_login>
  <wp:author_email>tom@lumenpress.test</wp:author_email>
  <wp:author_display_name><![CDATA[Tom Author]]></wp:author_display_name>
</wp:author>
<item>
  <title><![CDATA[The Lantern Keeper]]></title>
  <dc:creator>tom.author</dc:creator>
  <guid isPermaLink="false">https://lumenpress-books.test/books/the-lantern-keeper/</guid>
  <content:encoded><![CDATA[<!-- wp:paragraph --><p>See also <a href="https://lumenpress-books.test/books/night-ferry/">Night Ferry</a>.</p><!-- /wp:paragraph -->]]></content:encoded>
  <wp:post_id>4</wp:post_id>
  <wp:post_name>the-lantern-keeper</wp:post_name>
  <wp:status>publish</wp:status>
  <wp:post_type>lp_book</wp:post_type>
  <category domain="lp_genre" nicename="mystery"><![CDATA[Mystery]]></category>
  <wp:postmeta>
    <wp:meta_key>lp_isbn</wp:meta_key>
    <wp:meta_value><![CDATA[978-0-00-000001-9]]></wp:meta_value>
  </wp:postmeta>
</item>
  </channel>
</rss>

Points worth knowing about the format:

  • Block content travels as serialized block markup inside content:encoded, so blocks survive the trip as long as the target site has the same blocks registered. A block whose plugin is missing on the target shows as an unsupported block in the editor; its markup is kept.

  • Every author of an exported item is listed with login, e-mail and display name, never with a password hash or role.

  • Terms appear on each item (<category domain="lp_genre">); with All content they are also listed once at the top (<wp:category>, <wp:tag>, <wp:term>), with their hierarchy and term meta.

  • Attachments are items of type attachment with a <wp:attachment_url> element that points at the file on the source site. The file is not inside the XML.

  • The lp_isbn, lp_pages and lp_published_on meta are exported as text; any serialized meta value is exported as its serialized string.

wp export

wp export (part of WP-CLI since its early versions, now in the wp-cli/export-command package bundled with WP-CLI 2.12.0) builds the same WXR from the command line, without browser timeouts, and splits large exports into files of --max_file_size megabytes (15 by default, -1 for no limit). Its filters mirror the screen and add a few: --post_type, --post__in (with --with_attachments to keep the attachments of those posts), --start_id, --author, --start_date/--end_date, --post_status, --category, --max_num_posts, --skip_comments and --include_once (put terms and menus only in the first of several files). The file name follows --filename_format, default {site}.wordpress.{date}.{n}.xml.

# Only the book catalogue, into /xfer
wp export --dir=/xfer --post_type=lp_book --filename_format='lumen-books.{date}.{n}.xml'

# Everything, written to standard output
wp export --stdout > lumen-full.xml
Starting export process...
Writing to file /xfer/lumen-books.2026-10-28.000.xml
Success: All done with export.

Importing content

Tools > Import and the WordPress Importer

Tools > Import (wp-admin/import.php, capability import) lists the importers WordPress.org offers. None of them is part of core: each row has an Install Now link that installs a plugin from the directory, after which the row’s link becomes Run Importer. The one for WXR files is the WordPress Importer (wordpress-importer), maintained by the WordPress project. Version 0.9.7 (October 2026) requires WordPress 5.2 and PHP 7.2; its directory page still marks it as tested only up to 6.8, which produces the "not tested with the latest 3 major releases" warning even though it is the official importer. Recent versions changed a lot:

Version Change

0.8

Raised the minimum WordPress and PHP versions; PHP 8.0 to 8.2 fixes

0.9.0

Replaced the deprecated regular-expression parser with a new XML parser (WXR_Parser_XML_Processor)

0.9.1 to 0.9.5

Rewrite the source site’s URLs in post content, excerpts, block attributes, attachment URLs and CSS inside block markup

0.9.6

Skips the _wp_font_face_file meta; namespaced classes to avoid conflicts

0.9.7

Stops importing the _wp_attachment_edit_root_id meta, whose attachment ID would point at the wrong file

Running it from the admin takes four steps:

  1. Tools > Import > WordPress > Run Importer, then choose the .xml file and Upload file and import. The upload is limited by PHP’s upload_max_filesize and post_max_size (the screen prints the maximum), which is the usual reason to split exports or use WP-CLI.

  2. Assign Authors: for each author in the file, either enter a login to create a new user or pick an existing user. If neither is chosen, the content goes to the current user.

  3. Tick Download and import file attachments if the media files should be fetched from the URLs in the file. The source site must still be online and reachable from the target server for this to work.

  4. Wait for "All done. Have fun!". The importer then reminds the administrator to update the passwords and roles of imported users.

wp import

wp import drives the same plugin. It refuses to run until the plugin is installed and active:

wp plugin install wordpress-importer --activate
wp import /xfer/lumen-books.2026-10-28.000.xml --authors=create
wp user list --fields=ID,user_login,roles
Starting the import process...

Processing post #4 ("The Lantern Keeper") (post_type: lp_book)
-- 1 of 2 (in file lumen-books.2026-10-28.000.xml)
-- Imported post as post_id #4
-- Created term "Mystery"
-- Added terms (2) for taxonomy "lp_genre"
-- Added post_meta lp_isbn
-- Added post_meta lp_pages
-- Added post_meta lp_published_on
...
Success: Finished importing from '/xfer/lumen-books.2026-10-28.000.xml' file.
ID      user_login      roles
1       admin           administrator
2       tom.author      subscriber

--authors is required and takes create (create the missing users), skip (no mapping; content goes to the user running the command) or the path of a CSV file. With a path that does not exist yet, WP-CLI writes the file and stops, so it can be edited:

wp import /xfer/lumen-books.2026-10-28.000.xml --authors=/xfer/authors.csv
cat /xfer/authors.csv
Error: Please update author mapping file before continuing: /xfer/authors.csv
old_user_login,new_user_login
tom.author,tom.author
admin,admin

Editing the second column (for example admin,lena.catalogue) and running the same command again imports with that mapping. --skip=attachment leaves out attachments and --skip=image_resize skips generating intermediate sizes, which saves time on large imports (run wp media regenerate later). The command reference also lists --rewrite_urls, --importer and --source-dir (look for attachment files in a local directory before downloading them), but they are not in a stable release yet: WP-CLI 2.12.0 answers unknown --rewrite_urls parameter, and they need a nightly build (wp cli update --nightly).

What the import run showed

The Lumen Press runs above and the full-site import that followed exposed the importer’s behaviour that matters in practice:

  • Created users get the default role. tom.author was an Author on the old site and arrived as a Subscriber, without a password. Reset passwords (wp user reset-password) and roles (wp user set-role tom.author author) after the import.

  • Old URLs stay in the content when importing from WP-CLI 2.12.0. The link to Night Ferry still pointed at https://lumenpress-books.test/books/night-ferry/. Run wp search-replace on the target afterwards (see below).

  • GUIDs are kept. Imported posts keep the source site’s GUID, which is what feed readers expect.

  • Attachments need the old site online. With the old host unreachable, the import printed Failed to import Media "lantern-keeper-cover", but The Lantern Keeper kept its _thumbnail_id meta pointing at attachment 7, which did not exist on the target. Either import while the old site is up, copy wp-content/uploads across and run wp media import, or remove the dangling meta.

  • Re-running an import duplicates meta. The importer recognises a post that already exists (same post type, title and date) and prints "already exists", but it still adds the post’s meta and terms again: after a second run each book had two lp_isbn rows. A failed import should be cleaned up (or the target restored from a backup) before trying again, which is also what the official documentation recommends.

  • IDs are not guaranteed. The importer asks for the source ID and gets it on an empty site; on a site with content it gets new IDs and remaps parents, featured images and menu items that it imported in the same run. IDs stored in custom meta, options or block attributes (a lumen/latest-books block configured with a list of post IDs, for example) are not remapped.

  • Memory. Very large files exhaust PHP memory ("Allowed memory size …​ exhausted"). Split the export with --max_file_size or --post_type, raise WP_MAX_MEMORY_LIMIT or the CLI memory_limit, and import from WP-CLI.

Restricting what imports may do

The WordPress Importer has filters that a site can set from a must-use plugin, for example to stop administrators from creating accounts through imports on the Lumen Press production site:

<?php
/**
 * Plugin Name: Lumen Import Policy
 * Description: Restricts what the WordPress Importer may create on the Lumen Press site.
 */

defined( 'ABSPATH' ) || exit;

// Imported posts must be mapped to existing accounts; no new users from WXR files.
add_filter( 'import_allow_create_users', '__return_false' );

// Refuse attachments larger than 20 MB when the importer downloads them.
add_filter(
	'import_attachment_size_limit',
	static function () {
		return 20 * MB_IN_BYTES;
	}
);

import_allow_fetch_attachments returning false disables downloading attachments altogether. These filters govern the admin importer screen. wp import --authors=create creates users itself: with this must-use plugin active, the WP-CLI run above still created tom.author. Protect the command line by controlling who can run WP-CLI on the server.

Importing is a privileged operation. A WXR file can contain any post type, any meta and any author e-mail, and the attachment step makes the server download URLs named in the file. Import only files from a trusted source, never a file a third party sent, and on a staging copy first.

Importers for other platforms

The rows on Tools > Import, all of them plugins installed on demand:

Importer Plugin What it reads

Blogger

blogger-importer

The backup XML file exported from Blogger’s settings (since 0.9 a file, no longer the Blogger API): posts, comments, authors

Blogroll

opml-importer

Links in OPML format into the Link Manager, which is hidden on new sites since 3.5

Categories to Tags Converter

wpcat2tag-importer

Not an import: converts existing categories to tags, and back, selectively

LiveJournal

livejournal-importer

Posts and comments from a LiveJournal journal

Movable Type and TypePad

movabletype-importer

The export file of a Movable Type or TypePad blog

RSS

rss-importer

Items of an RSS feed file: titles, dates, content and categories only

Tumblr

tumblr-importer

Posts and media through the Tumblr API, authorised with OAuth

WordPress

wordpress-importer

WXR files

Several of these plugins are no longer actively developed: on 2026-10-28 the Blogger importer (0.9.3) was tested up to 6.6 and the Tumblr importer (1.3) up to 6.7. They still work for simple content, but test on a copy, and expect to clean up shortcodes, embedded HTML and image links by hand afterwards. Imported posts arrive as Classic blocks; the block editor’s Convert to blocks turns them into blocks one post at a time.

The Advanced Administration Handbook’s "Importing Content" page lists tools for other systems, many of them commercial or third-party: Drupal and Joomla migrators, a CSV/XML importer (WP All Import), and conversions for Textpattern, Serendipity, Typo and others. WooCommerce products and tax rates are imported from CSV files by WooCommerce itself (see Products and Product Types and Taxes, Coupons, Currencies and i18n). A platform without an importer can usually export RSS or CSV; failing that, a script that creates posts with wp post create or wp_insert_post() (the approach of the Lumen Press wp lumen import command, see WP-CLI) is more reliable than an unmaintained importer.

Older books show the import screen as if the platform importers were part of WordPress, and one shows Press This under Tools. Since 4.9 Press This is a separate plugin and every importer, the WordPress one included, is installed from the plugin directory when it is first used.

Moving a whole site

A migration moves the files and the database together and then fixes everything that stores the old location. The Lumen Press checklist:

flowchart TD A["Plan: target stack matches PHP, database and web server versions"] --> B["Back up files and database; test the backup"] B --> C["Lower DNS TTL; announce a content freeze"] C --> D["Copy wp-content: uploads, themes, plugins, mu-plugins"] D --> E["wp db export on the source"] E --> F["Adapt wp-config.php: DB credentials, salts, WP_ENVIRONMENT_TYPE"] F --> G["wp db import on the target"] G --> H{"Does the URL change?"} H -- "yes" --> I["wp search-replace with --dry-run, then for real; never the guid column"] H -- "no" --> J["Test through a hosts-file entry"] I --> J J --> K["Flush caches and rewrite rules; regenerate server rules"] K --> L{"All checks pass?"} L -- "no" --> M["Fix, or restore from the backup"] M --> J L -- "yes" --> N["Switch DNS; 301 redirects from the old domain"] N --> O["Monitor logs, Site Health, search console; keep the old site read-only for a while"]

Figure: the migration checklist used for Lumen Press, from preparation to monitoring after the DNS switch.

Files

Core does not need to be copied: install the same WordPress version on the target (wp core download --version=7.1.3 or the matching container image) and copy only what is site-specific:

  • wp-content/uploads (the media library), wp-content/themes, wp-content/plugins, wp-content/mu-plugins and any drop-ins (object-cache.php, advanced-cache.php, db.php). Caches under wp-content/cache are not worth moving.

  • wp-config.php, adapted rather than copied: new database host, name, user and password, the same table prefix, fresh keys and salts if the old server may be compromised (all users are logged out), and the right WP_ENVIRONMENT_TYPE (see wp-config.php Reference).

  • .htaccess or the nginx server block, plus robots.txt or other files placed in the web root by hand (see Web Servers: Apache and nginx).

Projects whose code lives in Git deploy themes and plugins from the repository and copy only the uploads (see Deployment and CI/CD). Between the two Lumen Press containers the uploads travelled as an archive:

# Source: pack the media library
tar -czf /xfer/lumen-uploads.tar.gz -C /var/www/html/wp-content uploads

# Target: unpack it, owned by the web server user
tar -xzf /xfer/lumen-uploads.tar.gz -C /var/www/html/wp-content
find /var/www/html/wp-content/uploads -type f | head -3
/var/www/html/wp-content/uploads/2026/10/lantern-keeper-cover.jpg
/var/www/html/wp-content/uploads/2026/10/lantern-keeper-cover-200x300.jpg
/var/www/html/wp-content/uploads/2026/10/lantern-keeper-cover-150x150.jpg

On real servers rsync -a over SSH does the same incrementally, which allows a first copy before the freeze and a short final sync. Files must end up owned by the web server or deploy user with 644/755 permissions (see File Permissions and Filesystem).

Database

wp db export runs mysqldump (or mariadb-dump) with the credentials of wp-config.php and accepts extra dump flags; wp db import loads a dump through the mysql client. Neither needs a working site URL, which makes them safe to use before the URLs are fixed.

# Source
wp db export /xfer/lumen-prod.sql --add-drop-table

# Target, with wp-config.php already pointing at the new database
wp db import /xfer/lumen-prod.sql
wp option get home
Success: Exported to '/xfer/lumen-prod.sql'.
Success: Imported from '/xfer/lumen-prod.sql'.
https://lumenpress-books.test

The imported database still says the site is at the old address, and it replaces the target’s whole state, including the list of active plugins: the WordPress Importer, active on the target before, was inactive afterwards because it had not been active on the source. The dump contains user e-mails and password hashes; keep it out of the web root, transfer it encrypted and delete it after the move. Large dumps are better compressed (wp db export - | gzip > lumen.sql.gz), and --exclude_tables can leave out log tables that plugins fill. phpMyAdmin or a host’s control panel can export and import too, but they are subject to PHP upload and time limits that WP-CLI does not have.

Replacing URLs and serialized data

WordPress stores absolute URLs: home and siteurl in the options table, links and image URLs inside post_content, URLs in widget, theme and plugin settings, and user profile URLs. These must change when the domain changes, and many of them are inside PHP-serialized values. A serialized string records its own length, so a plain SQL replacement that changes the length corrupts it. The Lumen Catalog settings show the effect:

wp option add lp_catalog_settings \
  '{"distributor_url":"https://api.distributor.example","catalogue_page":"https://lumenpress-books.test/books/"}' \
  --format=json --autoload=off

# The wrong way: a plain SQL REPLACE()
wp db query "UPDATE lp_options SET option_value = REPLACE(option_value, 'https://lumenpress-books.test', 'https://lumenpress.test') WHERE option_name = 'lp_catalog_settings'"
wp db query "SELECT option_value FROM lp_options WHERE option_name = 'lp_catalog_settings'"
wp eval 'var_dump( get_option( "lp_catalog_settings" ) );'
Success: Query succeeded. Rows affected: 1
option_value
a:2:{s:15:"distributor_url";s:31:"https://api.distributor.example";s:14:"catalogue_page";s:36:"https://lumenpress.test/books/";}
bool(false)

The string is 30 characters long but still declared as s:36, so unserialize() fails and get_option() returns false: the plugin silently falls back to its defaults. The same happens to widgets, theme mods and page-builder data.

wp search-replace unserializes each value, replaces inside it (recursing into arrays and objects), and serializes it again. Run it with --dry-run first:

wp search-replace 'https://lumenpress-books.test' 'https://lumenpress.test' \
  --skip-columns=guid --dry-run --report-changed-only

wp search-replace 'https://lumenpress-books.test' 'https://lumenpress.test' \
  --skip-columns=guid --report-changed-only

wp option get lp_catalog_settings --format=json
Table           Column          Replacements    Type
lp_options      option_value    3               PHP
lp_posts        post_content    4               SQL
lp_users        user_url        1               SQL
Success: 8 replacements to be made.
...
Success: Made 8 replacements.
{"distributor_url":"https:\/\/api.distributor.example","catalogue_page":"https:\/\/lumenpress.test\/books\/"}

The Type column shows the strategy: columns without serialized data are updated with a fast SQL query, those that contain serialized values (home, siteurl and the Lumen Catalog settings here) go through PHP. The options that matter:

Option Use

--dry-run

Count and report without writing

--skip-columns=guid

Leave the GUID column alone (it accepts table.column too)

--precise

Process every column in PHP; slower, safer for unusual serialized data

--all-tables-with-prefix

Include tables with the prefix that $wpdb does not know about, such as plugin tables

--all-tables

Every table in the database, whatever its prefix; dangerous in a shared database

--network

Every table registered with $wpdb on a Multisite network

--export=<file>

Write the result as an SQL dump instead of changing the database

--regex, --regex-flags

Regular expressions; 15 to 20 times slower

--log, --report-changed-only

Show what changed

Tables without a primary key are skipped. Search for the most specific string that is safe: https://old-domain catches links but not protocol-relative //old-domain URLs or a bare host name in plugin settings, while replacing the bare host may also hit e-mail addresses (editor@lumenpress-books.test). A second pass with --dry-run for the bare host shows what is left. Replacing http:// with https:// on the same domain is the same operation.

--export combines the replacement and the dump in one step on the source, which leaves the source untouched:

# Source: dump with the URLs already replaced
wp search-replace 'https://lumenpress-books.test' 'https://lumenpress.test' --skip-columns=guid --export=/xfer/lumen-moved.sql

# Target
wp db import /xfer/lumen-moved.sql
wp option get home
Success: Made 8 replacements and exported to /xfer/lumen-moved.sql.
Success: Imported from '/xfer/lumen-moved.sql'.
https://lumenpress.test

The exported file starts every table with DROP TABLE IF EXISTS and CREATE TABLE, so it replaces the target’s tables.

The GUID rule. The guid column of wp_posts is a permanent identifier that feed readers use to decide whether an item is new; it looks like a URL but is not a link. The official migration guide says never to change it, so every replacement above skips it. After the move the books kept https://lumenpress-books.test/books/the-lantern-keeper/ as GUID while their permalinks became https://lumenpress.test/books/the-lantern-keeper/. Code must never use the GUID as a link; wp_get_attachment_url() and get_permalink() build URLs from the current settings.

Without shell access, the migration guide names serialization-aware tools that run inside WordPress (the Better Search Replace plugin, for example) or the standalone interconnect/it script. A standalone script left in the web root is a remote database editor; delete it as soon as the replacement is done.

Changing the site URL

Two options define where WordPress lives, both edited in Settings > General:

  • WordPress Address (URL), option siteurl: where the core files are (wp-admin, wp-includes)

  • Site Address (URL), option home: the address visitors use; different from siteurl only when WordPress is installed in a subdirectory but served from the root

Both carry the scheme and no trailing slash. A wrong value there locks everyone out, because the login form then redirects to an address that does not answer. The official ways back in, from the most to the least preferred:

  • WP-CLI: wp option update home 'https://lumenpress.test' and wp option update siteurl 'https://lumenpress.test'.

  • Constants in wp-config.php, which take precedence over the database:

define( 'WP_HOME', 'https://www.lumenpress.test' );
define( 'WP_SITEURL', 'https://www.lumenpress.test' );

The constants change what WordPress reports and generates, not what is stored:

wp config set WP_HOME https://www.lumenpress.test --type=constant
wp config set WP_SITEURL https://www.lumenpress.test --type=constant
wp option get home
wp db query "SELECT option_name, option_value FROM lp_options WHERE option_name IN ('home','siteurl')"
wp eval 'echo home_url( "/books/" ), PHP_EOL;'
Success: Added the constant 'WP_HOME' to the 'wp-config.php' file with the value 'https://www.lumenpress.test'.
Success: Added the constant 'WP_SITEURL' to the 'wp-config.php' file with the value 'https://www.lumenpress.test'.
https://www.lumenpress.test
option_name     option_value
home            https://lumenpress.test
siteurl         https://lumenpress.test
https://www.lumenpress.test/books/

The fields on Settings > General can no longer be edited while the constants are defined. Content still contains the URLs that were stored, so the constants are not a substitute for wp search-replace; they are a way to pin the URL per environment (in containers, from environment variables) or to get back into a broken site. Remove them, or keep them deliberately, but do not forget them: a staging copy whose wp-config.php was cloned with production’s WP_HOME redirects to production.

The other documented methods are last resorts:

  • RELOCATE: define( 'RELOCATE', true ); in wp-config.php makes the login screen set siteurl to the address it was loaded from. This happens on every request to wp-login.php, before and without any login: the address is built from the request’s Host header, so while the constant is defined anyone can repoint siteurl with a forged Host header. It fixes only siteurl, not home or content: load wp-login.php once on the new server and remove the constant immediately.

  • update_option() in the theme’s functions.php: two temporary lines that set home and siteurl on the next page load, to be removed immediately afterwards.

  • Editing home and siteurl in the options table with phpMyAdmin or SQL. These two are plain strings, not serialized, so this is the one place where SQL is safe.

None of these changes the site URL on a Multisite network (see below).

After the move

wp cache flush               # persistent object cache, if any
wp transient delete --all    # transients may hold old URLs; WordPress rebuilds them
wp rewrite flush             # rebuild the rewrite rules for the new install
wp option get permalink_structure

wp rewrite flush --hard also writes .htaccess, but under WP-CLI it warns "Regenerating a .htaccess file requires special configuration" because the CLI cannot detect mod_rewrite; it needs apache_modules: [mod_rewrite] in wp-cli.yml. Saving Settings > Permalinks once in the browser does the same. Then check, before switching DNS (a hosts-file entry pointing the domain at the new server lets the team test the real URL):

  • the home page, a book (/books/the-lantern-keeper/), a page, the admin, the login and a password reset e-mail

  • images, including those inside block attributes such as Cover backgrounds

  • the block theme’s templates and global styles, which are stored in the database and moved with it

  • Site Health (Tools > Site Health) on the new server: PHP extensions, loopback requests, HTTPS, scheduled events

  • hard-coded references to the old domain in theme or plugin files (grep -r lumenpress-books.test wp-content)

  • plugin licences tied to a domain, CDN and cache configuration, SMTP and OAuth redirect URLs registered elsewhere

Older books suggest a plugin or a host tool for every migration and describe moving wp-config.php or changing ABSPATH as part of it. A host’s migration tool or a backup plugin is a valid choice, but it performs the same steps: copy, import, serialization-aware replace. ABSPATH is computed by wp-load.php and is never edited.

Changing the table prefix

A migration is a common moment to change the table prefix, for example when the target database holds several sites, or to consolidate on Lumen Press’s lp_. Changing $table_prefix alone makes WordPress start the installer, because the prefix also appears inside data (see Database Schema). The full change renames the 12 single-site tables and the keys that embed the prefix:

-- Rename every table from the wp_ prefix to lp_ (single site: 12 tables)
RENAME TABLE wp_commentmeta TO lp_commentmeta, wp_comments TO lp_comments,
  wp_links TO lp_links, wp_options TO lp_options, wp_postmeta TO lp_postmeta,
  wp_posts TO lp_posts, wp_term_relationships TO lp_term_relationships,
  wp_term_taxonomy TO lp_term_taxonomy, wp_termmeta TO lp_termmeta,
  wp_terms TO lp_terms, wp_usermeta TO lp_usermeta, wp_users TO lp_users;

-- Keys that embed the prefix: the role definitions and each user's roles and level
UPDATE lp_options SET option_name = 'lp_user_roles' WHERE option_name = 'wp_user_roles';
UPDATE lp_usermeta SET meta_key = CONCAT('lp_', SUBSTRING(meta_key, 4))
  WHERE meta_key LIKE 'wp\_%';

Run it on a backup-protected copy, then set $table_prefix = 'lp_'; in wp-config.php. On the test site the administrator kept the administrator role and manage_options, and the user meta keys became lp_capabilities and lp_user_level. Plugin tables (wp_lumen_stock_log and similar) need renaming too, and the LIKE 'wp\_%' update assumes no plugin stores an unprefixed meta key that happens to begin with wp_; list the keys first with SELECT DISTINCT meta_key FROM lp_usermeta WHERE meta_key LIKE 'wp\_%'.

Changing domains

Moving Lumen Press from lumenpress-books.test to lumenpress.test is a migration in place: the URL replacement above, plus the parts outside WordPress:

  • Redirects. The old domain must answer with 301 Moved Permanently to the same path on the new one, for as long as links and search results point to it (years, not weeks). Redirect at the web server or CDN, not with a plugin on the old site. Server rules are on Web Servers: Apache and nginx.

  • TLS. A certificate for the new domain (and www) must be in place before the switch; mixed http:// URLs left in the content are found with wp search-replace 'http://lumenpress.test' 'https://lumenpress.test' --dry-run.

  • Search engines. Search Console’s change-of-address tool, the new sitemap at /wp-sitemap.xml and canonical URLs that follow home (see SEO, Analytics and Privacy).

  • Everything registered elsewhere: OAuth and SSO redirect URIs, payment and webhook endpoints, the CDN origin, e-mail sender domains (SPF, DKIM; see Email Delivery), and REST clients such as the Next.js reader app, whose base URL points at /wp-json/lumen/v1/.

Feeds keep working for subscribers because GUIDs did not change; changing them would make every reader show the whole archive as new.

Moving a Multisite network

A network stores its domain in more places than a single site: the wp_site and wp_blogs tables, the DOMAIN_CURRENT_SITE and PATH_CURRENT_SITE constants in wp-config.php, the siteurl network option in wp_sitemeta, and home, siteurl and (on old networks) fileupload_url in each site’s wp_N_options table. The URL options and constants described for single sites do not apply. Moving a network with the same domain is a file and database copy; a new domain needs these steps, tested on a subdirectory network with the main Lumen Press site and the Lumen Kids imprint at /kids/:

# 1. The constant WordPress uses to find the network
wp config set DOMAIN_CURRENT_SITE lumenpress.test

# 2. The domain columns, so that WordPress can load the sites again
wp db query "UPDATE lp_site SET domain = 'lumenpress.test'; UPDATE lp_blogs SET domain = 'lumenpress.test';"

# 3. Every site's tables and the network tables
wp search-replace '//lumenpress-books.test' '//lumenpress.test' --network --skip-columns=guid --report-changed-only
wp site list --fields=blog_id,url
Success: Updated the constant 'DOMAIN_CURRENT_SITE' in the 'wp-config.php' file with the value 'lumenpress.test'.
Success: Query succeeded. Rows affected: 2
Table           Column                  Replacements    Type
lp_2_comments   comment_author_url      1               SQL
lp_2_options    option_value            2               PHP
lp_2_posts      post_content            2               SQL
lp_options      option_value            2               PHP
lp_sitemeta     meta_value              1               PHP
Success: Made 8 replacements.
blog_id url
1       https://lumenpress.test/
2       https://lumenpress.test/kids/

The order matters: until wp_site, wp_blogs and DOMAIN_CURRENT_SITE agree, WordPress (and therefore WP-CLI) cannot load the network, so run these steps in this order. The UPDATE statements fit this network, where every site shares the main domain; a subdomain network (kids.lumenpress.test) needs one statement per site, and sites with mapped custom domains (native domain mapping since 4.5) keep their own domains. Switching between subdomains and subdirectories also changes SUBDOMAIN_INSTALL and the rewrite rules; Multisite Networks covers the network itself. Older books describe domain mapping through a "MU Domain Mapping" plugin or a host’s portal; since 4.5 it is the Site Address field of each site in Network Admin > Sites.

Merging sites

Combining sites (Lumen Press absorbing an imprint’s separate blog, for example) is an import, not a migration: the target keeps its users, settings and IDs, and each source contributes content through WXR.

  1. Export each source with All content, or by post type, and note its URL.

  2. Create or choose the target users first and build an author mapping CSV for each file, so that people who wrote on several sites end up as one account with the right role.

  3. Register on the target every post type, taxonomy and block the sources use (activate lumen-catalog before importing books), otherwise the importer skips posts of unknown types with "Invalid post type".

  4. Import into a staging copy, file by file, with the sources still online so that attachments download.

  5. Run wp search-replace for each old domain, check for duplicate slugs (WordPress appends -2 to clashing slugs, which changes the URL), and map the old URLs to the new ones with 301 redirects.

  6. Reconcile what WXR does not carry: menus that point at IDs, widget and plugin settings, comment moderation settings, and categories with the same name but different meanings.

To turn separate sites into one Multisite network instead (one site per imprint, Lumen Kids and Lumen Noir), the official guide follows the same pattern: create a site per source in the network and import each WXR into its site, accepting that imported users lose their roles and that widget and plugin settings must be recreated.

Pitfalls and corrections to older books

  • A WXR export is not a backup. It omits settings, users' passwords and roles, themes, plugins, media files and plugin tables. Back up with wp db export and a copy of wp-content (see Backups and Disaster Recovery).

  • Never run SQL REPLACE() over serialized data or change the GUID column, as shown above.

  • Importers are plugins, and some are unmaintained. Older books present them as built in and list Press This under Tools; both have been plugins for years (Press This since 4.9).

  • ABSPATH is not edited during a move, and wp-config.php is adapted, not copied blindly.

  • Production data is personal data. Dumps and WXR files contain e-mail addresses (and dumps contain password hashes); copies to staging or laptops should be anonymised, encrypted in transit and deleted when no longer needed (see Local Development Environments: Staging copies).