As a WordPress site grows, category structures can easily become bloated, redundant, or outdated. Reorganizing your taxonomy manually through the WordPress admin dashboard works fine for a handful of posts, but if you need to merge hundreds – or thousands – of articles from one category into another, doing it manually is a slow, tedious chore.
While plugins exist for taxonomy management, adding extra software for a single maintenance task introduces unnecessary overhead and security risks.
Below is a lightweight, standalone PHP utility script that allows you to safely reassign posts from a source category to a destination category, clean up old taxonomy terms, and invalidate post caches – all while keeping memory overhead minimal and database operations secure.
We’ve used it ourselves for our blog, so we can confirm it works :-)
How to Run the PHP Script
Just copy the code below into a file in your blog directory, and update the Configuration settings in the script.
We have opted to use the category slugs (path) to identify the from/to categories, but you can choose to use the category id, or the category name. Because category slugs and category ids are unique, it is best to use one of those options.
You can opt to first do a dry run, where it gives you info on how many posts will be moved, and which posts, or just jump in and do the queries – $dry_run = false; means no dry run – if you want a dry run, change this to true.
You can choose to delete the original category with: $delete_old_category = true; – if you still want to keep this category, just change it to false;
Before running the script, it is a good idea to make a backup of your WordPress database. To run it, just go online, to the script location, i.e. your-website.com/blog/move-posts-category.php
<?php
/*
|--------------------------------------------------------------------------
| WordPress Category Merge Script
|--------------------------------------------------------------------------
*/
require_once __DIR__ . '/wp-load.php';
// 1. Security Check
if (!is_user_logged_in() || !current_user_can('manage_options')) {
wp_die('Unauthorized execution. Administrator login required.');
}
/*--------------------------------------------------
| Configuration
--------------------------------------------------*/
$from_input = 'traffic-statistics-2'; // Name, slug, or ID
$to_input = 'website-traffic'; // Name, slug, or ID
$delete_old_category = true;
$dry_run = false;
/*--------------------------------------------------
| Helper: Resolve Category Input (ID, Name, or Slug)
--------------------------------------------------*/
function resolve_category_term($input) {
if (is_numeric($input)) {
$term = get_term((int)$input, 'category');
} else {
$term = get_term_by('name', $input, 'category');
if (!$term) {
$term = get_term_by('slug', sanitize_title($input), 'category');
}
}
if (is_wp_error($term) || !$term) {
return false;
}
return $term;
}
$from = resolve_category_term($from_input);
$to = resolve_category_term($to_input);
if (!$from) exit("Source category not found: {$from_input}");
if (!$to) exit("Destination category not found: {$to_input}");
// 2. Self-Merge Protection
if ($from->term_id === $to->term_id) {
exit("Source and Destination categories cannot be identical (ID: {$from->term_id}).");
}
/*--------------------------------------------------
| Query Posts
--------------------------------------------------*/
$post_ids = get_posts([
'post_type' => 'post',
'post_status' => 'any',
'posts_per_page' => -1,
'fields' => 'ids',
'category' => $from->term_id,
'no_found_rows' => true,
'update_post_meta_cache' => false,
'update_post_term_cache' => false,
]);
$total = count($post_ids);
echo "<h2>Category Merge Process</h2>";
echo "<strong>Source:</strong> {$from->name} (ID: {$from->term_id})<br>";
echo "<strong>Destination:</strong> {$to->name} (ID: {$to->term_id})<br>";
echo "<strong>Matching Posts Found:</strong> {$total}<br><hr>";
if ($total === 0) {
exit("No posts to migrate.");
}
// Debug preview (Fixed $total positioning)
echo '<pre>Sample Post IDs to move (Max 100 shown): ';
print_r(array_slice($post_ids, 0, 100));
if ($total > 100) echo "...";
echo '</pre><hr>';
if ($dry_run) {
echo "<strong>[DRY RUN]</strong> Dry run complete. {$total} posts would be updated.<br>";
exit;
}
/*--------------------------------------------------
| Execution
--------------------------------------------------*/
$migrated = 0;
foreach ($post_ids as $post_id) {
// Add destination category
$added = wp_set_post_categories($post_id, [$to->term_id], true);
// Remove source category
$removed = wp_remove_object_terms($post_id, $from->term_id, 'category');
if (!is_wp_error($added) && !is_wp_error($removed)) {
$migrated++;
} else {
if (is_wp_error($added)) {
echo "<strong>Error Adding Category to Post ID {$post_id}:</strong> " . $added->get_error_message() . "<br>";
}
if (is_wp_error($removed)) {
echo "<strong>Error Removing Category from Post ID {$post_id}:</strong> " . $removed->get_error_message() . "<br>";
}
}
}
/*--------------------------------------------------
| Delete Old Term (With Safeguard Check)
--------------------------------------------------*/
$deleted_status = "No";
if ($delete_old_category) {
// Verify zero posts remain attached to this term
$remaining = get_posts([
'post_type' => 'any',
'post_status' => 'any',
'category' => $from->term_id,
'posts_per_page' => 1,
'fields' => 'ids',
]);
if (empty($remaining)) {
wp_delete_category($from->term_id);
$deleted_status = "Yes";
} else {
$deleted_status = "Skipped (Category still has remaining posts attached)";
}
}
wp_cache_flush();
/*--------------------------------------------------
| Summary Report
--------------------------------------------------*/
echo "<br><strong>Migration Summary:</strong><br>";
echo "- Posts reassigned: {$migrated} of {$total}<br>";
echo "- Old category deleted: {$deleted_status}<br><br>";
echo "<strong>Process Finished.</strong> Delete this script file immediately.";
?>
In case you are wondering what the Dry Run output looks like, here you go:
Key Features of This Approach
- Low Memory Footprint: Uses lightweight ID queries (
'fields' => 'ids') instead of pulling entire post objects into memory at once. - Smart Input Resolution: Resolves categories dynamically by Name, Slug, or ID.
- Preserves Multi-Category Assignments: Appends the new category while keeping any other assigned categories intact using
wp_set_post_categories($id, [...], true). - Built-in Safety Checks: Requires admin authentication, prevents merging a category into itself, includes a dry-run preview mode, and verifies the source term is empty before deletion.
In Summary
Cleaning up your WordPress taxonomy doesn’t require bloated plugins or risky database edits directly in phpMyAdmin. By running a controlled, standalone migration script, you can safely reassign thousands of posts, clean up outdated categories, and ensure your site’s content hierarchy stays organized.
Important Reminder: Always back up your WordPress database before running custom script migrations, and be sure to delete the script file from your server as soon as the execution is complete.
If you need to bulk add WordPress posts to a category based on its tag, via PHP, no plugin required, read:
How to Bulk Add WordPress Posts to a Category Using PHP (No Plugin Required)
Also related:
How to Automatically Share Your WordPress Blog Posts to Instagram
Do WordPress plugins sometimes leave stuff on your website after uninstalling the plugin?
Add SEO Meta Descriptions to Your WordPress Blog with UltimateWB – No Plugins Needed!
Looking for a website builder that doesn’t require third-party plugins? Learn more about UltimateWB! We also offer web design packages if you would like your website designed and built for you.
Got a techy/website question? Whether it’s about UltimateWB or another website builder, web hosting, or other aspects of websites, just send in your question in the “Ask David!” form. We will email you when the answer is posted on the UltimateWB “Ask David!” section.
