If you have moved more than two or three WordPress sites, you have seen a migration fail. Maybe the archive built fine and then the upload timed out at 90%. Maybe the plugin said “Migration complete” but half your posts were missing. Maybe it silently stopped at 40% and you could not figure out why. These are not rare edge cases – they are the default experience of migrating a non-trivial WordPress site.
The reason is not that the plugins are badly written. Most of them work fine for small sites. The reason is that the dominant migration model – build an archive, upload it, unpack it – runs headfirst into the architectural limits of shared hosting, the quirks of PHP’s serialization format, the cost of OFFSET pagination in MySQL, and the way every modern web application firewall inspects traffic for SQL. Any one of those is enough to kill a migration. Most real migrations hit at least two.
This article walks through the four failure modes every developer eventually sees, explains exactly why each one happens, and shows how the hostney-migration plugin on wordpress.org solves each one. The plugin is GPLv2 and the code is public – you can read the implementation yourself.
Failure mode 1: "Archive built, upload timed out"#
You click Start. The plugin works for twenty minutes building a zip of your files and a SQL dump of your database. Then it tries to upload the archive to the destination, and either the upload hangs, the destination server rejects the file for being too large, or the PHP process on one side hits its
max_execution_time
and dies mid-transfer.
Why it happens
The archive model is fundamentally a batch job in a request-response system that was not designed for batch jobs. PHP on shared hosting typically caps
max_execution_time
at 30-60 seconds,
memory_limit
at 256-512 MB, and
upload_max_filesize
at 64-128 MB. The archive has to fit entirely on disk on the source (often twice, once as raw files and once as the zip), travel across the network as a single unit, and be unpacked entirely on the destination. For a 4 GB WordPress site with a large media library, none of those steps fit in the defaults.
You can raise the limits if you have shell access. Most people on shared hosting do not, which is the entire reason they are using a migration plugin in the first place.
Related: WordPress maximum execution time exceeded and WordPress PHP memory exhausted error both cover the underlying PHP limits in more depth.
What we do differently
hostney-migration does not build archives. The destination worker pulls data from the source in paginated chunks, one batch at a time, at its own pace. Each batch is checkpointed. If a single batch fails, the worker retries it without redoing the rest of the migration. There is no single giant upload to time out, because there is no single giant upload at all.
When a batch fails with an out-of-memory error, the worker halves the batch size and retries: 1000 rows, then 500, then 250, all the way down to 50 before giving up. Large sites migrate on hosts with tight PHP limits because the worker adapts its payload size to whatever the source can actually serve.
Failure mode 2: "Migration complete but site broken"#
The plugin reports success. You load the destination URL and it looks wrong – images broken, widgets empty, theme customizer settings gone, the old domain showing up in unexpected places. A find-and-replace on the SQL dump turned
oldsite.com
into
newsite.com
, but the site still behaves as if the migration corrupted something. Because it did.
Why it happens
WordPress stores huge amounts of configuration in PHP’s
serialize()
format – widget settings, theme mods, plugin options, menu configurations, transients. Serialized strings include their own length prefix. A serialized string that says
s:11:"oldsite.com"
contains both the content (
oldsite.com
) and the length (
11
). If you replace
oldsite.com
with
newsite.com
using a plain find-and-replace, the content is now 11 characters of
newsite.com
but the prefix still says
11
. That happens to work because both strings are the same length. Now try replacing
oldsite.com
with
mynewsite.com
:
# Before
s:11:"oldsite.com"
# After naive find-and-replace
s:11:"mynewsite.com" # prefix says 11, content is 13 - broken
PHP’s unserializer reads the length, grabs that many bytes, and bails out when the following bytes do not match the expected structure. The option silently fails to load. Your widgets disappear. Your customizer resets. The database is not corrupted in a way any tool will flag – it is corrupted in a way only PHP will notice, at runtime, per-option, silently.
What we do differently
hostney-migration never does a naive string replace on serialized data. The worker reads the schema, identifies columns that contain serialized PHP, unserializes them, performs the URL substitution on the decoded structure, and re-serializes with correct length prefixes. It also handles nested serialization – serialized data inside serialized data, which is more common than you would think in older sites that have been migrated before. The same approach is what wp-cli’s search-replace with the –skip-columns=guid flag does, and hostney-migration uses the same safe logic automatically.
Failure mode 3: "Plugin says success but posts missing"#
Migration finishes. You open the admin. Posts list shows 847 posts, but the source had 1,200. Categories are intact. Users are intact. But chunks of content are missing, apparently at random, and the gaps are not at the start or end – they are in the middle.
Why it happens
This is the OFFSET pagination bug, and it strikes plugins that paginate large tables with
LIMIT 500 OFFSET 12000
. MySQL does not magically jump to row 12,000 – it scans rows 1 through 12,499 and then returns the last 500. For a table with one million rows, that is hundreds of millions of rows scanned across a full migration. Slow, but not broken.
It becomes broken when the migration is slow enough that new writes happen on the source during the run. Or when the table does not have a stable primary key and the engine reorders rows between requests. Or when a row is deleted during the migration. Rows can be skipped or duplicated silently. The migration log says “retrieved 500 rows” on every page, and the plugin dutifully reports success.
The tables that most commonly lack clean numeric primary keys?
wp_usermeta
,
wp_postmeta
, and
wp_options
on older sites that were themselves migrated from even older sites.
What we do differently
hostney-migration uses primary-key pagination:
WHERE id > last_seen_id ORDER BY id LIMIT 500
. This is O(log n) with an index instead of O(n+offset), and it is stable under concurrent writes because it tracks the last-seen ID rather than a positional offset. The worker detects tables that do not have a numeric primary key and falls back to a composite-key cursor. If the table genuinely has no suitable keys, the worker logs a warning and streams the table as a unit rather than silently dropping rows. You get told.
BLOB and binary columns are detected from the schema and base64-encoded for transport, so binary data survives the round trip intact. Binary content inside serialized fields stays binary.
Failure mode 4: "Migration silently stops at 40%"#
This is the worst one because there is nothing in the plugin’s logs. The worker on one side sent a request. The other side responded. The response was truncated, blank, or returned 403. The plugin retries, gets the same result, and eventually times out. Nothing obvious is wrong with either server. The migration just stops.
Why it happens
Web application firewalls inspect response bodies, not just request bodies. ModSecurity’s OWASP Core Rule Set, Wordfence, Cloudflare, Sucuri – they all ship with rules that look for suspicious patterns in HTTP responses, because data exfiltration attacks often hide SQL in response payloads.
Your legitimate migration response is a SQL dump. It contains
CREATE TABLE
,
INSERT INTO
,
UNION
,
SELECT
,
DROP
,
UPDATE
– every keyword the WAF is trained to flag. Three real examples of rules that trip on migration traffic:
- ModSecurity CRS rule 942100: “SQL Injection Attack Detected via libinjection” – runs on response bodies when the site’s paranoia level is raised.
- Cloudflare managed rule 100024: “SQLi – Body” – fires when response bodies contain SQL keywords in suspicious contexts.
- Wordfence’s “Dangerous file upload” rules inspect multipart response bodies for SQL signatures.
The WAF strips the body, returns a sanitized 403, and the plugin has no way to tell the difference between “WAF blocked this” and “server returned empty”. Your migration stalls at whatever percentage happened to be the last clean batch.
What we do differently
hostney-migration supports base64 response wrapping. When the worker sends an
X-Migration-Encoding: base64
header with the request, the source plugin wraps its response body in base64. The WAF sees opaque binary data with no SQL keywords in it. The worker decodes on arrival. The migration gets through.
Requests are signed with HMAC-SHA256 using a domain-separated key derivation – the migration token is never sent in plaintext over the wire, and the signature prevents request replay. The WAF does not strip the header because it has no reason to – it just sees an encoded payload, which is exactly what APIs return anyway.
Read the code#
Every architectural choice above is visible in the plugin source. The hostney-migration plugin on wordpress.org is GPLv2, and the code is on GitHub. You can audit the HMAC signing, the batch-halving logic, the serialized-data handling, and the base64 encoding yourself before you ever run a migration.
That is also the pitch. Migration plugins are security-sensitive – they have database credentials, they move user data, they run with full WordPress privileges. The fact that most of the popular ones are closed-source or obfuscated is not an accident. “Read the source” is a feature.
How Hostney handles migrations#
When you migrate to Hostney, this is the pipeline running under the hood. You install hostney-migration on the source site, paste the connection token into your Hostney dashboard, and the worker on our side pulls your data in checkpointed batches, handles serialized data correctly, paginates by primary key, and wraps response bodies to slip past your current WAF. Sites that have failed three times with archive-based plugins routinely complete on the first try. For more on how we handle migrations and ongoing backups, see the Hostney backups page.
The plugin is free and works whether you use Hostney or not – it is a WordPress.org plugin under GPLv2, not a proprietary tool locked to our platform. If you end up migrating somewhere else, the same technical approach works. We just happen to be the ones who built the destination worker to match.
If you are evaluating Hostney, the best way to try it is to migrate a real site over our 14-day free trial and see how the migration behaves end-to-end. For more context on the full WordPress migration process manually, see our guide on how to migrate WordPress to another hosting provider.