The symptom showed up a few hours after I migrated a multi-listing site. Google Search Console started screaming about hundreds of old URLs. I thought I was covered: the redirect handler was in place, tested on staging, and logically it "should" turn every old URL into a 301 to its new home. Production did the exact opposite. Every old URL that came in, instead of 301'ing, came back with a 404.
Here is a request that failed:
curl -I https://old-site.com/listing/cabin-by-the-lake
# HTTP/1.1 404 Not FoundAnd here is the heart of the redirect handler I had wired into template_redirect. The idea was simple: take the old slug off the incoming URL, resolve it to the matching term/post, then 301 to its new permalink.
add_action( 'template_redirect', function () {
if ( ! is_404() ) {
return;
}
$old_slug = get_query_var( 'name' ); // e.g. "cabin-by-the-lake"
// Try to resolve the old slug to its entity
$term = get_term_by( 'slug', $old_slug, 'listing_category' );
if ( ! $term ) {
return; // <- always lands here, so the 404 stands
}
wp_safe_redirect( get_term_link( $term ), 301 );
exit;
} );The logic looks right. But get_term_by() always returned null, so the handler returned and WordPress let the request 404. The question is: why does the old-slug lookup always fail when the data is obviously sitting right there in the DB?
Why this happens
Because the migration changed two things at once, in the same deploy: the URL structure changed, and the slugs got renamed. So the entity that used to have the slug cabin-by-the-lake now has a new slug — say lakeside-cabin — in the very same table I was querying.
The moment the migration script finished, get_term_by('slug', 'cabin-by-the-lake', ...) was searching for something that no longer existed. The old slug had been overwritten. All the DB holds now is the new slug. The lookup returns null, the handler returns, and the request falls through to a 404.
In one sentence: my handler was asking the database for an identity I had just renamed away with my own hands. This is not a get_term_by() versus get_page_by_path() problem — both fail identically. It is more fundamental than that: you cannot resolve a redirect by querying the very thing you just renamed. The old slug is the only key the incoming request carries, and that key is exactly what I had deleted from the DB.
That is what made the bug so slippery on staging. Early on I'd sometimes test after only the URL structure had changed but the slugs were still the old ones, or I'd type a new URL in directly. As long as the old slug still existed in the DB, the lookup succeeded and the redirect looked like it worked. It was only when the slug rename ran in the same deploy that the ground the handler stood on disappeared.
The fix
The key insight: the redirect handler must not depend on the live DB for an identity that has already changed. It has to read an explicit old → new map that was built before the rename happened.
The migration script is the only place that knows both sides of the story — it knows the old slug (the before) and the new URL (the after). So that is where the map has to be built. I generate it during the migration, keyed by old slug, and store it as an option.
// Runs INSIDE the migration script, before/while renaming.
// This script knows the "before" and the "after", so it can record both.
$redirect_map = array();
foreach ( $entities_to_migrate as $entity ) {
// $entity->old_slug is captured before it gets renamed.
$redirect_map[ $entity->old_slug ] = $entity->new_url; // the final, resolved URL
}
update_option( 'listing_redirect_map', $redirect_map, false );Then the handler never touches the live DB for resolution again. It only reads the snapshot:
add_action( 'template_redirect', function () {
if ( ! is_404() ) {
return;
}
$old_slug = get_query_var( 'name' );
$map = get_option( 'listing_redirect_map', array() );
if ( empty( $map[ $old_slug ] ) ) {
return; // genuinely unknown -> let it 404
}
wp_safe_redirect( $map[ $old_slug ], 301 );
exit;
} );Now the old slug cabin-by-the-lake is found in the map, the lookup is O(1), and the request comes back as a 301 to the new URL — completely indifferent to the fact that the DB slug is now lakeside-cabin. For a small site a static array in a file works just as well; the point is not where the map lives, but that it was snapshotted before the rename.
curl -I https://old-site.com/listing/cabin-by-the-lake
# HTTP/1.1 301 Moved Permanently
# Location: https://old-site.com/stays/lakeside-cabinThe takeaway
When you rename the very thing you'd query to resolve a redirect, you can't ask the live DB for its old identity — that identity is gone. Snapshot the mapping before you rename, and drive every 301 from that snapshot, not from the DB.
More broadly: redirects are not a runtime feature you bolt on afterward, they are an artifact of the migration itself. The only moment when both the "before" and the "after" are in your hands is while the migration script runs. Capture the mapping there. If you defer it and try to work out where an old URL should go after the data has already changed, you are asking the database about something you deliberately made it forget.
