I count the posts three times before flipping a WordPress host migration
The copy-complete notice from a shared host is a snapshot taken when the copy started, not when it finished. Here is the full enumeration I run before the cutover, and where I stop delegating.
The migration panel said "data copy complete" some time after midnight. The instructions were short from there: press the switch button. I was putting my mug away when I decided to look at the new side first, just once.
That one look mattered. The article I had published earlier that evening was not on the new server.
The thing I want to say first is that "copy complete" did not mean "identical right now." The copy is a snapshot from the moment it started, and anything I wrote and published afterward was never part of it. Obvious in hindsight — but the notice does not say so.
Copy complete means the copy started, not that you are in sync
A shared host's migration feature copies the old server's files and database to the new one. Depending on the size of the site, that takes a while. The completion notice tells you the copying finished. It does not tell you that updates made during the copy were picked up.
For a while I assumed the notice meant both sides were equal. That did not work out well. Lining up the notice timestamp against my own last publish time would have shown it immediately — I had simply handed the judgment to the wording of a notification.
Looking back, it was less that I begrudged the check and more that I had not thought of a way to do it. I had decided the new server sat on the far side of DNS and could not be touched.
Now the notice does not make me press anything. It makes me go look at the new side.
Looking at the new server before you move DNS
This is where I got stuck at first. DNS still points at the old host, so how do I talk to WordPress on the new one?
The answer was curl's --resolve. You keep the hostname as the domain and swap only the address it connects to. SNI and certificate validation both proceed under the real domain name, so you never need --insecure.
# Talk to WordPress on the new server while keeping the real hostname.# NEW_IP is the destination server's address.curl -sS -o /dev/null -D - \ --resolve "example.com:443:NEW_IP" \ "https://example.com/wp-json/wp/v2/posts?per_page=1" \ | tr -d '\r' | grep -i '^x-wp-total'
If you find yourself reaching for --insecure, I would rather you paused there. With validation off you cannot tell that you have landed somewhere other than the server you meant. If the destination certificate has not been issued yet, waiting for it is the cheaper option.
If the destination certificate has not been issued yet, wait for the migration tool to finish issuing it. In that case waiting turns out to be the faster path.
To confirm you really reached the new machine, drop a temporary file into the new server's document root before cutover and request it. The old server does not have that file, so a successful response means you are on the new side.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦You will be able to run a full old-versus-new post comparison on your own site before you touch the cutover button
✦You will catch posts whose bodies never made it across, even when the counts match, before a reader does
✦You will have a clear line between the parts of a migration you hand to an agent and the parts you press yourself
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
I started out planning to compare counts only. A thousand on the old side, a thousand on the new. That felt sufficient.
It was not. Equal counts do not mean equal contents.
Pass
What it catches
What it misses
Count (X-WP-Total)
Posts added after the copy, posts missing entirely
Reassigned IDs, posts whose body alone is gone
ID set
Exactly which posts are missing or extra
Posts that arrived with an empty body
Body length
Bodies that never transferred, bodies truncated mid-way
Same byte length with different content (I have never seen this in practice)
I settled on three passes because I built the two-pass failure myself and watched it slip through. That is the next section.
Here is the comparison script I actually run. It writes one line per post — ID, modified time, and body length in bytes — from each side, then diffs them.
#!/usr/bin/env bash# Compare every WordPress post on the old and new servers before cutover.# Production: ./wp-migration-diff.sh example.com 203.0.113.10 198.51.100.20# Local test: BASE_OLD=http://127.0.0.1:8801 BASE_NEW=http://127.0.0.1:8802 ./wp-migration-diff.sh example.comset -uo pipefailDOMAIN="${1:?domain required}"OLD_IP="${2:-}"NEW_IP="${3:-}"PER_PAGE=100STATUSES="publish,future,draft,pending,private"# Counting drafts and scheduled posts needs authentication.# Put "user:application_password" in WP_AUTH.AUTH_ARGS=()[ -n "${WP_AUTH:-}" ] && AUTH_ARGS=(--user "$WP_AUTH")fetch() { # $1=old|new $2=path local side="$1" path="$2" if [ "$side" = old ]; then if [ -n "${BASE_OLD:-}" ]; then curl -sS --max-time 30 "${AUTH_ARGS[@]}" "${BASE_OLD}${path}" else curl -sS --max-time 30 "${AUTH_ARGS[@]}" --resolve "${DOMAIN}:443:${OLD_IP}" "https://${DOMAIN}${path}" fi else if [ -n "${BASE_NEW:-}" ]; then curl -sS --max-time 30 "${AUTH_ARGS[@]}" "${BASE_NEW}${path}" else curl -sS --max-time 30 "${AUTH_ARGS[@]}" --resolve "${DOMAIN}:443:${NEW_IP}" "https://${DOMAIN}${path}" fi fi}# Matching counts will not surface a post that arrived with an empty body.# Take the length as well.dump() { local side="$1" page=1 out n : > "/tmp/wpdiff-${side}.tsv" while : ; do out="$(fetch "$side" "/wp-json/wp/v2/posts?per_page=${PER_PAGE}&page=${page}&status=${STATUSES}&_fields=id,modified_gmt,content&orderby=id&order=asc")" || return 1 n="$(printf '%s' "$out" | python3 -c 'import sys, jsonrows = json.load(sys.stdin)if isinstance(rows, dict): # WordPress returns an error object past the last page rows = []for r in rows: body = (r.get("content") or {}).get("rendered", "") print("%s\t%s\t%d" % (r["id"], r.get("modified_gmt", ""), len(body.encode("utf-8"))))print("__COUNT__%d" % len(rows), file=sys.stderr)' 2>/tmp/wpdiff-n)" || return 1 printf '%s' "$n" | sed '/^$/d' >> "/tmp/wpdiff-${side}.tsv" grep -q '__COUNT__0' /tmp/wpdiff-n && break grep -q "__COUNT__${PER_PAGE}" /tmp/wpdiff-n || break page=$((page + 1)) done sort -n -o "/tmp/wpdiff-${side}.tsv" "/tmp/wpdiff-${side}.tsv"}dump old || { echo "Failed to read the old server"; exit 2; }dump new || { echo "Failed to read the new server"; exit 2; }OLD_N=$(wc -l < /tmp/wpdiff-old.tsv | tr -d ' ')NEW_N=$(wc -l < /tmp/wpdiff-new.tsv | tr -d ' ')echo "count: old ${OLD_N} / new ${NEW_N}"cut -f1 /tmp/wpdiff-old.tsv | sort > /tmp/wpdiff-old.idscut -f1 /tmp/wpdiff-new.tsv | sort > /tmp/wpdiff-new.idsMISSING="$(comm -23 /tmp/wpdiff-old.ids /tmp/wpdiff-new.ids)"EXTRA="$(comm -13 /tmp/wpdiff-old.ids /tmp/wpdiff-new.ids)"SHRUNK="$(join -t$'\t' -j1 /tmp/wpdiff-old.tsv /tmp/wpdiff-new.tsv \ | awk -F'\t' '$5 != $3 || $4 != $2 { printf "%s old %s bytes / new %s bytes\n", $1, $3, $5 }')"FAIL=0[ -n "$MISSING" ] && { echo "missing on the new server:"; echo "$MISSING" | sed 's/^/ id /'; FAIL=1; }[ -n "$EXTRA" ] && { echo "missing on the old server:"; echo "$EXTRA" | sed 's/^/ id /'; FAIL=1; }[ -n "$SHRUNK" ] && { echo "body or modified time differs:"; echo "$SHRUNK" | sed 's/^/ id /'; FAIL=1; }if [ "$FAIL" -eq 0 ]; then echo "No differences. Safe to cut over."else echo "Differences found. Hold the cutover."fiexit "$FAIL"
I include drafts and scheduled posts in status because counting published posts only means a missing scheduled post stays invisible until its publish time arrives. It costs an authentication step, but this runs once on migration night, so I lean toward including them.
I broke it locally before pointing it at production
As an indie developer I usually have nothing but production data to test against. But a script like this one has to be watched failing first, or you will read "no output" as a pass. I nearly did exactly that.
So I stood up two fake WordPress REST endpoints on my own machine first. One plays the old server, one plays the new, and I introduced two kinds of loss on purpose:
One post present on the old side and absent on the new — standing in for the article published after the copy snapshot
One post with a matching ID and modified time whose body was empty
Running the script against that pair gives:
count: old 6 / new 5missing on the new server: id 6body or modified time differs: id 3 old 203 bytes / new 0 bytesDifferences found. Hold the cutover.
Pointed at identical data, it stays quiet:
count: old 6 / new 6No differences. Safe to cut over.
This is the part that went against what I expected. A count-only comparison lets id 3 through as a match. Adding the ID comparison changes nothing. Only the body length brings it to the screen.
Put another way: matching counts prove the numbers agree, not that the content arrived. It took me two rebuilds of the fake servers before I could write that sentence with any confidence.
Moving the A record alone leaves your mail on the old host
Even once the posts line up, you are halfway. Cutover means editing DNS, and the A record is not the only thing that has to move.
Record
What happens if you forget it
A / AAAA
The site keeps pointing at the old host. The easiest one to notice
MX
Contact form copies and inbound mail keep landing on the old host, and vanish the day you cancel it
TXT (SPF)
Mail now leaves the new server unauthorized, and more of it gets filtered
CNAME (delivery, verification)
Certificate renewals and third-party domain checks stop passing
Fixing a record you forgot is easy while the old server is still alive, and impossible once it is gone. That is why cancelling the old host sits last in my order of operations.
SPF is the one I missed. The old server's hostname was written in directly with a:, and it sat there untouched after the switch. This small script prints out what the current SPF record actually points at:
# Pull the current SPF record and list every host it references.dig +short TXT example.com | tr -d '"' | grep '^v=spf1' | python3 -c 'import sys, retxt = sys.stdin.read()for tok in txt.split(): m = re.match(r"^(?:\+|~|-|\?)?(a|mx|ip4|ip6|include|redirect)[:=](.+)$", tok) if m: print("%-8s -> %s" % (m.group(1), m.group(2))) elif tok in ("a", "mx"): print("%-8s -> (this domain)" % tok)'
Any old-host names in that output are homework waiting for you after the switch. I run it the day before and write the lines I need to change on paper before migration night.
What I hand to an agent, and what I do not
This is the part I thought about most.
I hand over the work that is high in volume and free of judgment: enumerating and comparing every post, taking inventory of DNS records, crawling for broken links after the switch. Each has exactly one right answer and far too many items for me to eyeball. Give it the procedure and the same result comes back every time.
I keep the moves that cannot be undone: the migration tool's switch button, the actual DNS edit, and deleting the old server. Those I press myself. Knowing the exact minute I pressed them is worth something on its own.
Recounting goes to the agent; the irreversible press stays on my own finger. That line is the one I hold to hardest on the nights I am tired.
There is still work after you press it, and the order matters.
If you shortened the DNS TTL, confirm propagation before restoring it. Query dig +short example.com @8.8.8.8 and @1.1.1.1 until both return the new address
Purge any cache sitting in front. Reading a stale response and concluding the migration failed is the most wasteful misdiagnosis available that night
Run the comparison script again — this time with the old server pinned by IP and the new one reached through DNS. That confirms the side your readers actually see
Send yourself a message through the contact form. One real message is the fastest way to settle whether MX and SPF are right
Do not delete the old server yet. I keep mine for at least a week
If you take one item from that list, take the third. Once before the switch and once after: the same script, run twice, separates "the data moved" from "readers are reaching it."
If you are migrating tonight, start by pulling X-WP-Total from both sides just once. A mismatch is a reason to stop. If the numbers agree, the body lengths can wait until after your next cup of coffee.
Thank you for reading this far. If any of it saves someone else's late night, I would be glad.
Share
Thank You for Reading
Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.