How to Diagnose Why wp-cron Events Queue but Never Execute on Shared Hosting

If you run WordPress on shared hosting, you’ve probably seen it: a scheduled post that never publishes, a backup that never runs, a plugin that says its next event is overdue. The wp-cron system is WordPress’s built-in task scheduler, but on shared hosting it often queues events without ever executing them. This article is a field guide to diagnosing that failure mode. We’ll cover the difference between WP-Cron and a real system cron, the database rows that hold queued events, the HTTP request chain that triggers execution, and the specific shared-hosting conditions that break that chain. Every claim here is tied to a reproducible WP-CLI command, SQL query, or code snippet you can run on your own production install.

This matters for small-to-mid publishing teams because wp-cron is not just a convenience. It drives scheduled post transitions, editorial workflow reminders, comment moderation checks, and plugin housekeeping. When events queue but never run, the symptom is often silent: a missed publish time, a stale cache, a failed email digest. By the end of this article, you’ll be able to trace a queued event from the wp_options table to the HTTP request that should have run it, and you’ll know which shared-hosting settings to check first.

Server rack with network cables in a data center
Shared hosting environments often restrict the outbound HTTP requests that wp-cron depends on.

What wp-cron Actually Is

WordPress does not have a background daemon. Instead, it uses a web-triggered scheduler. On every page load, WordPress checks whether any scheduled events are due. If so, it sends an HTTP request to wp-cron.php in the WordPress root. That request runs the due events. The key file is wp-cron.php, and the scheduling logic lives in wp-includes/cron.php.

The queue itself is stored in the wp_options table under the option name cron. The value is a serialized PHP array. Each event has a timestamp, a hook name, and arguments. When an event is due, WordPress spawns a non-blocking HTTP request to wp-cron.php?doing_wp_cron=. That request runs the hook callbacks.

This design has a known failure mode: if no one visits the site, no page load occurs, and no cron runs. But on shared hosting, the more common failure is that page loads happen, the event is due, and the HTTP request to wp-cron.php still never completes. That’s the failure mode this article focuses on.

First Evidence: Check the Queue Directly

Before touching any configuration, look at the actual queued events. The fastest way is WP-CLI:

wp cron event list --fields=hook,next_run_relative,next_run

If you don’t have WP-CLI on the shared host, run this SQL query against the WordPress database:

SELECT option_value FROM wp_options WHERE option_name = 'cron';

The output is a serialized array. You can unserialize it with PHP:

php -r '$cron = get_option("cron"); print_r($cron);'

Or use a one-off script in a mu-plugin to dump the queue to the error log. The point is to confirm two things: the event exists, and its timestamp is in the past. If the timestamp is in the future, the event is simply not due yet. If it’s in the past and still listed, you have a queue-but-not-execute problem.

What a Stuck Queue Looks Like

A healthy queue shows events with next_run_relative values like now or 1 minute. A stuck queue shows events with next_run_relative values like 2 hours ago or 1 day ago. The event is due, but the hook never fired. This is the signature of a broken execution path, not a missing schedule.

Person typing on a laptop with code on the screen
WP-CLI gives you a direct view of the cron queue without waiting for a page load.

The Execution Path: From Page Load to wp-cron.php

When a visitor loads any page on your site, WordPress runs wp_cron() during the shutdown sequence. That function checks the cron option for due events. If it finds any, it calls spawn_cron(), which sends an HTTP request to wp-cron.php. The request is non-blocking: WordPress uses wp_remote_post() with a very short timeout, typically 0.01 seconds. The idea is to fire the request and let the server handle it in the background.

On shared hosting, this is where things break. The non-blocking request depends on the server being able to make an outbound HTTP connection to itself. Many shared hosts block loopback requests, or they restrict the PHP functions that wp_remote_post() uses, such as fsockopen() or curl. If the loopback request fails silently, the event stays queued.

Test the Loopback Request

You can test whether your server can make a loopback request with a small mu-plugin:

add_action('init', function() {
    if (isset($_GET['loopback_test'])) {
        $response = wp_remote_post(home_url('/wp-cron.php'), array(
            'timeout' => 5,
            'blocking' => true,
        ));
        if (is_wp_error($response)) {
            error_log('Loopback test failed: ' . $response->get_error_message());
        } else {
            error_log('Loopback test succeeded: ' . wp_remote_retrieve_response_code($response));
        }
        exit;
    }
});

Then visit https://yourdomain.com/?loopback_test=1 and check the PHP error log. If you see a timeout, a connection refused error, or a DNS failure, the loopback request is the problem. This is the single most common cause of queued-but-never-executed cron events on shared hosting.

Shared Hosting Failure Modes

Shared hosting environments introduce several specific failure modes that don’t appear on a VPS or dedicated server. Here are the ones I’ve seen most often in production installs.

1. Loopback Requests Are Blocked

Some hosts block outbound HTTP requests from PHP scripts as a security measure. This prevents a compromised script from sending spam or participating in a botnet. The side effect is that wp_remote_post() to your own domain fails. The fix is usually to disable WP-Cron and use a real system cron job, which we’ll cover below.

2. The Server Cannot Resolve Its Own Domain

On some shared hosts, the server’s DNS resolver cannot resolve the site’s own domain. The loopback request to https://yourdomain.com/wp-cron.php fails because the server cannot find the IP address. This is more common on hosts that use a CDN or a proxy in front of the origin server. You can test this by running wp eval 'echo wp_remote_retrieve_response_code(wp_remote_get(home_url("/")));' via WP-CLI. If it returns 0 or an error, DNS resolution is likely the issue.

3. PHP Execution Time Limits

Shared hosts often set max_execution_time to 30 seconds or less. The non-blocking cron request is designed to return immediately, but if the server is slow, the request can take longer than the timeout. When the timeout is hit, the request is aborted, and the event never runs. This is more common on hosts with oversold CPU resources.

4. The ALTERNATE_WP_CRON Fallback Is Not Set

WordPress has a fallback mechanism for hosts that block loopback requests. If you define ALTERNATE_WP_CRON as true in wp-config.php, WordPress will redirect the visitor’s browser to wp-cron.php instead of making a server-side loopback request. This works, but it has a cost: the visitor’s page load is delayed while the cron runs. For a publishing site with low traffic, this is often an acceptable tradeoff.

define('ALTERNATE_WP_CRON', true);

Add that line to wp-config.php and test again. If events start running, the loopback request was the problem.

Close-up of server status lights
Server-side loopback restrictions are a common culprit on shared hosting.

The System Cron Fix

The most reliable fix on shared hosting is to disable WP-Cron entirely and run the scheduler from a real system cron job. Most shared hosts provide a cron manager in their control panel, such as cPanel’s Cron Jobs tool. The steps are:

  1. Add define('DISABLE_WP_CRON', true); to wp-config.php.
  2. Create a system cron job that hits wp-cron.php directly on a schedule.

The cron job command depends on your host. For cPanel, it’s typically:

wget -q -O - https://yourdomain.com/wp-cron.php?doing_wp_cron > /dev/null 2>&1

Or if wget isn’t available:

curl -s https://yourdomain.com/wp-cron.php?doing_wp_cron > /dev/null 2>&1

Set the schedule to every 5 or 10 minutes. This bypasses the loopback request entirely because the system cron job runs from the server’s own scheduler, not from a PHP script. It also means cron runs even when no one visits the site.

One caveat: some shared hosts restrict the use of wget or curl in cron jobs. If that happens, you can use a PHP CLI command instead:

php /home/username/public_html/wp-cron.php

But this requires knowing the absolute path to your WordPress install, and it may not work if the host’s PHP CLI is configured differently from the web server’s PHP.

Diagnosing with WP-CLI

WP-CLI is the fastest way to test the cron system without waiting for a page load. Here are the commands I use most often.

List All Events

wp cron event list

Run a Specific Event Immediately

wp cron event run 

This runs the event synchronously, bypassing the HTTP request entirely. If the event runs successfully via WP-CLI but not via page load, the problem is in the HTTP execution path, not in the event callback itself.

Run All Due Events

wp cron event run --due-now

This is useful for clearing a backlog after you’ve fixed the underlying issue.

Check the Cron Option Directly

wp option get cron --format=json

This shows the raw serialized queue. If the option is missing or empty, WordPress will rebuild it on the next page load, but any custom schedules from plugins will be lost until those plugins re-register them.

Common Plugin Interactions

Some plugins add their own cron handlers and can mask or worsen the problem. For example, a backup plugin might schedule a daily event, but if the event never runs, the plugin shows a “next backup: overdue” notice. The fix is the same: diagnose the execution path, not the plugin.

One specific interaction to watch for: object caching plugins. If you use a persistent object cache like Redis or Memcached on shared hosting, the cron option can be cached. When WordPress updates the queue, the cache may not be invalidated, so the page load sees a stale queue and never spawns the cron request. If you suspect this, flush the object cache and test again.

When the Queue Itself Is Corrupt

Occasionally, the cron option becomes corrupt. This can happen if a plugin writes a malformed value, or if the database row is truncated. The symptom is a PHP warning about an invalid cron array, or events that appear and disappear unpredictably.

To check for corruption, run:

wp eval 'var_dump(_get_cron_array());'

If the output is false or contains unexpected types, the option is corrupt. The fix is to delete the option and let WordPress rebuild it:

wp option delete cron

Then visit the site once to trigger a rebuild. Note that this removes all scheduled events, including plugin events. Plugins will re-register their events on the next page load, but any one-off events will be lost.

Editorial Workflow Implications

For a publishing team, a stuck cron queue has direct editorial consequences. Scheduled posts don’t publish. Editorial reminder emails don’t send. Comment moderation queues don’t refresh. The fix isn’t to manually publish posts; it’s to fix the scheduler so the automated workflow works.

One practical step is to add a cron health check to your editorial dashboard. A simple mu-plugin can log the number of overdue events to the error log on every admin page load:

add_action('admin_init', function() {
    $cron = _get_cron_array();
    $overdue = 0;
    foreach ($cron as $timestamp => $events) {
        if ($timestamp < time()) {
            $overdue += count($events);
        }
    }
    if ($overdue > 0) {
        error_log('Overdue cron events: ' . $overdue);
    }
});

This gives you an early warning before a scheduled post misses its publish time. For a deeper look at how scheduled posts interact with the database, see What to Fix First When a New WordPress Site Says Nothing Found.

FAQ

Why do my scheduled posts sometimes publish late on shared hosting?

Scheduled posts rely on wp-cron. If the loopback request to wp-cron.php fails, the event stays queued until a page load successfully triggers it. On shared hosting, loopback restrictions or DNS resolution failures are the most common causes. Test the loopback request with the mu-plugin snippet above, and if it fails, switch to a system cron job.

Can I just disable wp-cron and run everything manually?

You can disable wp-cron with define('DISABLE_WP_CRON', true);, but you must replace it with a system cron job that hits wp-cron.php on a regular schedule. Otherwise, no scheduled events will run at all. The system cron approach is more reliable on shared hosting because it doesn’t depend on a page load or a loopback request.

How do I know if the cron queue is corrupt?

Run wp eval 'var_dump(_get_cron_array());'. If the output is false or contains unexpected types, the cron option is corrupt. Delete it with wp option delete cron and visit the site once to rebuild the queue. Plugins will re-register their events, but one-off events will be lost.

What is the difference between wp-cron and a real system cron?

wp-cron is a web-triggered scheduler: it runs only when someone visits the site, and it depends on an HTTP loopback request. A real system cron runs from the server’s scheduler at fixed intervals, independent of site traffic. On shared hosting, a system cron is more reliable because it bypasses the loopback request and runs even when no one visits the site.

Next Steps for Your Install

Start with the queue. Run wp cron event list and look for overdue events. Then test the loopback request. If it fails, either set ALTERNATE_WP_CRON or switch to a system cron job. Document the fix in your team’s runbook so the next person doesn’t have to rediscover it. And if you’re maintaining multiple production installs, consider a recurring column on this site for shared-hosting failure modes. The next topic worth covering is how to audit plugin cron registrations so you know exactly which events each plugin adds to the queue.

The Way WordPress Handles 404s for Attachment Pages (And Why It Confuses Crawlers)

WordPress attachment pages are a leftover from the pre-block-editor era, when every uploaded media file got its own URL and a template that rendered a single image or document. For a small-to-mid publishing team running its own production install, those attachment URLs are now a quiet source of crawl waste, soft-404 ambiguity, and index bloat. The core behavior is not a bug in the traditional sense: WordPress resolves an attachment URL through the attachment rewrite rules, queries the post_type=attachment post, and only falls back to a 404 when the attachment post itself is missing or the rewrite does not match. The confusion for crawlers comes from the gap between what WordPress considers a valid resource and what a search engine considers a useful landing page.

This article walks through the exact request path, the database rows involved, the HTTP status behavior, and the failure modes that show up in crawl logs. It is written for teams that maintain their own WordPress installs and want reproducible evidence before changing template behavior, redirect rules, or sitemap output.

WordPress attachment page code on a screen

What an Attachment Page Actually Is in Core

When you upload an image through the media library, WordPress creates a post of type attachment in the wp_posts table. The attachment post has a post_parent pointing to the post or page where the file was first uploaded, a post_mime_type such as image/jpeg, and a guid that contains the raw file URL. The attachment post also gets a post_name derived from the filename, which becomes the slug for the attachment page.

You can confirm this with a direct SQL query:

SELECT ID, post_title, post_name, post_parent, post_mime_type, guid
FROM wp_posts
WHERE post_type = 'attachment'
AND post_mime_type LIKE 'image/%'
ORDER BY ID DESC
LIMIT 10;

The attachment page URL is then built from the parent post permalink plus the attachment slug. For a parent post at /2024/09/editorial-workflow-notes/ and an attachment named newsroom-dashboard.png, the attachment URL becomes /2024/09/editorial-workflow-notes/newsroom-dashboard/. That URL is not a redirect to the file. It is a full WordPress page request that loads the attachment.php template if your theme has one, or falls back to single.php or index.php.

The Rewrite and Query Path

WordPress matches the attachment URL through the attachment rewrite rule generated by WP_Rewrite. The rule captures the parent path and the attachment slug, then passes them to index.php?attachment=$matches[1]. The main query then looks for a post of type attachment with that slug. If the attachment post exists, WordPress returns a 200 OK status and renders the template. If the attachment post does not exist, WordPress returns a 404 Not Found status through the normal WP_Query no-results path.

This is the first point of confusion for crawlers: a URL can return 200 OK even when the parent post is unpublished, trashed, or deleted. The attachment post remains in the database unless you explicitly delete the media item. A crawler that follows an old attachment URL from a sitemap, an RSS feed, or an external link can land on a page that shows only an image and a minimal title, with no editorial context and no clear navigation back to the parent article.

Why Crawlers Treat Attachment Pages as Soft 404s

Search engines do not rely only on the HTTP status code. They also evaluate whether a page provides substantive content that matches the query intent. An attachment page for a single image often contains no meaningful text beyond the image title, caption, and description fields. Many themes render the image at full size, add a comment form, and link back to the parent post. That is a thin page by any reasonable standard.

Google’s documentation on soft 404s describes the pattern: a page returns 200 OK but the content is so thin or irrelevant that the crawler treats it as a missing page. Attachment pages are a textbook case. The crawler wastes budget on URLs that will never rank, and the site accumulates index bloat that dilutes the signal from real editorial pages.

You can see the scale of the problem with a simple count:

SELECT COUNT(*) AS attachment_count
FROM wp_posts
WHERE post_type = 'attachment'
AND post_status = 'inherit';

On a site with five years of editorial images, that number can easily exceed the number of published articles. Each attachment URL is a potential crawl target unless you actively block or redirect it.

Crawl log showing attachment page requests

The post_status=inherit Detail

Attachment posts use the inherit post status, not publish. That status means the attachment inherits the status of its parent post. If the parent post is published, the attachment is publicly queryable. If the parent post is trashed, the attachment is not publicly queryable through the normal query, but the attachment post still exists in the database. This inheritance is what makes attachment URLs behave inconsistently after editorial changes.

For example, if you trash a parent post, the attachment URL may start returning a 404 because the parent is no longer available. If you restore the parent, the attachment URL returns 200 again. Crawlers that saw the 404 may not revisit the URL for a long time, and crawlers that saw the 200 before the trash may keep the stale URL in their index.

Reproducing the 404 and 200 Behavior

The fastest way to see the behavior is with WP-CLI and curl. First, find an attachment URL:

wp post list --post_type=attachment --post_mime_type=image/jpeg --format=ids --posts_per_page=1

Then request the URL with headers:

curl -I https://example.com/path-to-parent/attachment-slug/

You will see HTTP/2 200 for a valid attachment page. Now delete the attachment post directly in the database or through the media library, and request the same URL again. You will see HTTP/2 404. The difference is entirely in the wp_posts row, not in the file on disk. The actual image file can still exist in wp-content/uploads/ and be served correctly at its direct file URL, while the attachment page returns 404.

This split between the file URL and the attachment page URL is another source of crawler confusion. A crawler can fetch /wp-content/uploads/2024/09/newsroom-dashboard.png and get a 200 with the image bytes, then fetch /2024/09/editorial-workflow-notes/newsroom-dashboard/ and get a 404. The crawler has no reliable way to know that the two URLs are related unless the site provides a canonical or redirect signal.

What the Database Schema Tells You

The wp_posts table stores the attachment post, but the file metadata lives in wp_postmeta. The _wp_attached_file meta key holds the relative path to the uploaded file, and _wp_attachment_metadata holds a serialized array with sizes, dimensions, and image editor data. The attachment page URL is derived from the post_name and the parent post’s permalink, not from the file path.

This separation means you can change the file on disk without changing the attachment page URL, and you can change the attachment slug without moving the file. It also means that a broken attachment page can exist even when the file is perfectly intact. A crawler that follows the attachment page URL and gets a 200 with a broken image tag has no way to distinguish that from a real editorial page with a broken image.

To inspect the metadata for a specific attachment:

SELECT p.ID, p.post_name, pm.meta_key, pm.meta_value
FROM wp_posts p
LEFT JOIN wp_postmeta pm ON p.ID = pm.post_id
WHERE p.post_type = 'attachment'
AND p.ID = 12345
AND pm.meta_key IN ('_wp_attached_file', '_wp_attachment_metadata');

The serialized metadata is not queryable with normal SQL, but you can see the raw structure and confirm that the file path and the attachment page slug are independent values.

Common Failure Modes in Production Installs

Small-to-mid publishing teams usually hit three specific failure modes with attachment pages.

1. Sitemap and Index Bloat

If you use a sitemap plugin that includes attachment pages by default, every uploaded image gets a sitemap entry. A site with 10,000 images submits 10,000 thin URLs to search engines. The crawler spends budget on those URLs instead of your actual articles. You can check whether your sitemap includes attachments by looking for post_type=attachment in the sitemap XML or by running a quick crawl of your own sitemap with a tool like wget or curl.

The fix is usually a filter or a plugin setting that excludes attachment pages from the sitemap. In code, you can use the wp_sitemaps_post_types filter to remove the attachment post type from core sitemaps:

add_filter( 'wp_sitemaps_post_types', function( $post_types ) {
    unset( $post_types['attachment'] );
    return $post_types;
} );

This is a one-line change that prevents future sitemap bloat, but it does not fix URLs that are already indexed.

2. Soft 404s from Thin Templates

Even when the attachment page returns 200, the template may render so little content that search engines treat it as a soft 404. The default attachment.php in many classic themes shows the image, the caption, and a comment form. There is no article text, no related content, and no clear purpose for a reader who lands on the page from search.

You can test this by viewing the rendered HTML of an attachment page and counting the visible text. If the text is under 100 words and the page has no unique value, it is a soft-404 candidate. The fix is either to redirect attachment pages to the parent post or to the file URL, or to build a genuinely useful attachment template with context, metadata, and navigation. Most publishing teams choose the redirect because it is simpler and preserves crawl budget.

3. Orphaned Attachments After Parent Deletion

When you delete a parent post, WordPress does not automatically delete the attachment posts. The attachment posts remain with post_parent pointing to a non-existent post ID. The attachment page URL may return 404 because the parent is missing, but the attachment post still exists in the database. This creates a mismatch between the database state and the URL behavior.

You can find orphaned attachments with a SQL query:

SELECT a.ID, a.post_title, a.post_parent
FROM wp_posts a
LEFT JOIN wp_posts p ON a.post_parent = p.ID
WHERE a.post_type = 'attachment'
AND p.ID IS NULL;

These orphaned rows are not harmful by themselves, but they can confuse plugins that iterate over attachments, and they can produce unexpected 404s in crawl logs. A cleanup routine that deletes orphaned attachments or reassigns them to a valid parent is a reasonable maintenance task for a production install.

How to Decide: Redirect, Block, or Keep

The right choice depends on your editorial workflow and your archive strategy. There is no universal answer, but there are three defensible positions.

Redirect to the parent post. This is the most common choice for publishing teams. It preserves the link equity from any external links to the attachment page, sends readers to a useful page, and removes the thin page from the index. You can implement it with a template redirect in a child theme or a small plugin:

add_action( 'template_redirect', function() {
    if ( is_attachment() ) {
        global $post;
        if ( $post && $post->post_parent ) {
            wp_safe_redirect( get_permalink( $post->post_parent ), 301 );
            exit;
        }
    }
} );

This redirect sends every attachment page to its parent post. If the parent post is missing, the redirect falls through and the attachment page returns its normal 404 or 200 behavior. You can extend the snippet to redirect to the file URL instead, but that sends readers to a raw image with no editorial context, which is rarely useful.

Block attachment pages with a 404 or 410. Some teams prefer to return a hard 404 for all attachment pages, even when the attachment post exists. This is a stronger signal to crawlers that the URL should be removed from the index. The downside is that any external links to attachment pages will land on a 404, which is a poor user experience. If you choose this route, make sure your 404 template is useful and includes a search form and links to recent articles.

Keep attachment pages and improve the template. This is the least common choice, but it can work for sites that publish photography, infographics, or other visual content where the attachment page has standalone value. The template needs to include the image at a reasonable size, the caption, the description, the parent post link, related images, and enough text to avoid a soft 404. This is more work than a redirect, and it only makes sense if your attachment pages have a real audience.

WordPress database schema for attachment posts

What the Crawl Logs Actually Show

If you have access to server logs or a crawl tool, look for the pattern of attachment URLs being requested repeatedly. A typical log entry looks like this:

66.249.66.1 - - [12/Sep/2024:08:14:22 +0000] "GET /2024/09/editorial-workflow-notes/newsroom-dashboard/ HTTP/2" 200 1842 "-" "Googlebot/2.1 (+http://www.google.com/bot.html)"

The 200 status with a small response size is the signature of a thin attachment page. If you see hundreds of these requests per week, the crawler is spending budget on URLs that will never rank. After you implement a redirect, the same URL should return a 301 and the crawler should follow it to the parent post. The log entry changes to:

66.249.66.1 - - [12/Sep/2024:08:15:02 +0000] "GET /2024/09/editorial-workflow-notes/newsroom-dashboard/ HTTP/2" 301 0 "-" "Googlebot/2.1 (+http://www.google.com/bot.html)"

That 301 is the signal you want. It tells the crawler that the attachment URL is permanently moved, and it consolidates any link equity into the parent post.

Checking Your Own Install

Before changing anything, run a quick audit. Use WP-CLI to count attachments, check the sitemap, and sample a few attachment URLs:

wp post list --post_type=attachment --format=count
wp option get permalink_structure
wp eval 'var_dump( wp_sitemaps_get_server()->get_sitemaps() );'

Then request a sample of attachment URLs with curl -I and note the status codes. If you see a mix of 200, 301, and 404, your attachment handling is inconsistent. That inconsistency is what confuses crawlers most: the same type of URL behaves differently depending on the parent post status, the theme template, and the plugin stack.

For a deeper look at how WordPress handles missing content in general, see What to Fix First When a New WordPress Site Says Nothing Found. The 404 path for attachment pages shares the same query and template fallback logic, but the attachment-specific rewrite rules add an extra layer of indirection.

FAQ

Why does WordPress create attachment pages at all?

Attachment pages are a legacy feature from the early WordPress architecture, when every uploaded file was treated as a post-like object with its own URL. The attachment post type still exists in core because themes and plugins rely on it for media metadata, even though the standalone attachment page template is rarely useful for modern publishing sites.

Do attachment pages hurt SEO?

They can, but not because of a penalty. The harm comes from crawl budget waste, index bloat, and soft-404 signals. A site with thousands of thin attachment pages gives search engines more URLs to crawl without adding any substantive content. Redirecting or blocking attachment pages usually improves crawl efficiency and consolidates link equity into real editorial pages.

What is the difference between an attachment page 404 and a normal 404?

A normal 404 occurs when the requested URL does not match any rewrite rule or when the main query finds no post. An attachment page 404 occurs when the rewrite rule matches but the attachment post is missing, or when the parent post is unavailable and the attachment inherits that unavailable status. The HTTP status code is the same, but the underlying query path is different.

Can I disable attachment pages without a plugin?

Yes. The template_redirect snippet shown earlier is a complete solution for redirecting attachment pages to their parent posts. You can add it to a child theme’s functions.php or to a small custom plugin. For blocking attachment pages entirely, you can use the same hook to return a 404 or 410 status instead of redirecting.

Next Step for Your Install

Run the audit queries, check your sitemap, and sample a dozen attachment URLs. If you find thin pages returning 200, implement the redirect and monitor the crawl logs for the 301 pattern. Then document the decision in your team’s editorial workflow notes so that future uploads follow the same rule. This is a small change with a measurable impact on crawl efficiency, and it removes one of the quietest sources of index noise in a self-managed WordPress install.

Why Your Block Styles Enqueue in the Wrong Order When theme.json and PHP Both Declare Them

Block styles in WordPress are the CSS rules that shape how core blocks, custom blocks, and theme-defined patterns render on the front end. When a theme declares styles in theme.json and also enqueues stylesheets through PHP, the resulting cascade order can break your most deliberate design decisions. This matters for small-to-mid publishing teams that maintain their own production installs because a single misplaced stylesheet can override editorial components, alter the reading experience, and force editors to fight the block editor instead of publishing. The failure mode is not theoretical: it appears in the wp_enqueue_scripts hook, in the generated global-styles-inline-css output, and in the order WordPress prints styles in the document head.

This article explains the exact mechanism behind the ordering problem, shows how to reproduce it with a minimal theme, and gives you WP-CLI and SQL checks to confirm what is happening on your own site. The focus is on WordPress core internals, database schema, block editor behavior, and editorial workflow automation for teams that run their own production installs.

Code editor showing CSS and PHP files side by side

The Core Conflict: Two Declaration Paths, One Cascade

WordPress supports two primary ways for a theme to declare block styles. The first is theme.json, the configuration file that defines global styles, block-specific styles, and settings for the block editor. The second is the traditional PHP enqueue system, where a theme calls wp_enqueue_style() or wp_enqueue_block_style() inside a hook such as wp_enqueue_scripts or enqueue_block_assets. Both paths are valid, but they are processed at different times and with different priorities.

When both paths declare styles for the same block, the final order in the document head is not always what you expect. WordPress prints theme.json-generated styles through the global-styles-inline-css handle, which is registered and enqueued by the core block styles engine. PHP-enqueued stylesheets are printed according to their own dependencies and priorities. The result is that a PHP stylesheet with a lower priority can load before the inline global styles, or after them, depending on the hook and the dependency graph.

The practical consequence is that a style declared in theme.json can be overridden by a PHP stylesheet that loads later, even if the PHP stylesheet was intended to be a fallback. Conversely, a PHP stylesheet that loads before the inline global styles can be overridden by the theme.json output, which may surprise developers who assumed PHP always wins.

How WordPress Generates and Prints Block Styles

To understand the ordering problem, you need to know the sequence WordPress follows when it builds the front-end page. The block styles engine reads theme.json during the wp_enqueue_scripts action, but the actual CSS is generated later, when the wp_print_styles action runs. The generated CSS is attached to the global-styles-inline-css handle, which is a dependency of the global-styles handle. This means the inline CSS is printed after the global-styles stylesheet, but before any stylesheet that depends on global-styles.

PHP-enqueued stylesheets do not automatically depend on global-styles. If you enqueue a stylesheet with wp_enqueue_style() and do not declare a dependency on global-styles, WordPress may print it before the inline global styles. This is the most common cause of the wrong order: a PHP stylesheet that loads before the theme.json output, so the theme.json rules win.

Here is a minimal reproduction. Create a theme with a theme.json that sets a custom color for the core paragraph block:

{
  "version": 2,
  "settings": {
    "color": {
      "palette": [
        {
          "slug": "brand",
          "color": "#0a5c8a",
          "name": "Brand"
        }
      ]
    }
  },
  "styles": {
    "blocks": {
      "core/paragraph": {
        "color": {
          "text": "var(--wp--preset--color--brand)"
        }
      }
    }
  }
}

Then enqueue a PHP stylesheet in functions.php that sets the paragraph color to red:

add_action( 'wp_enqueue_scripts', function() {
    wp_enqueue_style(
        'my-theme-paragraph-override',
        get_stylesheet_directory_uri() . '/paragraph-override.css',
        array(),
        '1.0.0'
    );
} );

In paragraph-override.css, add:

p {
    color: red;
}

On the front end, the paragraph text will be the brand color from theme.json, not red. The reason is that the PHP stylesheet is printed before the inline global styles, so the theme.json rule wins. If you inspect the document head, you will see the PHP stylesheet link before the style id="global-styles-inline-css" block.

Browser inspector showing stylesheet order in the document head

Reproducing the Order with WP-CLI and SQL

You can confirm the order without opening a browser. First, use WP-CLI to list the enqueued styles and their dependencies:

wp eval 'global $wp_styles; foreach ( $wp_styles->queue as $handle ) { echo $handle . "\n"; }'

This prints the queue in the order WordPress will process it. If my-theme-paragraph-override appears before global-styles-inline-css, you have the ordering problem. You can also check the dependencies of a specific handle:

wp eval 'global $wp_styles; var_dump( $wp_styles->registered["my-theme-paragraph-override"]->deps );'

If the dependencies array is empty, the stylesheet has no relationship to global-styles, and WordPress will print it in the default order.

For a database-level check, you can query the wp_posts table for the wp_global_styles post type, which stores the compiled theme.json data. This is useful when you suspect that a cached or stale global styles post is affecting the output:

SELECT ID, post_title, post_content FROM wp_posts WHERE post_type = 'wp_global_styles' AND post_status = 'publish';

The post_content field contains the JSON representation of the global styles, including any block-specific rules. If you see rules that you did not expect, the database may be holding an older version of theme.json that is still being used by the block styles engine.

Why the Hook and Dependency Graph Matter

The order problem is not just about theme.json versus PHP. It is about the hook you use and the dependency graph you build. WordPress processes styles in the order they are enqueued, but dependencies can reorder the queue. If you enqueue a PHP stylesheet on wp_enqueue_scripts with a dependency on global-styles, WordPress will print it after the inline global styles. This is the correct way to ensure that your PHP stylesheet can override theme.json rules when you intend it to.

Here is the corrected enqueue:

add_action( 'wp_enqueue_scripts', function() {
    wp_enqueue_style(
        'my-theme-paragraph-override',
        get_stylesheet_directory_uri() . '/paragraph-override.css',
        array( 'global-styles' ),
        '1.0.0'
    );
} );

With this dependency, the PHP stylesheet is printed after the inline global styles, and the red color wins. The dependency graph is the key to controlling the cascade order when both declaration paths are in play.

However, there is a tradeoff. If you add a dependency on global-styles, your stylesheet will not load if the global styles handle is not enqueued. In most block themes, global-styles is always enqueued, but in a classic theme that does not use theme.json, the handle may not exist. You need to check for the handle before adding the dependency, or use a conditional enqueue.

The Block Editor Side: Editor Styles and Front-End Styles

The ordering problem also appears in the block editor, but the mechanism is slightly different. In the editor, WordPress enqueues editor styles through the enqueue_block_editor_assets hook, and it also generates editor-specific inline styles from theme.json. The editor uses an iframe for the block canvas, and the styles are loaded inside that iframe. If you enqueue a PHP stylesheet for the editor without the correct dependency, it can load before the theme.json editor styles, causing the same override problem in the editing experience.

For editorial teams, this is a workflow issue. If the editor preview does not match the front end, editors will make decisions based on incorrect styling. They may add inline styles to blocks to compensate, which then creates a new layer of overrides that is even harder to debug. The fix is the same: declare a dependency on the appropriate global styles handle for the editor context.

Here is an example for the editor:

add_action( 'enqueue_block_editor_assets', function() {
    wp_enqueue_style(
        'my-theme-editor-override',
        get_stylesheet_directory_uri() . '/editor-override.css',
        array( 'wp-edit-blocks' ),
        '1.0.0'
    );
} );

The wp-edit-blocks handle is a dependency of the editor styles, but it does not guarantee that your stylesheet loads after the theme.json editor styles. To be precise, you should depend on the handle that WordPress uses for the editor global styles, which is wp-block-editor in recent versions. Test the order in the editor with the browser inspector, and adjust the dependency until your stylesheet appears after the inline global styles.

Database Schema and the Global Styles Post

The wp_global_styles post type is part of the database schema that WordPress uses to store theme.json data. When you save changes in the site editor, WordPress writes a new wp_global_styles post with the modified JSON. This post is then used to generate the inline CSS on the front end. If you have a stale wp_global_styles post, the generated CSS may not match your current theme.json, which can make the ordering problem appear even when your PHP enqueue is correct.

You can check for stale global styles posts with WP-CLI:

wp post list --post_type=wp_global_styles --post_status=publish --format=table

If you see multiple posts, the most recent one is the active global styles. Older posts are kept for revisions, but they should not affect the front end. If you suspect a stale post is causing problems, you can delete the revisions with a SQL query, but be careful: this is a destructive operation. Always back up the database first.

DELETE FROM wp_posts WHERE post_type = 'wp_global_styles' AND post_status = 'inherit';

This removes the revision posts, leaving only the published global styles. After running this, clear any caching plugins and re-check the front-end order.

Practical Fixes for Production Installs

The most reliable fix is to stop declaring the same block styles in two places. Choose one source of truth for each block style. If a style belongs to the theme’s design system, declare it in theme.json. If a style is a one-off override for a specific template or context, enqueue it through PHP with a dependency on global-styles. This separation prevents the cascade from becoming a guessing game.

For teams that need to override theme.json styles from PHP, the dependency approach is the correct pattern. Here is a complete example that works in a block theme:

add_action( 'wp_enqueue_scripts', function() {
    $handle = 'my-theme-contextual-override';
    $src = get_stylesheet_directory_uri() . '/contextual-override.css';
    $deps = array( 'global-styles' );
    wp_enqueue_style( $handle, $src, $deps, '1.0.0' );
} );

In contextual-override.css, use the same specificity as the theme.json rule, or higher, to ensure the override applies. For example, if theme.json targets .wp-block-paragraph, your PHP stylesheet should target the same class or a more specific selector.

If you need to override a theme.json style only in the editor, use the enqueue_block_editor_assets hook and depend on the editor global styles handle. Test the order in the editor iframe, because the dependency graph in the editor is not identical to the front end.

When the Order Is Correct but the Style Still Fails

Sometimes the enqueue order is correct, but the style still does not apply. This can happen when the CSS specificity of the theme.json rule is higher than your PHP rule. WordPress generates theme.json styles with a specific selector structure, often using :root and .wp-block-* classes. If your PHP stylesheet uses a less specific selector, the theme.json rule wins even if it loads earlier.

To debug this, inspect the computed style in the browser and look at the selector that is winning. Then adjust your PHP stylesheet to use a selector with equal or higher specificity. You can also use the !important flag, but that creates a new layer of overrides that is hard to maintain. A better approach is to match the selector structure that WordPress generates.

Here is an example. If theme.json generates:

.wp-block-paragraph {
    color: var(--wp--preset--color--brand);
}

Your PHP override should use the same class:

.wp-block-paragraph {
    color: red;
}

If you use p as the selector, the .wp-block-paragraph rule has higher specificity and wins. This is a common mistake that looks like an enqueue order problem but is actually a specificity problem.

Editorial Workflow Implications

For a publishing team, the wrong block style order is not just a technical annoyance. It changes how editors perceive the content they are working on. If the editor preview shows a different style than the front end, editors will make formatting decisions based on false information. They may add inline styles, change block settings, or restructure content to compensate. This creates a feedback loop of overrides that makes the site harder to maintain.

The fix is to treat the block style order as part of the editorial workflow. When you change a style in theme.json, test the front end and the editor immediately. Use a staging environment that mirrors the production database, including the wp_global_styles posts. If you use a deployment process, include a step that checks the enqueue order with WP-CLI before the site goes live.

One practical approach is to add a WP-CLI command to your deployment script that prints the style queue and fails if a known override handle appears before global-styles-inline-css. This is a simple guard that catches the ordering problem before it reaches production.

Editor reviewing block styles in WordPress admin

Common Failure Modes and Their Symptoms

Here are the failure modes I see most often in production installs, along with the symptoms and the fix.

Failure Mode 1: PHP Stylesheet Loads Before Global Styles

Symptom: A style declared in theme.json overrides a PHP stylesheet, even though the PHP stylesheet was intended to be the override.

Cause: The PHP stylesheet has no dependency on global-styles.

Fix: Add array( 'global-styles' ) to the dependencies in wp_enqueue_style().

Failure Mode 2: Stale Global Styles Post

Symptom: The front end shows styles that do not match the current theme.json, even after clearing the cache.

Cause: A wp_global_styles post in the database is out of date.

Fix: Check the wp_global_styles posts with WP-CLI, delete revisions if necessary, and re-save the site editor settings.

Failure Mode 3: Specificity Mismatch

Symptom: The enqueue order is correct, but the PHP style still does not apply.

Cause: The theme.json rule has higher specificity than the PHP rule.

Fix: Match the selector structure of the generated theme.json CSS, or increase the specificity of the PHP rule.

FAQ

Why does my PHP stylesheet load before the theme.json styles?

Your PHP stylesheet loads before the theme.json styles because it has no dependency on the global-styles handle. WordPress prints styles in the order they are enqueued, and the inline global styles are attached to the global-styles-inline-css handle, which is a dependency of global-styles. If your stylesheet does not declare global-styles as a dependency, it will be printed before the inline global styles.

How do I make a PHP stylesheet override theme.json styles?

Add array( 'global-styles' ) as the dependencies argument in wp_enqueue_style(). This tells WordPress to print your stylesheet after the inline global styles, so your rules can override the theme.json output. Make sure your CSS selectors have equal or higher specificity than the generated theme.json selectors.

Can I check the enqueue order without opening a browser?

Yes. Use WP-CLI to print the style queue: wp eval 'global $wp_styles; foreach ( $wp_styles->queue as $handle ) { echo $handle . "\n"; }'. If your PHP stylesheet handle appears before global-styles-inline-css, the order is wrong. You can also check the dependencies of a specific handle with wp eval 'global $wp_styles; var_dump( $wp_styles->registered["your-handle"]->deps );'.

What is the wp_global_styles post type?

The wp_global_styles post type stores the compiled theme.json data in the WordPress database. When you save changes in the site editor, WordPress writes a new wp_global_styles post. The block styles engine reads this post to generate the inline CSS on the front end. If the post is stale, the generated CSS may not match your current theme.json.

Next Steps for Your Site

If you are maintaining a production install, start by auditing your current enqueue order. Run the WP-CLI command above and look for any PHP stylesheet that appears before global-styles-inline-css. Then check the wp_global_styles posts for staleness. Fix the dependencies first, because that is the most common cause of the wrong order. After that, test the editor preview against the front end to make sure the editorial workflow is not being distorted by the cascade.

This topic connects to a broader question about how WordPress handles the relationship between the database, the block editor, and the front end. If you are also dealing with a site that returns nothing found on new posts, the fix may be related to the rewrite rules or the database schema. See What to Fix First When a New WordPress Site Says Nothing Found for a concrete debugging path.

For a deeper look at the block styles engine, the WordPress core source for wp-includes/global-styles-and-settings.php is the authoritative reference. The function wp_enqueue_global_styles() shows how the global-styles handle is registered and how the inline CSS is attached. Reading that source will give you the exact sequence of events, which is more reliable than any summary.

The next time you change a block style, do it in one place. If you must override from PHP, declare the dependency and test the order. Your editors will see the same styles you see, and your production install will stop fighting itself.

How to Reconstruct a Dead Plugin’s Database Footprint Before Uninstalling

When a plugin stops getting updates, throws fatal errors on modern PHP, or just vanishes from the repository, the first impulse is to delete it and move on. That impulse is wrong for anyone running a production WordPress install. A dead plugin is not just a folder in wp-content/plugins. It is a set of rows in wp_options, a possible custom table or two, user meta entries, capabilities, cron events, and sometimes transients that still fire. If you uninstall without mapping that footprint, you leave orphaned data that can slow queries, confuse future migrations, or create conflicts with replacement plugins. This article shows how to reconstruct that footprint using SQL, WP-CLI, and the WordPress database schema itself before you remove anything.

This matters for small-to-mid publishing teams that maintain their own installs. You do not have a staging environment with a dedicated DBA. You have a production database, a backup plugin, and a terminal window. The goal is not to preserve dead code. The goal is to know exactly what the dead code left behind, so the uninstall is clean and reversible.

Start with the plugin’s declared schema, not its folder

Before touching the database, read the plugin’s main file and its uninstall routine. Many plugins register tables, options, and cron hooks in the main PHP file. Even if the plugin is dead, the code is still on disk and still readable. Look for register_activation_hook, register_deactivation_hook, register_uninstall_hook, and any dbDelta calls. These tell you what the plugin intended to create.

grep -R "register_activation_hook\|dbDelta\|add_option\|update_option\|wp_schedule_event" wp-content/plugins/dead-plugin/

This is not a complete map. Plugins often create options lazily, only when a feature is used. But the declared hooks give you the first layer: the plugin’s own assumptions about its footprint. Write those down. You will compare them against what actually exists in the database.

Inventory options with a prefix pattern

Most plugins store settings in wp_options using a consistent prefix. The prefix is usually the plugin slug or an abbreviation. If the plugin is called “Old Gallery Pro,” the options might be ogp_, old_gallery_, or ogpro_. You can find the prefix by grepping the plugin source for get_option and update_option calls.

grep -R "get_option\|update_option" wp-content/plugins/dead-plugin/ | head -50

Once you have candidate prefixes, query the options table. This query returns every option whose name starts with a given prefix, along with its autoload status and a truncated value. Autoload status matters because large autoloaded options are loaded on every request, even after the plugin is gone.

SELECT option_name, LENGTH(option_value) AS value_length, autoload
FROM wp_options
WHERE option_name LIKE 'ogp\_%'
ORDER BY option_name;

The underscore in the LIKE pattern is escaped because _ is a single-character wildcard in SQL. If you forget the backslash, you will match ogpX and ogp1 as well. That is a real failure mode when you are cleaning up after a plugin with a short prefix.

Do not stop at one prefix. Some plugins use multiple prefixes for different subsystems. A gallery plugin might use ogp_ for settings, ogp_album_ for album metadata, and ogp_cache_ for cached thumbnails. Grep the source for all get_option calls and collect every distinct prefix.

Find orphaned custom tables

Plugins that store large datasets often create custom tables. The table names usually follow the WordPress prefix, so a plugin with the slug old-gallery-pro might create wp_ogp_albums and wp_ogp_photos. To find them, list all tables that are not part of the core WordPress schema.

SELECT table_name
FROM information_schema.tables
WHERE table_schema = DATABASE()
AND table_name NOT IN (
  'wp_commentmeta', 'wp_comments', 'wp_links', 'wp_options',
  'wp_postmeta', 'wp_posts', 'wp_term_relationships',
  'wp_term_taxonomy', 'wp_termmeta', 'wp_terms',
  'wp_usermeta', 'wp_users'
);

This returns every non-core table, including tables from other plugins and any custom tables you created yourself. You need to match the table names against the dead plugin’s source. Grep for CREATE TABLE and $wpdb->prefix in the plugin folder.

grep -R "CREATE TABLE\|\$wpdb->prefix" wp-content/plugins/dead-plugin/

If the plugin used dbDelta, the table creation statements are usually in an includes or admin subfolder. The table names will be concatenated from $wpdb->prefix and a literal string. That literal string is what you look for in the information_schema output.

Before dropping any table, export it. A dead plugin’s table might contain data you need for a migration, or it might be the only record of a content relationship that a replacement plugin needs to rebuild. Use mysqldump or WP-CLI to export the table to a file, then store that file outside the web root.

wp db export --tables=wp_ogp_albums,wp_ogp_photos /tmp/dead-plugin-tables.sql

Trace user meta and capabilities

Plugins that add roles or capabilities write to wp_usermeta and wp_options. A membership plugin might add a wp_capabilities entry for a custom role, or a wp_user_level value. A plugin that stores per-user preferences writes to wp_usermeta with a key like ogp_user_settings.

To find user meta left by the dead plugin, query for keys that match the plugin’s prefix or slug.

SELECT user_id, meta_key, meta_value
FROM wp_usermeta
WHERE meta_key LIKE '%ogp%'
OR meta_key LIKE '%old_gallery%'
ORDER BY meta_key, user_id;

Capabilities are trickier. They are stored as serialized arrays in wp_options under the key wp_user_roles, and as per-user serialized arrays in wp_usermeta under wp_capabilities. If the dead plugin registered a custom role, that role is still in the wp_user_roles option. You can inspect it with WP-CLI.

wp role list --fields=role,name

If you see a role that only the dead plugin used, note it. Removing the role is not as simple as deleting the option. You need to remove the role from every user who has it, then remove the role definition. WP-CLI can do this, but only after you have confirmed no other plugin or theme depends on that role.

wp role exists ogp_editor
wp user list --role=ogp_editor --fields=ID,user_login

If the role exists and has users, reassign those users to a standard role before removing the custom role. Otherwise you leave users with a capability set that no longer resolves to a defined role, which can cause unexpected access denials or, worse, unexpected access grants if a future plugin reuses the same role slug.

Check cron events and scheduled tasks

Dead plugins often leave scheduled events in wp_options under the cron option. These events fire on every page load if their scheduled time has passed, and they call functions that no longer exist. That produces PHP warnings in your error log and, in some cases, fatal errors that take down the site.

List all scheduled events and look for hooks that match the dead plugin’s slug or function names.

wp cron event list --fields=hook,next_run_relative,recurrence

If you see a hook like ogp_daily_cleanup or old_gallery_sync, that is a leftover. You can remove it with WP-CLI.

wp cron event delete ogp_daily_cleanup

But before deleting, check whether the hook is registered anywhere else. A theme or a must-use plugin might have taken over the hook. Grep the entire wp-content directory for the hook name.

grep -R "ogp_daily_cleanup" wp-content/

If the only match is in the dead plugin’s folder, the event is safe to remove. If the hook appears in a theme or another plugin, you need to understand that dependency before deleting the event.

Inspect transients and object cache leftovers

Transients are stored in wp_options with a _transient_ prefix. They expire, but a dead plugin’s transients might have long expiration times or might be set to autoload. A plugin that cached external API responses might have left hundreds of transients that are still being loaded on every request.

SELECT option_name, LENGTH(option_value) AS value_length, autoload
FROM wp_options
WHERE option_name LIKE '\_transient\_ogp%'
OR option_name LIKE '\_transient\_timeout\_ogp%'
ORDER BY option_name;

The _transient_timeout_ entries are companion rows that store the expiration timestamp. If you delete the transient but not the timeout, WordPress will try to read a missing transient and then delete the orphaned timeout on the next request. That is harmless but messy. Delete both.

DELETE FROM wp_options
WHERE option_name LIKE '\_transient\_ogp%'
OR option_name LIKE '\_transient\_timeout\_ogp%';

If you use an object cache like Redis or Memcached, transients might be stored there instead of the database. Flush the object cache after deleting the database rows, or the old values will be served until the cache expires.

wp cache flush

Map post meta and taxonomy terms

Plugins that extend content types often write to wp_postmeta and wp_term_taxonomy. A gallery plugin might store image metadata in wp_postmeta with keys like _ogp_image_id or _ogp_album_order. A plugin that adds custom taxonomies might have registered a taxonomy that is still present in wp_term_taxonomy.

Find post meta keys that match the plugin’s prefix.

SELECT meta_key, COUNT(*) AS row_count
FROM wp_postmeta
WHERE meta_key LIKE '\_ogp%'
OR meta_key LIKE 'ogp%'
GROUP BY meta_key
ORDER BY row_count DESC;

Do not delete these rows yet. Some post meta is used by the block editor or by other plugins that read the same keys. A replacement gallery plugin might import the old plugin’s post meta to rebuild galleries. Export the rows first, then decide whether to delete them.

For taxonomies, check wp_term_taxonomy for taxonomy names that match the dead plugin.

SELECT taxonomy, COUNT(*) AS term_count
FROM wp_term_taxonomy
WHERE taxonomy LIKE '%ogp%'
OR taxonomy LIKE '%old_gallery%'
GROUP BY taxonomy;

If the dead plugin registered a custom taxonomy, the terms are still in wp_terms and wp_term_taxonomy. The taxonomy itself is registered in code, so once the plugin is deleted, the taxonomy no longer exists. But the term rows remain. They are orphaned data. You can delete them, but first check whether any posts are still assigned to those terms.

SELECT p.ID, p.post_title
FROM wp_posts p
INNER JOIN wp_term_relationships tr ON p.ID = tr.object_id
INNER JOIN wp_term_taxonomy tt ON tr.term_taxonomy_id = tt.term_taxonomy_id
WHERE tt.taxonomy = 'ogp_album'
LIMIT 50;

If posts are assigned to the dead taxonomy, you need to decide what to do with those assignments. Deleting the terms will remove the assignments, but the posts themselves remain. That is usually the correct outcome, but only after you have confirmed the posts do not rely on the taxonomy for display or routing.

Reconstruct the full footprint in a single report

You can combine these queries into a single WP-CLI command or a SQL script that outputs a complete footprint report. The report should include options, tables, user meta, cron events, transients, post meta, and taxonomy terms. This is the document you review before uninstalling.

wp db query "SELECT 'options' AS type, option_name AS name, LENGTH(option_value) AS size, autoload AS extra FROM wp_options WHERE option_name LIKE 'ogp\\_%' UNION ALL SELECT 'tables', table_name, 0, '' FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name LIKE 'wp\\_ogp%' UNION ALL SELECT 'usermeta', meta_key, 0, '' FROM wp_usermeta WHERE meta_key LIKE '%ogp%' UNION ALL SELECT 'postmeta', meta_key, COUNT(*), '' FROM wp_postmeta WHERE meta_key LIKE '\\_ogp%' GROUP BY meta_key;"

The escaping in this query is ugly because WP-CLI passes the SQL through a shell. If you are running this from a SQL client, you can simplify the escaping. The point is to produce one output that shows every database object the dead plugin touched.

Save that report to a file. It is your rollback plan. If the uninstall breaks something, the report tells you exactly what to restore.

What to do before you click delete

Once you have the footprint report, take a full database backup. Do not rely on the report alone. A backup is the only way to restore the exact state if something goes wrong.

wp db export /backups/pre-uninstall-dead-plugin-$(date +%Y%m%d).sql

Then deactivate the plugin, but do not delete it. Deactivation triggers the plugin’s deactivation hook, which might clean up some data or leave it in a different state. Check the footprint again after deactivation. If the deactivation hook removed some options or cron events, your uninstall plan changes.

Only after deactivation and a second footprint check should you delete the plugin. And even then, prefer deleting via WP-CLI or the admin interface, not by removing the folder over FTP. The admin delete process runs the plugin’s uninstall hook if it has one. That hook might clean up data you would otherwise have to remove manually.

wp plugin deactivate dead-plugin
wp plugin delete dead-plugin

After deletion, run the footprint queries again. Anything that remains is orphaned data. You can now remove it manually, using the report as your checklist.

Common failure modes when skipping this process

The most common failure is autoloaded options. A dead plugin that stored a large serialized array in wp_options with autoload = 'yes' continues to load that array on every request. If the array is a few megabytes, your site’s memory usage stays elevated forever. You can find the worst offenders with this query.

SELECT option_name, LENGTH(option_value) AS value_length
FROM wp_options
WHERE autoload = 'yes'
ORDER BY value_length DESC
LIMIT 20;

If any of the top entries match the dead plugin’s prefix, that is your smoking gun. Delete them after the uninstall.

Another failure mode is a leftover cron event that calls a missing function. WordPress fires the event, the function does not exist, and PHP logs a fatal error. If the event is scheduled to run frequently, your error log fills up and your site’s performance degrades. The fix is to delete the event, but only after confirming the hook is not registered elsewhere.

A third failure mode is a custom table that a replacement plugin tries to reuse. If the replacement plugin has the same table name but a different schema, the old table causes a conflict. The replacement plugin’s activation routine might fail, or it might write data into a table with the wrong columns. Dropping the old table before installing the replacement prevents this.

When to keep the data instead of deleting it

Not every orphaned row should be deleted. If you plan to migrate to a replacement plugin, the old plugin’s data might be the only source of truth for content relationships, user preferences, or historical records. In that case, export the data and keep the export file. You can delete the database rows after the migration is complete and verified.

If the dead plugin stored content in custom tables, those tables might contain data that belongs in wp_posts or wp_postmeta. A migration script can read the old tables and write the data into the new plugin’s format. That script is easier to write if the old tables still exist. So do not drop them until the migration is done.

If the dead plugin registered a custom post type, the posts of that type are still in wp_posts with a post_type value that no longer resolves. Those posts are invisible in the admin unless you register the post type again. You can either delete them or convert them to a standard post type. Converting is often better for SEO, because the posts might have inbound links.

UPDATE wp_posts
SET post_type = 'post'
WHERE post_type = 'ogp_gallery'
AND post_status = 'publish';

This is a destructive operation. Back up first. And check whether the posts have meta boxes or taxonomies that only make sense for the old post type. Converting the post type does not convert the meta.

Document the footprint for the next person

After the uninstall is complete, write a short note in your team’s internal documentation. Include the plugin name, the date, the footprint report, and what you deleted. If a future team member wonders why a certain option is missing or why a table no longer exists, the note answers the question.

This is not busywork. Production WordPress installs accumulate decisions. A dead plugin’s footprint is a decision someone made years ago. If you do not record the cleanup, the next person has to reconstruct it from scratch. That is wasted time and a real risk of deleting something important.

If your team maintains multiple installs, consider a recurring column or internal checklist for plugin retirements. The same process applies to themes, but themes have a smaller database footprint. The discipline of mapping before deleting is the same.

FAQ

How do I know if a dead plugin left autoloaded options?

Run a query against wp_options that filters for autoload = 'yes' and sorts by value length. If any option names match the dead plugin’s prefix or slug, those are autoloaded leftovers. You can also use WP-CLI to list autoloaded options and their sizes.

wp option list --autoload=yes --fields=option_name,option_value --format=table | head -50

The option_value field is truncated in the table view, but the option names are enough to identify the plugin.

What is the safest order for uninstalling a dead plugin?

Deactivate first, then check the footprint again, then delete via the admin or WP-CLI, then run the footprint queries a third time. The deactivation hook might clean up some data. The uninstall hook might clean up more. Only after both hooks have run should you manually remove what remains. Always take a full database backup before deactivation.

Can I just delete the plugin folder and ignore the database?

You can, but you will leave orphaned rows that continue to affect performance and can cause conflicts later. Autoloaded options still load on every request. Cron events still fire and call missing functions. Custom tables still take up space. If you never install a replacement plugin, the damage is mostly invisible. If you do install a replacement, the orphaned data can cause real conflicts.

How do I find the option prefix for a plugin that is already deleted?

If the plugin folder is gone, you cannot grep the source. Instead, look for option names that contain the plugin’s slug or a likely abbreviation. You can also check your backup files for the plugin’s source code. If you have a full backup from before the deletion, extract the plugin folder from the backup and grep it. If you have no backup, you are guessing. That is why the footprint report should be created before deletion.

For more on diagnosing a WordPress site that returns nothing, see What to Fix First When a New WordPress Site Says Nothing Found. The same diagnostic discipline applies here: check the database before assuming the problem is in the code.

Person reviewing database tables on a laptop screen

Close-up of SQL query results in a terminal window

Team members discussing a cleanup plan around a desk

How to Handle WordPress Site Health Tests That Pass But Mask Real Problems

WordPress Site Health is a diagnostic surface, not a guarantee. A green checkmark means a test ran and matched a threshold. It does not mean the underlying subsystem is healthy under production load, correctly configured for your hosting topology, or safe from silent failure. For small-to-mid publishing teams that maintain their own installs, the dangerous failures are the ones Site Health never flags: object cache backends that report connected but evict constantly, database tables that pass integrity checks while accumulating dead rows, cron jobs that fire but never complete, and block editor requests that return 200 with empty content. This article covers the specific tests that pass while masking real problems, how to reproduce each failure mode, and what to monitor instead.

Site Health sits in the WordPress admin under Tools → Site Health. It runs a set of server, database, and WordPress configuration checks. The checks are useful for baseline triage, but they are static. They do not simulate traffic, measure latency under concurrency, inspect query plans, or validate that scheduled events actually produce output. If you run a production install with real editorial deadlines, you need a second layer of checks that target the gaps.

Why a Passing Site Health Test Can Be a False Positive

Site Health tests are designed to be safe for shared hosting and low-privilege environments. That constraint limits what they can inspect. A test may check that a PHP extension is loaded, not that it is configured correctly. It may check that a database table exists, not that its indexes are being used. It may check that a cron event is scheduled, not that it ran successfully. The result is a set of green indicators that can coexist with real production failures.

For example, the object cache test reports whether a persistent object cache is in use. It does not report the cache hit rate, eviction rate, or whether the cache backend is actually faster than a database query. A misconfigured Redis instance with maxmemory set too low will pass the test while evicting nearly every key. The site will work, but every page load will fall back to the database, and the cache will add latency instead of removing it.

Similarly, the database test checks that tables are present and accessible. It does not check for index fragmentation, dead rows, or missing indexes on high-traffic queries. A table with millions of rows and no index on a frequently filtered column will pass the test while every query that touches it runs a full table scan.

Object Cache: Connected Is Not the Same as Effective

The Site Health object cache test checks whether a persistent object cache drop-in is active. It does not measure whether the cache is doing its job. To see the real state of your object cache, you need to inspect the backend directly.

Reproduce the Failure: Redis Evictions

If you use Redis, connect to the instance and run:

redis-cli INFO stats | grep -E 'evicted_keys|keyspace_hits|keyspace_misses'

If evicted_keys is climbing while keyspace_misses is high relative to keyspace_hits, your cache is thrashing. The fix is usually to raise maxmemory, switch to a more memory-efficient serialization, or reduce the number of large transient keys. Site Health will still show green because the drop-in is present.

For Memcached, the equivalent check is:

echo "stats" | nc 127.0.0.1 11211 | grep -E 'evictions|get_hits|get_misses'

A high eviction count with a low hit ratio means the cache is not reducing database load. You are paying for the cache in memory and latency, but getting none of the benefit.

What to Monitor Instead

Track cache hit rate, eviction rate, and average latency for cache operations. Set alerts when the hit rate drops below 80% for a sustained period or when evictions exceed a threshold per minute. These metrics are not visible in Site Health, but they are the ones that predict slow page loads and database saturation.

Database Tables: Present Is Not the Same as Healthy

The Site Health database test checks that all core tables exist and are accessible. It does not check for dead rows, index bloat, or missing indexes. In a busy publishing install, the wp_posts and wp_postmeta tables are the usual suspects.

Reproduce the Failure: Dead Rows in InnoDB

If you use MySQL or MariaDB with InnoDB, dead rows accumulate after deletes and updates. They are not removed until the table is optimized. To see the current state:

SELECT table_name, data_free FROM information_schema.tables WHERE table_schema = 'your_db_name' ORDER BY data_free DESC LIMIT 10;

If data_free is large relative to the table size, the table has significant dead space. This slows full table scans and increases disk usage. Site Health will not flag it because the table is present and queryable.

To reclaim the space, run OPTIMIZE TABLE wp_postmeta; during a low-traffic window. Be aware that OPTIMIZE TABLE locks the table on some MySQL versions. For large tables, consider pt-online-schema-change from Percona Toolkit if you need to avoid downtime.

Reproduce the Failure: Missing Index on a High-Traffic Query

Use the slow query log to find queries that run frequently and take a long time. Enable it in your MySQL configuration:

slow_query_log = 1
long_query_time = 1
log_queries_not_using_indexes = 1

Then inspect the log for queries against wp_postmeta that filter on meta_key and meta_value without an index. A common offender is a query that looks up posts by a custom field. The default wp_postmeta table has an index on meta_key, but not on meta_value. If your theme or a plugin filters by meta_value, every query scans the entire table.

Add a targeted index only if the query pattern is stable and the table is large enough to justify it. For example:

ALTER TABLE wp_postmeta ADD INDEX meta_key_value (meta_key(191), meta_value(191));

Test the query plan before and after with EXPLAIN. An index that is never used is just write overhead.

Cron: Scheduled Is Not the Same as Executed

WordPress cron is a cooperative scheduler. It runs when someone visits the site. If your site has low traffic or a page cache that serves visitors without loading WordPress, cron events can sit in the queue for hours or days. Site Health checks that the cron system is not disabled, but it does not check that events are actually running on time.

Reproduce the Failure: Stuck Cron Events

List the current cron queue with WP-CLI:

wp cron event list --fields=hook,next_run_relative,status

If you see events with a next_run_relative of now or a past timestamp that never clear, cron is not keeping up. The usual causes are a page cache that bypasses WordPress, a missing system cron job, or a long-running event that blocks the queue.

The fix is to disable WordPress pseudo-cron and run it from the system scheduler:

define('DISABLE_WP_CRON', true);

Then add a system cron entry:

*/5 * * * * wp cron event run --due-now --path=/var/www/your-site --quiet

This guarantees that due events run every five minutes, regardless of traffic. Site Health will still show green either way, because the cron system is technically enabled.

What to Monitor Instead

Track the age of the oldest due cron event. If it exceeds 10 minutes, something is wrong. Also track the number of failed cron runs. A scheduled post that goes out late is a visible editorial failure, and it is almost never caught by Site Health.

Block Editor: HTTP 200 Is Not the Same as Usable

The block editor relies on a series of REST API requests. If a plugin or theme filter breaks the response shape, the editor can load with a blank canvas or missing blocks while the underlying HTTP requests return 200. Site Health does not test the block editor at all.

Reproduce the Failure: Empty Block List

Open the block editor and watch the network tab. Look for requests to /wp-json/wp/v2/block-types and /wp-json/wp/v2/block-patterns/patterns. If the response is 200 but the body is empty or missing expected fields, the editor will appear to load but will not show any blocks.

A common cause is a filter that modifies the REST response for block types. For example, a plugin that removes block types for certain user roles may accidentally remove all of them. To test from the command line:

wp eval 'print_r( WP_Block_Type_Registry::get_instance()->get_all_registered() );'

If the registry is empty, the editor has nothing to render. The fix is to find the filter that is emptying the registry and remove or correct it.

What to Monitor Instead

Add a smoke test that loads the block editor as an editor user and checks that the block list is non-empty. You can do this with a headless browser or a simple script that hits the REST endpoint and validates the JSON shape. This is the kind of check that catches failures before an editor sits down to write.

Site Health Score: A Number Without Context

The Site Health score is a weighted sum of individual test results. It is useful for spotting obvious misconfigurations, but it is not a performance metric. A site can score 100% while serving pages in three seconds and dropping scheduled posts. The score measures compliance with a checklist, not the health of the system under real use.

If you maintain a production install, treat Site Health as a starting point. Run it after major changes, but do not rely on it for ongoing monitoring. The checks that matter are the ones you write yourself, because they target the specific failure modes of your stack.

Building a Second Layer of Checks

The most useful checks are small, specific, and tied to a known failure mode. Here are three that cover the gaps described above.

Check 1: Object Cache Hit Rate

Run this every five minutes and alert if the hit rate drops below 80% for more than 15 minutes:

redis-cli INFO stats | awk -F: '/keyspace_hits/{hits=$2} /keyspace_misses/{misses=$2} END {if (hits+misses > 0) print hits/(hits+misses)*100}'

This is a single metric that tells you whether your cache is actually reducing database load.

Check 2: Oldest Due Cron Event

Run this every five minutes and alert if the oldest due event is older than 10 minutes:

wp cron event list --fields=next_run_relative --format=csv | tail -n +2 | sort | head -n 1

This catches the scheduled-post failure mode before an editor notices that their article did not go live.

Check 3: Block Editor Smoke Test

Run this every hour and alert if the block list is empty:

curl -s -H "Authorization: Bearer YOUR_APP_PASSWORD" https://your-site.com/wp-json/wp/v2/block-types | jq 'length'

If the length is zero, the editor is broken. This is a five-minute check that prevents a full day of editorial downtime.

When Site Health Is Actually Useful

Site Health is good at catching configuration errors that are easy to verify: missing PHP extensions, outdated WordPress core, insecure file permissions, and missing SSL certificates. These are real problems, and the tests for them are reliable. The issue is not that Site Health is useless; it is that it is incomplete. Use it for what it is good at, and build your own checks for everything else.

For a small publishing team, the highest-value checks are the ones that protect the editorial workflow. A scheduled post that goes out late is a visible failure. A block editor that loads blank is a visible failure. A slow page load is a visible failure. Site Health will not catch any of them, but a handful of targeted checks will.

FAQ

Why does my Site Health show green when my site is slow?

Site Health does not measure page load time, database query latency, or cache effectiveness. It checks that required components are present and configured to a minimum standard. A slow site can pass every test if the slowness comes from missing indexes, cache thrashing, or slow external requests.

How do I know if my object cache is actually working?

Check the cache backend directly. For Redis, run redis-cli INFO stats and look at keyspace_hits, keyspace_misses, and evicted_keys. A healthy cache has a high hit ratio and a low eviction rate. If evictions are high, the cache is not reducing database load.

Can I rely on WordPress cron for scheduled posts?

Only if your site has consistent traffic that loads WordPress on every visit. If you use a full-page cache or have low traffic, cron events can be delayed indefinitely. Disable WordPress pseudo-cron and run it from the system scheduler to guarantee that due events run on time.

What is the most common block editor failure that Site Health misses?

The most common failure is an empty block list caused by a filter that removes all registered block types. The editor loads, but no blocks are available. Site Health does not test the block editor, so the failure goes unnoticed until an editor tries to write.

Next Steps for Your Production Install

Start with the three checks above. They cover the failure modes that most often affect publishing teams: cache thrashing, stuck cron, and a broken block editor. Once those are in place, add checks for slow queries and table bloat. The goal is not to replace Site Health, but to fill the gaps it leaves open.

If you are dealing with a site that returns Nothing Found on new posts, the problem is usually a rewrite or query issue, not a Site Health failure. See What to Fix First When a New WordPress Site Says Nothing Found for a step-by-step diagnosis.

For ongoing monitoring, keep a log of every check that fires. The pattern of failures over time tells you more than any single alert. A cache that thrashes once a week is a different problem than one that thrashes every afternoon. The log is your evidence base for deciding what to fix next.

Server rack with network cables in a data center
Close-up of a database server hard drive activity light
Person typing on a laptop while reviewing code on a monitor

The Specific Problem With add_rewrite_rule() That flush_rewrite_rules() Doesn’t Fix

If you run a WordPress production install, you’ve probably registered a custom URL pattern with add_rewrite_rule(), called flush_rewrite_rules(), and expected the route to just work. Here’s the thing: flush_rewrite_rules() only rebuilds the rewrite_rules option from whatever rules are currently registered. It won’t fix a rule that points at the wrong query variable, a rule that collides with an existing internal rewrite, or a regex that never matches because it was built on a wrong assumption about how WordPress parses requests. For small-to-mid publishing teams, this stings because a broken rewrite rule usually looks like a caching issue, a permalink problem, or a theme conflict. The real failure is sitting in the wp_options table, inside the rewrite_rules array, or in the WP_Rewrite object before the request ever reaches the template loader.

This article is about the failure mode that survives a rewrite flush. It’s not a beginner’s guide to pretty permalinks. I’m assuming you already know WordPress stores compiled rewrite rules in the rewrite_rules option and that flush_rewrite_rules() deletes and rebuilds that option. The problem is narrower: a rule can be sitting in the database, visible in wp rewrite list, and still never fire because it was registered in a way that can’t match the request or can’t produce a valid query.

Server rack with network cables in a data center
Production rewrite failures are often misdiagnosed as server or cache issues.

What flush_rewrite_rules() Actually Does

flush_rewrite_rules() calls WP_Rewrite::flush_rules(). That method deletes the rewrite_rules option and then calls WP_Rewrite::wp_rewrite_rules() to rebuild the array from the rules that are currently registered. The key phrase is “currently registered.” If your rule was registered on init with a bad regex, a bad redirect, or a bad query string, the flush will happily write that bad rule back into the database. The flush doesn’t validate anything. It doesn’t test the rule against a sample URL. It doesn’t warn you that the rule will never match.

You can confirm this with a minimal plugin or mu-plugin:

add_action( 'init', function () {
    add_rewrite_rule( '^bad-route/([^/]+)/?$', 'index.php?bad_var=$matches[1]', 'top' );
} );
flush_rewrite_rules();

After running that code, the rule exists in the database. wp rewrite list shows it. But the rule won’t produce a valid query unless bad_var is a public query variable. If it’s not, WordPress won’t populate $wp_query->query_vars['bad_var'], and the request will fall through to a 404 or to a different rule. The flush did its job. The registration didn’t.

The Core Failure: A Rule That Cannot Resolve to a Query

WordPress rewrite rules are not standalone routes. They’re regex-to-query-string translations. A rule only works if the query string on the right side maps to a query variable that WordPress recognizes. Public query variables come from WP_Query, from registered post types and taxonomies, and from the query_vars filter. If you write a rule that points to index.php?custom_slug=$matches[1] but never register custom_slug as a public query var, the rule is dead on arrival.

This is the most common version of the problem in production. A developer adds a rule, flushes, tests one URL, sees a 404, and then starts disabling plugins or clearing caches. The actual fix is usually one of two lines:

add_filter( 'query_vars', function ( $vars ) {
    $vars[] = 'custom_slug';
    return $vars;
} );

Or, if the rule is meant to map to an existing post type or taxonomy, the query string should use the correct key, such as post_type, name, p, page_id, category_name, or a custom taxonomy query var.

You can inspect the current public query vars with WP-CLI:

wp eval 'var_dump( $wp_query->public_query_vars );'

If your custom key isn’t in that list, no amount of flushing will make the rule work.

Conflicting Rules and the ‘top’ vs ‘bottom’ Problem

Another failure that survives a flush is a rule that’s registered correctly but never reached because an earlier rule matches the same URL pattern. WordPress compiles rewrite rules into a large associative array. The order of that array isn’t the order in which you called add_rewrite_rule(). It’s determined by the internal rule groups: post rules, page rules, date rules, comment rules, search rules, author rules, and so on. Your custom rule is appended to the extra_rules_top or extra_rules group depending on the third argument.

If you pass 'top', the rule is added to extra_rules_top, which is placed before most internal rules. If you pass 'bottom', it’s added to extra_rules, which is placed after many internal rules. The problem is that “top” doesn’t mean “first.” It means “before the default internal groups.” A page rule or a post rule can still match first in some configurations, especially if your regex is too broad.

Consider this rule:

add_rewrite_rule( '^([^/]+)/?$', 'index.php?custom_page=$matches[1]', 'top' );

That regex matches every single-segment URL. It will intercept /about/, /contact/, and /2024/. If you have pages with those slugs, the page rewrite may still win because page rules are compiled into a specific position. The result is unpredictable unless you inspect the final array.

Use WP-CLI to see the actual order:

wp rewrite list --format=table

Look for your rule in the output. If it appears after a rule that matches the same URL, your rule will never fire. The fix is to make the regex more specific, change the rule group, or use a different mechanism such as a custom endpoint or a custom post type rewrite slug.

Person working on a laptop with code on the screen
Inspecting the compiled rewrite array is faster than guessing why a route 404s.

Regex Assumptions That Break After the Flush

WordPress rewrite rules are regular expressions. A rule that works in a local test can fail in production because the URL structure is different. The most common assumption is that the request path always starts with the site’s home path. On a subdirectory install, the request path passed to the rewrite engine is relative to the WordPress directory, not the domain root. A rule written for a root install won’t match on a subdirectory install unless the regex accounts for the base.

Another assumption is that query strings are not part of the rewrite match. They’re not. The rewrite engine matches against the path only. If your rule expects a query string parameter to be part of the match, it will never fire. The query string is parsed separately and is available in $_GET and in the query vars after the rewrite match.

A third assumption is that the regex delimiter and escaping are correct. WordPress uses # as the delimiter for some internal rules, but add_rewrite_rule() expects a regex without delimiters. If you include delimiters, the rule will be stored with them and won’t match. This is a silent failure: the rule is in the database, the flush succeeded, and the URL still 404s.

Test your regex outside WordPress first. Use preg_match() in a standalone PHP file or in wp shell:

wp shell
$pattern = '^bad-route/([^/]+)/?$';
$subject = 'bad-route/hello';
var_dump( preg_match( '#' . $pattern . '#', $subject, $matches ) );

If the test fails, the rule will fail in WordPress. The flush isn’t the problem.

When the Rule Is Correct but the Query Is Not

There’s a subtler failure mode. The rule matches, the query var is public, and the request still doesn’t load the expected content. This happens when the query string on the right side of the rule doesn’t produce a complete WP_Query. For example, a rule that maps to index.php?post_type=event&event_slug=$matches[1] will match, but WP_Query won’t know that event_slug is the slug for the event post type unless the post type is registered with 'query_var' => 'event_slug' and the rewrite slug is set correctly.

The same problem occurs with custom taxonomies. A rule that maps to index.php?event_type=$matches[1] won’t load a taxonomy archive unless event_type is the query var for a registered taxonomy. The rule is present, the flush is clean, and the request still falls through to the main query with no results.

You can debug this by hooking into request and logging the parsed query vars:

add_filter( 'request', function ( $query_vars ) {
    error_log( print_r( $query_vars, true ) );
    return $query_vars;
} );

Then request the URL and check the debug log. If your custom query var is missing or empty, the rule matched but the query didn’t resolve. The fix is in the post type or taxonomy registration, not in the rewrite rule.

Flush Timing and the Init Hook

Another specific problem is that flush_rewrite_rules() is often called at the wrong time. If you call it before your rules are registered, the flush writes an empty or incomplete rule set. If you call it on every init, you’re rebuilding the option on every request, which is a performance problem and can cause race conditions on high-traffic sites.

The correct pattern is to register rules on init and flush once, usually on plugin activation or theme switch. For a production install maintained by a small team, the safest approach is to use WP-CLI after deploying the rule change:

wp rewrite flush

That command regenerates the rules from the current codebase. It doesn’t fix a bad rule, but it does ensure that the flush happens after all rules are registered. If you’re debugging a rule that survives a flush, run the WP-CLI command and then immediately inspect the rule list. If the rule is present but the URL still fails, the problem is in the rule definition, not the flush.

Inspecting the Stored Rules Directly

The rewrite_rules option is a serialized array in wp_options. You can inspect it with SQL:

SELECT option_value FROM wp_options WHERE option_name = 'rewrite_rules';

The value is serialized. You can unserialize it with WP-CLI:

wp eval '$rules = get_option( "rewrite_rules" ); foreach ( $rules as $regex => $query ) { if ( false !== strpos( $regex, "bad-route" ) ) { echo $regex . " => " . $query . PHP_EOL; } }'

This shows you exactly what WordPress will match against. If the regex isn’t what you intended, the rule is broken. If the query string points to an unregistered query var, the rule is broken. If the rule is missing entirely, the flush didn’t run or the rule wasn’t registered at flush time.

Close-up of database code on a monitor
The rewrite_rules option is the source of truth for URL matching.

A Reproducible Failure Case

Here’s a complete failure case that you can reproduce on a clean WordPress install. It shows the exact problem: a rule that’s present after a flush but never fires.

add_action( 'init', function () {
    add_rewrite_rule( '^team/([^/]+)/?$', 'index.php?team_member=$matches[1]', 'top' );
} );

add_action( 'init', function () {
    flush_rewrite_rules();
}, 20 );

After loading the site once, run:

wp rewrite list --format=table | grep team

The rule appears. Now request /team/alice/. The result is a 404 or a fallback to the main query. The reason is that team_member isn’t a public query var. The rule matched, but WordPress couldn’t populate the query var, so the main query had no way to load a team member.

The fix is to register the query var:

add_filter( 'query_vars', function ( $vars ) {
    $vars[] = 'team_member';
    return $vars;
} );

Then flush again. The rule now works. The flush was never the problem. The rule was incomplete.

Why This Matters for Publishing Teams

Small-to-mid publishing teams often maintain their own production installs. They don’t have a dedicated platform team to debug rewrite rules. When a custom URL stops working after a deploy, the first instinct is to flush permalinks, clear the cache, or restore a backup. Those actions don’t fix a rule that was registered incorrectly. The result is lost time and a lingering fear that WordPress routing is fragile.

The durable fix is to treat rewrite rules as code, not configuration. Register them in version control. Test the regex before deploying. Verify that every query var on the right side of the rule is public. Inspect the compiled rule array after every flush. If a rule is present but not firing, the problem is in the rule definition, not in the flush.

For a deeper look at what to check when a new WordPress site returns nothing found, see What to Fix First When a New WordPress Site Says Nothing Found. That article covers the broader 404 diagnosis path, including permalink structure and server configuration, which are often confused with rewrite rule failures.

FAQ

Why does my rewrite rule show in wp rewrite list but still 404?

The rule is stored in the rewrite_rules option, but it may not match the request path, or it may match and produce a query string that WordPress cannot resolve. Check the regex against the actual request path and verify that every query var on the right side is public.

Does flush_rewrite_rules() fix a broken add_rewrite_rule() call?

No. The flush only regenerates the stored rules from the currently registered rules. If the registered rule has a bad regex, a bad query string, or an unregistered query var, the flush writes the same broken rule back into the database.

How can I tell if a query var is public in WordPress?

Run wp eval 'var_dump( $wp_query->public_query_vars );' or inspect the query_vars filter output. If your custom key isn’t in the list, WordPress won’t populate it from a rewrite rule.

What is the difference between ‘top’ and ‘bottom’ in add_rewrite_rule()?

The third argument controls whether the rule is added to extra_rules_top or extra_rules. “Top” places the rule before most internal rules, but it doesn’t guarantee first match. A broad regex can still be intercepted by a more specific internal rule. Inspect the final rule order with wp rewrite list.

Should I call flush_rewrite_rules() on every init?

No. That rebuilds the rewrite_rules option on every request, which is wasteful and can cause race conditions. Register rules on init and flush once on activation, theme switch, or after a deploy using wp rewrite flush.

How to Audit wp_users and wp_usermeta for Orphaned Capability Assignments

An orphaned capability assignment is a role or capability record in wp_usermeta that no longer maps to a defined role in the active WordPress installation. The most common form is a stale wp_capabilities entry, but the same failure appears in wp_user_level rows, leftover wp_sitemeta keys on multisite, and serialized arrays that reference roles removed by a plugin or theme. For a small-to-mid publishing team, these orphans are not cosmetic. They change who can edit, approve, export, or delete content, and they survive plugin deactivations, role editor experiments, and manual database imports. This article covers the exact tables, the failure modes, the SQL and WP-CLI checks, and the cleanup sequence that does not break active editorial users.

Adjacent concepts here are user role resolution, capability meta keys, serialized option data, multisite user metadata, and the WP_User::has_cap() chain. The audience is a systems engineer or technical editor who already knows that wp_users stores identity and wp_usermeta stores per-user key-value data. The problem is not missing knowledge about the schema. The problem is that most audits stop at the wp_users table and never inspect the serialized capability arrays that actually control access.

Database server racks in a data center, representing WordPress user meta storage

Why Orphaned Capability Assignments Persist

WordPress does not garbage-collect user meta when a role disappears. If a plugin registers a custom role, assigns it to three editors, and is then deactivated, the wp_capabilities meta value for those three users still contains the custom role key. WordPress will ignore the unknown role during normal capability checks, but the stale key remains in the database. The same happens when a site owner uses a role editor to rename a role, when a theme registers a temporary role during setup, or when a staging database is merged into production with a different plugin set.

The second persistence vector is serialization. A wp_capabilities value looks like this:

a:1:{s:13:"administrator";b:1;}

If a role key is removed from the active role list but the serialized array is not rewritten, the orphan survives as a string inside the meta value. A simple LIKE search for the role name will find it, but a naive REPLACE or UPDATE can corrupt the serialized length markers and make the entire meta value unreadable. That is the real failure mode: a well-intentioned cleanup that turns a stale role into a broken user object.

Where the Orphans Live

Start with the two tables named in the title. wp_users holds ID, user_login, user_email, user_status, and display_name. It does not hold roles. wp_usermeta holds umeta_id, user_id, meta_key, and meta_value. The capability-related keys are:

  • wp_capabilities — serialized array of role or capability names mapped to boolean values.
  • wp_user_level — legacy numeric level, still written by some plugins and imports.
  • wp_dashboard_quick_press_last_post_id — not capability-related, but often confused with user state during audits.
  • wp_sitemeta on multisite — site-level meta that can hold role definitions for the network.

On a standard single-site install, the audit target is wp_usermeta where meta_key = 'wp_capabilities'. On multisite, each site has its own wp_usermeta table, and the network user record is shared. A user can have different roles on different sites, which means an orphan on site 2 may not be an orphan on site 3. The audit must be scoped per site table, not per user ID.

Step 1: Enumerate the Active Roles

Before touching the database, get the authoritative list of roles from the current codebase. The cleanest source is the wp_user_roles option in the wp_options table. Run this query:

SELECT option_value FROM wp_options WHERE option_name = 'wp_user_roles';

The value is a serialized array of role slugs mapped to role definitions. Each role definition contains a name and a capabilities array. The role slugs are the keys you will compare against the wp_capabilities meta values. If you prefer WP-CLI, the equivalent is:

wp role list --fields=role,name --format=csv

This gives you the active role slugs without manually unserializing the option. The default roles are administrator, editor, author, contributor, and subscriber. Any role slug not in this list is a candidate orphan.

One caution: some plugins register roles conditionally. A role may exist only when a specific plugin is active, or only on certain pages, or only after a license check. If you audit a staging copy with the plugin disabled, you will see false positives. Run the audit on the production database, or on a staging copy that has the exact same plugin set active.

Code on a monitor showing a database query for WordPress user roles

Step 2: Extract and Inspect the wp_capabilities Meta

Pull all capability meta rows into a readable form. The raw query is:

SELECT user_id, meta_value FROM wp_usermeta WHERE meta_key = 'wp_capabilities';

Each meta_value is a serialized PHP array. You can unserialize it in a PHP script, or use a tool that understands PHP serialization. Do not attempt to parse it with a regular expression. The serialized format includes string length markers, and a role slug like editor is six characters, while administrator is thirteen. A regex that matches role names will also match substrings inside other serialized values.

For a quick manual check, copy the meta_value into a PHP one-liner:

php -r '$v = '''a:1:{s:13:"administrator";b:1;}'''; var_export(unserialize($v));'

For a larger audit, write a small script that loops through the rows, unserializes each value, and compares the array keys against the active role list. The output should be a table of user_id, user_login, orphaned_role, and full_meta_value. That table is your cleanup queue.

Step 3: Identify the Orphan Types

Not every unknown key in wp_capabilities is an orphaned role. There are three distinct types:

Type 1: Removed Role Slug

The meta value contains a role slug that is not in wp_user_roles. Example: a:1:{s:16:"content_approver";b:1;} where content_approver was registered by a now-deactivated editorial workflow plugin. This is the classic orphan. The user may still have other valid roles, or the orphan may be the only role, leaving the user with no capabilities at all.

Type 2: Capability Key Without a Role

The meta value contains a capability name, not a role slug. Example: a:1:{s:13:"edit_others_posts";b:1;}. This is not a role assignment. It is a direct capability grant, which WordPress supports but rarely uses in normal operation. If the capability is no longer registered by any plugin, it is an orphaned capability. If it is still registered, it is a valid direct grant and should not be removed without understanding why it was added.

Type 3: Serialized Array with a Broken Length Marker

The meta value fails to unserialize. This is not an orphan in the strict sense, but it is a corrupted capability record. The user may be locked out of the admin, or WordPress may fall back to a default role. This type requires repair, not cleanup. The fix is to reconstruct the array from a known-good backup or to reset the user to a valid role.

Step 4: Check wp_user_level and Other Legacy Keys

wp_user_level is a numeric value from the pre-2.0 role system. It still appears in imports from old WordPress sites, in some membership plugins, and in hand-written SQL migrations. The value is not used by modern WordPress for capability checks, but it can confuse audits and some plugins still read it. If the wp_user_level value is higher than the user’s current role would allow, it is a stale assignment. Example: a user with the subscriber role and a wp_user_level of 10. The fix is to set wp_user_level to 0 or delete the meta row entirely.

Also check for plugin-specific capability keys. Some editorial workflow plugins store approval state in wp_usermeta under keys like approval_status, edit_lock, or workflow_state. These are not capability assignments, but they can block content if the plugin is deactivated and the meta remains. A full audit should list every meta_key that contains the substring cap, role, level, or approve, and then classify each one.

Step 5: Clean Up Without Corrupting Serialized Data

The safe cleanup sequence is:

  1. Back up the wp_usermeta table. A full database backup is better, but a table-level export is the minimum.
  2. For each orphaned role slug, remove only that key from the serialized array. Do not rewrite the entire array by hand.
  3. If the array becomes empty after removal, delete the wp_capabilities meta row. WordPress will assign the default role on the next user load.
  4. If the array still contains at least one valid role, write the updated serialized array back to the database.
  5. For wp_user_level, delete the meta row or set it to 0.

The PHP code for step 2 is straightforward:

$meta = get_user_meta( $user_id, 'wp_capabilities', true );
if ( is_array( $meta ) && isset( $meta['content_approver'] ) ) {
    unset( $meta['content_approver'] );
    if ( empty( $meta ) ) {
        delete_user_meta( $user_id, 'wp_capabilities' );
    } else {
        update_user_meta( $user_id, 'wp_capabilities', $meta );
    }
}

Run this as a WP-CLI command or a small plugin file, not as a raw SQL UPDATE. The WordPress meta functions handle serialization correctly. A raw SQL REPLACE on a serialized string is the fastest way to break every user on the site.

If you must use SQL, the only safe approach is to select the row, unserialize it in a script, modify the array, serialize it again, and then run an UPDATE with the full new value. Never use REPLACE(meta_value, 'old', 'new') on serialized data.

Person working on a laptop with code, cleaning up WordPress user meta data

Step 6: Verify the Cleanup

After cleanup, run the audit query again and confirm that no orphaned role slugs remain. Then test the affected users. Log in as each user, or use WP-CLI to check capabilities:

wp user get 42 --fields=ID,user_login,roles

If a user had only the orphaned role, the roles field should now show the default role, usually subscriber. If the user had a valid role plus the orphan, the valid role should remain unchanged. Check the admin screens for the affected users: the Users list, the post editing screen, and any editorial workflow screens that depend on role checks.

One more verification: run a site-wide capability check for a few known actions. For example, confirm that an editor can still edit others’ posts, that an author can still publish, and that a subscriber cannot access the dashboard. The orphan cleanup should not change any of these outcomes. If it does, you removed a valid role or capability by mistake.

Preventing Future Orphans

The root cause is usually a plugin or theme that registers a role and then leaves it behind. Before deactivating any plugin that registers roles, export the current role list and the affected user meta. After deactivation, run the audit. If the plugin is part of a publishing workflow, document the role names and the users assigned to them. That documentation is the difference between a five-minute cleanup and a two-hour incident.

For teams that use staging and production databases, add a role audit to the deployment checklist. A staging merge can bring over wp_usermeta rows that reference roles from the staging plugin set. The audit query is cheap, and the cleanup script can be run as a WP-CLI command after every merge.

If the site uses a role editor plugin, treat every role rename as a data migration. Renaming a role in the plugin UI does not automatically update the wp_capabilities meta for existing users. The old role slug becomes an orphan, and the users lose the capabilities they had. The fix is to run a script that maps the old slug to the new slug and updates the meta before the rename is finalized.

What This Audit Does Not Cover

This audit covers wp_users and wp_usermeta only. It does not cover wp_options role definitions, wp_sitemeta network roles, or capability checks in custom code. It also does not cover user sessions, authentication cookies, or password resets. Those are separate failure modes with separate fixes. If a user is locked out after a cleanup, the cause is usually a broken serialized array, not a missing role. The repair path is to restore the wp_capabilities meta from backup and re-run the cleanup with the correct script.

For a related failure mode, see What to Fix First When a New WordPress Site Says Nothing Found. That article covers the case where a site appears empty after a migration or plugin change, which often shares the same root cause: stale or corrupted meta data that WordPress cannot interpret.

FAQ

How do I know if a role is orphaned or just inactive?

An orphaned role is a role slug that exists in a user’s wp_capabilities meta but not in the wp_user_roles option. An inactive role is a role that exists in wp_user_roles but is not assigned to any user. The audit in this article targets the first case. The second case is a separate cleanup task: removing unused role definitions from the wp_user_roles option.

Can I clean orphaned capabilities with a SQL query?

You can identify orphans with SQL, but you should not modify serialized meta values with SQL string functions. The safe path is to select the rows, unserialize them in PHP, remove the orphaned keys, and write the updated arrays back using update_user_meta(). A raw SQL REPLACE or UPDATE on a serialized string can corrupt the length markers and break the user’s capabilities entirely.

What happens if a user has only an orphaned role and no valid role?

WordPress will treat the user as having no capabilities. On the next user load, WordPress may assign the default role, usually subscriber, but this behavior depends on the code path. The user may be locked out of the admin until the wp_capabilities meta is repaired. The cleanup script in this article deletes the empty meta row, which triggers the default role assignment on the next load.

How often should a publishing team run this audit?

Run it after every plugin deactivation, every role editor change, every staging-to-production merge, and every major WordPress core update. For a small-to-mid publishing team with a stable plugin set, a monthly audit is enough. For a team that experiments with editorial workflow plugins, run it weekly or after every plugin change.

How to Handle Database Character Set Mismatches During Site Migrations

Database character set mismatches are one of the quietest ways a WordPress migration goes wrong. The site moves from one host to another. The front end loads. The admin dashboard works. Then somebody opens an old post and sees “ where a curly quote should be, or é instead of é. The migration didn’t fail. The bytes moved. What changed was how those bytes got read. This article walks through the exact mechanics of that failure, how to catch it before it becomes a support ticket, and how to fix it without rebuilding the site.

For small-to-mid publishing teams, this matters because the editorial archive is the asset. A character set mismatch doesn’t just break display. Run the wrong conversion and it can corrupt stored data. The fix isn’t a plugin setting. It’s a sequence of checks against wp-config.php, the MySQL connection, the table collations, and the dump file itself.

Database server racks in a data center

What a Character Set Mismatch Actually Is

WordPress stores text in MySQL tables. MySQL gives each table a character set and a collation. The character set defines which bytes map to which characters. The collation defines how those characters sort and compare. WordPress has used utf8mb4 as its default since version 4.2, but plenty of older sites still run utf8, and plenty of hosts still create databases with utf8 or even latin1 as the default.

The mismatch shows up when one layer says utf8mb4 and another layer says latin1. The bytes don’t change. The label changes. MySQL then interprets the same byte sequence under a different encoding, and the output becomes mojibake: “, é, —, and similar garbage.

There are three common places where the label gets lost or changed:

  • The mysqldump export file, which may not include SET NAMES utf8mb4 or may include a conflicting SET NAMES latin1.
  • The wp-config.php file, where DB_CHARSET may be set to utf8 while the tables are utf8mb4, or the other way around.
  • The target database server, where the default character set for new tables may differ from the source server.

Detecting the Mismatch Before It Spreads

Don’t start a migration by importing the dump and hoping. Check the source first. Run this query against the source database:

SELECT TABLE_NAME, TABLE_COLLATION
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = 'your_database_name'
AND TABLE_NAME LIKE '%_posts';

If the TABLE_COLLATION for wp_posts is utf8mb4_unicode_ci or utf8mb4_unicode_520_ci, the source is modern. If it’s utf8_general_ci or latin1_swedish_ci, the source is legacy. That single value tells you what to expect in the dump.

Next, check the dump file itself. Open the first 50 lines of the .sql file in a plain text editor. Look for lines like:

/*!40101 SET NAMES utf8mb4 */;

or

/*!40101 SET NAMES latin1 */;

If the dump was created with mysqldump without the --default-character-set=utf8mb4 flag, the SET NAMES line may be missing or wrong. That’s the first failure point.

Finally, check wp-config.php on the target site. Look for these two lines:

define('DB_CHARSET', 'utf8mb4');
define('DB_COLLATE', '');

If DB_CHARSET is utf8 but the tables are utf8mb4, WordPress will tell MySQL to use utf8 for the connection. That alone can produce mojibake on display even when the stored data is fine.

Code editor showing SQL query on a monitor

The Exact Fix Sequence

There’s a correct order of operations. Skipping a step or doing them out of order can make the corruption permanent. Follow this sequence.

1. Export with an Explicit Character Set

Never rely on the host’s default mysqldump settings. Always pass the character set explicitly:

mysqldump --default-character-set=utf8mb4 \
  --single-transaction \
  --quick \
  --no-tablespaces \
  -u username -p database_name > site_backup.sql

The --default-character-set=utf8mb4 flag forces the dump to label the bytes correctly. The --single-transaction flag prevents table locks on InnoDB tables during the export. The --no-tablespaces flag avoids a common permission error on shared hosts.

If the source tables are latin1 but actually contain utf8 bytes — a common legacy situation — don’t use --default-character-set=latin1. That will double-encode the data. Instead, export with --default-character-set=latin1 only if you’re certain the stored bytes are genuinely latin1. For most WordPress sites, the stored bytes are utf8 even when the table label says latin1. In that case, export with utf8mb4 and fix the table labels after import.

2. Inspect the Dump Header

After the export, open the .sql file and confirm the SET NAMES line matches utf8mb4. If it doesn’t, don’t import. Re-run the export with the correct flag. Importing a mislabeled dump is how you turn a display problem into a storage problem.

3. Create the Target Database with the Right Defaults

On the target server, create the database with an explicit character set and collation:

CREATE DATABASE new_site_db
  CHARACTER SET utf8mb4
  COLLATE utf8mb4_unicode_ci;

This ensures that any table created during the import without an explicit character set inherits utf8mb4. If the target host doesn’t allow CREATE DATABASE through a control panel, use the panel’s database creation form and select utf8mb4 if available. If the panel only offers utf8, create the database anyway and fix the tables after import.

4. Import with the Same Explicit Character Set

Import the dump using the same character set flag:

mysql --default-character-set=utf8mb4 \
  -u username -p new_site_db < site_backup.sql

This tells the MySQL client to interpret the dump file as utf8mb4. If the dump header says utf8mb4 and the client says utf8mb4, the bytes land in the target tables unchanged.

5. Verify Table Collations After Import

After the import completes, run the same information_schema query against the target database. Compare the TABLE_COLLATION values with the source. If the source was utf8mb4_unicode_ci and the target is utf8_general_ci, the import didn't preserve the collation. That's a mismatch, but it's usually cosmetic. The fix is a single ALTER TABLE statement per table, or a loop over all tables.

If the target tables are latin1 while the source was utf8mb4, the import failed to apply the character set. Don't run ALTER TABLE ... CONVERT TO CHARACTER SET utf8mb4 yet. First check whether the data is already mojibake. If the front end shows clean text, the bytes are fine and only the label is wrong. In that case, use ALTER TABLE ... DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci without CONVERT TO. That changes the label without touching the bytes.

6. Fix the Connection Character Set in wp-config.php

Set DB_CHARSET to utf8mb4 and leave DB_COLLATE empty:

define('DB_CHARSET', 'utf8mb4');
define('DB_COLLATE', '');

An empty DB_COLLATE lets MySQL use the table's own collation for comparisons. Setting a specific collation here can cause unexpected sort order changes on migrated sites.

7. Test with Known Problem Characters

Create a test post or edit an existing one. Type or paste these characters into the post body and title:

  • Curly quotes: “ ” ‘ ’
  • Em dash: —
  • Accented letters: é ü ñ
  • Non-Latin script: 日本語
  • Emoji: 😀

Save the post, reload it, and check the front end. If any of these render as ?, “, or empty boxes, the connection or table character set is still wrong. Don't proceed with content edits until this test passes.

When the Data Is Already Corrupted

If the migration was already completed and the stored data now shows mojibake, the fix is different. You're no longer preventing corruption. You're reversing it.

The most common case is a latin1 table that contains utf8 bytes. The bytes are correct, but MySQL interprets them as latin1. The fix is to change the table's character set to utf8mb4 without converting the bytes. In MySQL, that's:

ALTER TABLE wp_posts
  MODIFY post_content LONGTEXT
  CHARACTER SET utf8mb4;

This tells MySQL to reinterpret the existing bytes as utf8mb4. It doesn't re-encode them. If the bytes were already valid utf8, the text becomes readable immediately.

The dangerous case is when someone already ran CONVERT TO CHARACTER SET utf8mb4 on a latin1 table that contained utf8 bytes. That double-encodes the data. The original bytes are gone. The only reliable fix is to restore from a pre-conversion backup and redo the migration correctly. If no backup exists, the data is permanently damaged. This is why the order of operations matters.

Why This Happens on Shared Hosts

Shared hosting control panels often create databases with latin1 or utf8 as the default, regardless of what the application expects. The panel's migration tool may also export with a hardcoded character set. When you combine a panel-created database with a panel-generated dump, the labels can disagree at three different layers: the dump header, the client connection, and the table definition.

This isn't a WordPress bug. WordPress sets the connection character set based on DB_CHARSET in wp-config.php. The problem is that the surrounding infrastructure doesn't always honor that setting. The fix is to stop trusting the infrastructure and start checking the actual bytes and labels at each layer.

Network cables connected to a server switch

Preventing the Next Migration Failure

Add a pre-migration checklist to your team's runbook. The checklist should include:

  • Run the information_schema query on the source and record the collation for every table.
  • Export with --default-character-set=utf8mb4 and verify the SET NAMES line in the dump.
  • Create the target database with utf8mb4 defaults.
  • Import with the same explicit character set.
  • Compare source and target table collations after import.
  • Test with curly quotes, em dashes, accented letters, and emoji before publishing any new content.

If your team uses a migration plugin, the same checks still apply. Plugins can hide the export and import steps, but they can't fix a mislabeled dump. After any plugin-based migration, run the collation query and the character test. If either fails, export and import manually using the sequence above.

What This Means for Editorial Workflows

For a publishing team, a character set mismatch isn't just a technical annoyance. It can silently corrupt archived content. An editor opens a post from 2016, sees mojibake, and assumes the content was always broken. The archive loses trust. The fix isn't to re-type the content. The fix is to restore the correct byte interpretation.

This is also why migration testing should include a content audit, not just a front-end smoke test. Open the oldest post, the longest post, and a post with non-Latin characters. Check the post title, the excerpt, and the content. If any of them show mojibake, stop the migration and fix the character set before proceeding.

If you're dealing with a site that shows no content at all after migration, the problem may be different. See What to Fix First When a New WordPress Site Says Nothing Found for the separate failure mode of empty archives and missing rewrite rules.

FAQ

Why do I see “ instead of curly quotes after a migration?

That's the classic signature of utf8 bytes being interpreted as latin1. The curly quote is stored as three bytes in utf8. When MySQL reads those bytes as latin1, each byte becomes a separate character, producing “. The fix is to change the table or connection character set to utf8mb4 without converting the bytes.

Should I use utf8 or utf8mb4 for WordPress?

Use utf8mb4. MySQL's utf8 is a three-byte subset that can't store emoji or some rare characters. WordPress has defaulted to utf8mb4 since version 4.2. If your tables are still utf8, migrate them to utf8mb4 as part of the next site migration.

Can I fix a character set mismatch with a plugin?

No. The mismatch lives in the database layer, not the application layer. A plugin can change how WordPress queries the database, but it can't change how MySQL interprets the stored bytes. The fix requires SQL statements against the tables or a correct re-import of the dump.

What is the difference between changing the default character set and converting the data?

Changing the default character set updates the table's label for future inserts. It doesn't touch existing bytes. Converting the data re-encodes every existing byte sequence from one character set to another. If the existing bytes are already utf8 but labeled latin1, converting will double-encode them and permanently corrupt the text. Change the label first. Convert only when you're certain the stored bytes are genuinely in the old character set.

Next Step for This Site

This article is part of a series on migration failure modes. The next article in the series covers serialized data corruption in wp_options during search-and-replace operations — a related failure that often appears alongside character set mismatches when teams use naive SQL find-and-replace on serialized arrays. If you have a migration story where the character set was fine but widgets and theme options broke, that's the article to read.

Why Your Custom Block’s save() Function Desyncs From Its edit() Render

In WordPress block development, the save() function defines the static HTML markup stored in post_content, while the edit() function controls the live React-rendered experience inside the block editor. A desync occurs when the markup produced by save() no longer matches the markup the editor expects from edit(). The result is the familiar “This block contains unexpected or invalid content” error, a broken block preview, or silent data loss on re-open. For small-to-mid publishing teams running custom editorial workflows, this is not a cosmetic annoyance. It is a content integrity problem that compounds across revisions, scheduled posts, and multi-author environments.

This article walks through the exact failure modes, the underlying serialization contract, and the specific fixes that prevent desyncs before they reach production. It assumes you already understand block registration basics and have shipped at least one custom block that later broke.

Developer debugging WordPress block code on a laptop screen

The Serialization Contract Between edit() and save()

WordPress stores block content as HTML comments with JSON attributes, followed by the saved markup. The block parser reads that markup back into the editor by comparing it against the output of save(). If the parser cannot reconcile the stored markup with the current save() output, the block enters recovery mode or shows a validation error.

The contract is strict: save() must return a deterministic, static HTML string. The editor then uses that string as the source of truth for block validation. Any difference between the saved markup and the expected markup triggers a desync. This includes whitespace, attribute order, class name changes, and nested component output.

Where Teams Usually Break the Contract

Most desyncs come from four specific mistakes:

  • Using dynamic values in save() that depend on runtime state, such as Date.now(), random IDs, or user-specific data.
  • Returning different markup based on editor-only conditions, like isSelected or hasSelectedInnerBlock.
  • Changing the save() output after blocks have already been saved in existing posts.
  • Using RichText or InnerBlocks incorrectly, so the saved markup does not match the editor’s internal representation.

Each of these has a specific fix, but the underlying principle is the same: save() is a pure function of block attributes. Nothing else.

Failure Mode 1: Dynamic Values in save()

Consider a block that generates a unique ID for a wrapper element. A developer might write:

save({ attributes }) {
  const id = `accordion-${Math.random().toString(36).substr(2, 9)}`;
  return 
{attributes.content}
; }

This breaks immediately. The saved markup contains one ID, but the next time the editor loads the block, save() generates a different ID. The parser sees a mismatch and flags the block as invalid.

The fix is to move any dynamic value into the edit() function only, or to store the generated value as an attribute. If the ID must be stable, generate it once during block creation and save it as an attribute:

edit({ attributes, setAttributes }) {
  if (!attributes.anchorId) {
    setAttributes({ anchorId: `accordion-${Math.random().toString(36).substr(2, 9)}` });
  }
  return 
{attributes.content}
; }, save({ attributes }) { return
{attributes.content}
; }

This keeps save() deterministic and moves the non-deterministic logic into the editor, where it belongs.

Failure Mode 2: Editor-Only Conditions in save()

Another common mistake is using editor state inside save(). Developers sometimes copy the edit() JSX into save() and forget to remove editor-only props like isSelected, className from useBlockProps, or onChange handlers.

For example:

save({ attributes, isSelected }) {
  return (
    
{attributes.content}
); }

This saves different markup depending on whether the block was selected when the post was saved. The next load will not match, and the block will break.

The fix is to strip all editor-only logic from save(). If you need a class name that reflects block state, store that state as an attribute and use it in both functions. If you need a class name only for editor styling, apply it in edit() using useBlockProps and leave save() clean.

Failure Mode 3: Changing save() After Blocks Are in Production

This is the most common desync in publishing teams. A block ships, authors create hundreds of posts with it, and then a developer changes the save() output to fix a styling issue or add a wrapper element. Every existing post now contains markup that no longer matches the new save() output.

WordPress does not automatically migrate old block markup. The block editor will show a validation error for every affected post, and authors will be prompted to attempt block recovery. In many cases, recovery fails or produces broken content.

The correct approach is to use a deprecated block definition. WordPress supports an array of deprecated save functions that allow the parser to recognize old markup and migrate it to the new format:

deprecated: [
  {
    attributes: { content: { type: 'string' } },
    save({ attributes }) {
      return 
{attributes.content}
; }, }, ],

When the editor encounters old markup, it matches it against the deprecated save(), then re-saves the block using the current save(). This preserves content and prevents validation errors.

For teams that need to migrate many posts at once, a WP-CLI script can loop through posts and re-serialize block content. But that is a separate operation and should not replace proper deprecation handling.

WordPress block editor showing a validation error on a custom block

Failure Mode 4: RichText and InnerBlocks Mismatches

RichText and InnerBlocks have their own serialization rules. If you use RichText in edit() but output plain text in save(), the parser will not be able to reconcile the two. The same applies to InnerBlocks: the saved markup must include the inner block comments exactly as the editor expects them.

A common mistake is wrapping RichText content in a custom element in save() but not in edit(), or vice versa. For example:

edit({ attributes, setAttributes }) {
  return (
     setAttributes({ content })}
    />
  );
},
save({ attributes }) {
  return 
{attributes.content}
; }

This saves the content inside a div, but the editor expects it inside a p. The block will desync on the next load.

The fix is to use the same tag name and structure in both functions. If you need a wrapper element, use it consistently:

edit({ attributes, setAttributes }) {
  return (
    
setAttributes({ content })} />
); }, save({ attributes }) { return

{attributes.content}

; }

For InnerBlocks, the same principle applies. The save() function must output the inner block comments in the exact structure that edit() renders. If you add a wrapper element in one but not the other, the block will break.

Debugging a Desync in a Live Post

When a block desyncs in a live post, the first step is to open the post in the block editor and look at the validation error. WordPress will show the expected markup and the actual markup side by side. This comparison is often enough to identify the mismatch.

If the error is not clear, inspect the raw post_content in the database. Look for the block comment and compare the saved markup to what save() currently returns. You can do this with a quick WP-CLI command:

wp post get 123 --field=post_content

Or by querying the database directly. The key is to see the exact HTML string that was saved, not the rendered output.

For more complex blocks, add temporary logging to save() to print the returned markup. Then compare that string to the stored markup character by character. Whitespace differences, self-closing tags, and attribute order all matter.

Preventing Desyncs in a Team Workflow

Small-to-mid publishing teams often have multiple developers working on the same block codebase. Without a clear process, one developer can change save() without realizing the impact on existing content.

Three practices prevent most desyncs:

  • Treat save() as a frozen contract. Once a block ships, any change to save() requires a deprecated version. This is a hard rule, not a guideline.
  • Write block fixtures. Use the @wordpress/block-editor testing utilities to create fixture files that capture the expected saved markup. Run these tests in CI to catch accidental changes.
  • Review block changes with content migration in mind. Before merging a PR that touches save(), ask: “What happens to the 500 posts that already use this block?” If the answer is not “they migrate cleanly via deprecation,” the PR is not ready.

These practices are not theoretical. They are the difference between a block that survives a year of editorial use and one that breaks every time a developer touches it.

What to Do When a Block Is Already Broken in Production

If a block has already desynced across many posts, you have three options:

  1. Add a deprecated version that matches the old markup. This is the cleanest fix. The editor will recognize the old markup and migrate it to the new format on the next save.
  2. Write a migration script. Use WP-CLI or a custom plugin to loop through posts, parse the block markup, and update it to the new format. This is more invasive but can be necessary for large content sets.
  3. Leave the old markup and handle it in the frontend. If the block is only used for display and the old markup is still valid HTML, you can write a frontend filter that handles both formats. This is a stopgap, not a long-term solution.

The worst option is to ignore the validation errors and tell authors to “just click attempt recovery.” That shifts the burden to the people least equipped to handle it and often results in lost content.

How This Fits Into a Larger Editorial Workflow

Custom blocks are not just developer toys. They are the building blocks of a publishing team’s editorial workflow. When a block desyncs, it interrupts the entire pipeline: authors cannot edit posts, editors cannot review content, and scheduled publications slip.

This is why block stability is a systems engineering problem, not a frontend problem. The save() function is a data contract. Treating it as such—with versioning, testing, and migration planning—is the only way to keep a publishing operation running smoothly.

If you are dealing with a related issue where a new WordPress site returns “Nothing Found” on the frontend, the fix often involves permalink structure or query configuration. See What to Fix First When a New WordPress Site Says Nothing Found for a step-by-step breakdown.

Team of developers reviewing WordPress block code during a code review session

FAQ

Why does my block show “This block contains unexpected or invalid content” after I update the plugin?

This error means the markup stored in post_content no longer matches the output of your current save() function. You changed the save() output without adding a deprecated version. The editor cannot reconcile the old markup with the new expected markup, so it flags the block as invalid. The fix is to add a deprecated entry that matches the old save() output.

Can I use dynamic values like Date.now() in save() if I wrap them in a useMemo hook?

No. save() is not a React component and does not run hooks. It is a pure function that receives attributes and returns static HTML. Any dynamic value in save() will produce different markup on different loads, which guarantees a desync. Move dynamic logic to edit() and store the result as an attribute.

How do I know if my block needs a deprecated version?

If you change the output of save() in any way—adding a wrapper, changing a tag name, reordering attributes, or altering whitespace—you need a deprecated version. The only exception is if the block has never been used in any published post. In a team environment, assume every block has been used somewhere and treat save() as immutable once it ships.

What is the difference between a validation error and a block recovery failure?

A validation error occurs when the stored markup does not match the expected markup from save(). Block recovery is the editor’s attempt to fix the mismatch by re-parsing the stored markup. Recovery fails when the stored markup is so different that the parser cannot map it to the current block structure. This often happens when a block’s attributes have changed significantly or when the saved markup is malformed.

Next Steps for Your Team

If you are maintaining custom blocks for a publishing team, start by auditing every block’s save() function for non-deterministic output. Then add fixture tests that capture the exact saved markup. Finally, establish a rule that any change to save() requires a deprecated version and a migration plan.

This is not a one-time fix. It is a discipline that must be part of your block development workflow. The teams that treat save() as a data contract are the ones that avoid the worst block editor failures. The teams that don’t are the ones writing emergency migration scripts at 2 a.m. before a scheduled publication.

How to Reverse-Engineer a Shortcode’s Expected Attributes From Production Content

—that expands into server-side output through a registered handler. The handler declares expected attributes, applies defaults, and often fails without a sound when production content passes something unexpected. Reverse-engineering those expected attributes from live posts, pages, or custom post types is not a thought experiment. It is what you do when a migrated site renders [staff_bio id=""] as an empty div, when a legacy theme shortcode swallows a show_title flag, or when an editorial workflow depends on attributes that were never written down. This article covers the exact methods: reading the registration source, interrogating the database, tracing the render path, and rebuilding the attribute contract from production content.

For small-to-mid publishing teams, the failure is usually not a missing shortcode. It is a shortcode that exists, renders, and quietly drops attributes because the handler expects post_id while the content contains id. The fix is not another plugin. The fix is a precise reconstruction of the expected attribute map, followed by a content correction or a compatibility shim. This article assumes you have database access, a staging environment, and enough skepticism to distrust every comment in the theme’s functions.php.

Developer reviewing shortcode attribute code on a monitor in a WordPress staging environment

Why Production Content Is the Only Reliable Contract

Documentation lies. Code comments lie. The only source of truth for a shortcode’s expected attributes is the combination of the registered handler and the content that has survived in production. A shortcode like [author_card] may be documented as accepting name, role, and photo, but the production database may contain [author_card author="Jane" title="Editor" image="jane.jpg"]. The handler may map author to name through a compatibility branch, or it may ignore author entirely and render a blank card. You cannot know which without reading the handler and sampling the content.

Production content is also where attribute drift becomes visible. A shortcode introduced in 2019 may have used columns. A 2021 redesign may have changed the handler to expect cols. The old posts still contain columns. The new posts contain cols. The handler may support both, or it may have a shortcode_atts call that silently discards the old key. Reverse-engineering the expected attributes means finding both the current contract and the historical aliases that production content still relies on.

Step 1: Locate the Shortcode Registration

Start with the registration call. In a theme, it is usually in functions.php or an included file. In a plugin, it is in the main plugin file or a module. Search the codebase for add_shortcode. The second argument is the callback function name. That callback is where the attribute contract lives.

add_shortcode( 'staff_bio', 'render_staff_bio' );

Open the callback. The first thing to look for is the shortcode_atts call. This is the WordPress function that merges user-supplied attributes with defaults. The defaults array is the authoritative list of expected attributes—at least for the current version of the handler.

function render_staff_bio( $atts ) {
    $atts = shortcode_atts(
        array(
            'id'         => 0,
            'show_title' => 'true',
            'layout'     => 'compact',
        ),
        $atts,
        'staff_bio'
    );
    // ...
}

That array tells you the handler expects id, show_title, and layout. Anything else in the shortcode tag is discarded. If production content contains [staff_bio post_id="42"], the post_id attribute never reaches the render logic. The handler sees id as 0 and either renders nothing or falls back to a global post object.

But the defaults array is not the whole story. The callback may contain conditional logic that reads additional attributes directly from the $atts array before or after the shortcode_atts merge. It may also call shortcode_parse_atts manually, or it may use a custom parser for nested shortcodes. Read the entire callback, not just the first ten lines.

Step 2: Extract the Actual Attribute Usage From the Database

The registration source tells you what the handler expects. The database tells you what the content actually passes. These two sets rarely match perfectly. Run a query against wp_posts to find every post containing the shortcode tag.

SELECT ID, post_title, post_status
FROM wp_posts
WHERE post_content LIKE '%[staff_bio%'
  AND post_status = 'publish';

That gives you the posts. Now you need the raw shortcode instances. A simple approach is to export the matching post content and grep for the shortcode pattern. A more precise approach is to use a script that parses the content with get_shortcode_regex() and extracts the attribute strings.

$pattern = get_shortcode_regex( array( 'staff_bio' ) );
foreach ( $posts as $post ) {
    preg_match_all( '/' . $pattern . '/s', $post->post_content, $matches );
    foreach ( $matches[3] as $atts_string ) {
        $atts = shortcode_parse_atts( $atts_string );
        // Log $atts for analysis.
    }
}

This gives you the exact attribute keys and values used in production. Compare that list to the defaults array from the handler. The differences are your drift. Common findings:

  • Production uses post_id; handler expects id.
  • Production uses show_title="false"; handler expects show_title="0" or show_title="no".
  • Production uses align="left"; handler expects align="alignleft".
  • Production passes an attribute that no longer exists in the handler, such as author_bio after a redesign.

Each mismatch is a potential silent failure. The shortcode renders, but the output is wrong. The editor sees a broken layout and blames the theme. The actual cause is an attribute contract that drifted without a migration script.

Step 3: Trace the Render Path for Conditional Attributes

Some shortcodes do not use shortcode_atts at all. They read attributes directly from the $atts array and apply their own defaults inline. This is common in older themes and in shortcodes written by developers who did not trust the WordPress API.

function render_legacy_pullquote( $atts ) {
    $align = isset( $atts['align'] ) ? $atts['align'] : 'left';
    $cite  = isset( $atts['cite'] ) ? $atts['cite'] : '';
    // ...
}

In this case, the expected attributes are whatever the callback explicitly checks with isset or array_key_exists. The only way to reconstruct the contract is to read every conditional branch. Look for attributes that are read only when another attribute has a specific value. A shortcode may accept source="manual" and then read author_name and author_url only in that branch. Production content may contain those attributes, but they are ignored when source is auto.

Also check for attributes that are passed to a nested function or a template part. The shortcode callback may extract layout and then pass the entire $atts array to a template file. That template file may read additional keys that are not in the defaults array. The contract is then split across two files. You need to trace the full render path, not just the callback.

WordPress database query results showing shortcode attribute drift in production content

Step 4: Reconstruct the Attribute Map and Document the Aliases

Once you have the handler defaults, the conditional reads, and the production usage, build a single attribute map. For each attribute, record:

  • The canonical key the handler expects.
  • The default value applied when the attribute is missing.
  • The type or format the handler expects (string, integer, boolean string, comma-separated list).
  • Any legacy aliases that production content still uses.
  • Whether the attribute is required for meaningful output.

For the staff_bio example, the map might look like this:

Canonical key Default Type Legacy aliases Required
id 0 integer post_id, user_id Yes
show_title true boolean string title No
layout compact string style No

This map is the deliverable. It tells you exactly what to fix in production content and what to add to the handler for backward compatibility. Without it, you are guessing.

Step 5: Fix the Drift Without Breaking the Editorial Workflow

There are two ways to close the gap between production content and the handler contract. The first is to update the content. The second is to update the handler. The right choice depends on the volume of affected posts and the risk of touching live content.

If the drift is limited to a few dozen posts, a targeted content update is safer. Use a script that reads each post, parses the shortcode attributes, and rewrites the shortcode tag with the canonical keys. Run it in a staging environment first. Verify the rendered output before pushing to production.

If the drift affects hundreds of posts, or if the content is edited by multiple people who will continue to use the old keys, add an alias layer to the handler. Before the shortcode_atts call, map legacy keys to canonical keys.

function render_staff_bio( $atts ) {
    $atts = shortcode_atts(
        array(
            'id'         => 0,
            'show_title' => 'true',
            'layout'     => 'compact',
        ),
        $atts,
        'staff_bio'
    );

    // Legacy alias: post_id -> id
    if ( isset( $atts['post_id'] ) && ! isset( $atts['id'] ) ) {
        $atts['id'] = $atts['post_id'];
    }

    // Legacy alias: title -> show_title
    if ( isset( $atts['title'] ) && ! isset( $atts['show_title'] ) ) {
        $atts['show_title'] = $atts['title'];
    }

    // ...
}

This keeps old content rendering correctly while new content uses the canonical keys. It also gives you time to update the editorial guidelines and retrain the team. The alias layer is technical debt, but it is visible, documented debt—not a silent failure.

Common Failure Modes When Reverse-Engineering Shortcode Attributes

Boolean attributes that are not boolean

WordPress shortcode attributes are strings. A handler that expects show_title="true" may break when content contains show_title="1" or show_title="yes". The shortcode_atts function does not coerce types. If the handler checks if ( 'true' === $atts['show_title'] ), then show_title="1" evaluates to false. Production content may contain every variant. Reconstruct the expected values, not just the keys.

Attributes that are read before the defaults merge

Some callbacks read $atts before calling shortcode_atts. This is a bug, but it exists in production themes. If the callback checks if ( isset( $atts['id'] ) ) before the merge, then a missing id attribute behaves differently than a default id of 0. The contract is not just the defaults array; it is the order of operations in the callback.

Nested shortcodes that pass attributes through

A shortcode may contain another shortcode, and the outer shortcode may pass attributes to the inner one. For example, [section layout="grid"][card title="One"][/section]. The section handler may extract layout and then pass the remaining attributes to the card handler. The expected attributes for card are then partially defined by the section handler. Reverse-engineering requires tracing the nested render path, not just the individual shortcode registrations.

Attributes that are used only in a specific context

A shortcode may behave differently in a widget, a block template, or a REST API response. The handler may read context from a global variable and apply different defaults. Production content may contain attributes that are only relevant in one context. The attribute map must account for context-dependent behavior, or you will “fix” content that was actually correct.

Tools for the Job

You do not need a commercial plugin for this work. The tools are already in WordPress core and your database client.

  • get_shortcode_regex() — returns the regex pattern for matching shortcodes in content.
  • shortcode_parse_atts() — parses an attribute string into an associative array.
  • shortcode_atts() — merges user attributes with defaults; the source of truth for the current contract.
  • wp db query — WP-CLI command for running SQL against the database without leaving the terminal.
  • wp post list — WP-CLI command for listing posts that match a content search.

For a one-off audit, a small PHP script run via WP-CLI is usually faster than a plugin. The script can load WordPress, query the posts, parse the shortcodes, and output a CSV of every attribute key and value found in production. That CSV becomes the basis for the attribute map.

When to Write a Compatibility Shim Instead of Fixing Content

There is a point where fixing content is the wrong move. If the shortcode is used in thousands of posts, if the content is edited by a large team, or if the legacy attributes are deeply embedded in the editorial workflow, a compatibility shim in the handler is the pragmatic choice. The shim maps legacy keys to canonical keys and logs a deprecation notice for future cleanup.

The shim should be explicit. Do not use a generic loop that maps every unknown key to a canonical key. That hides drift instead of fixing it. Instead, list each legacy alias with a comment explaining when it was introduced and when it can be removed.

/**
 * Legacy alias map for staff_bio shortcode.
 *
 * post_id -> id       (introduced 2019, still used in 214 posts)
 * title   -> show_title (introduced 2020, still used in 87 posts)
 * style   -> layout     (introduced 2021, still used in 12 posts)
 */
$legacy_aliases = array(
    'post_id' => 'id',
    'title'   => 'show_title',
    'style'   => 'layout',
);

foreach ( $legacy_aliases as $legacy_key => $canonical_key ) {
    if ( isset( $atts[ $legacy_key ] ) && ! isset( $atts[ $canonical_key ] ) ) {
        $atts[ $canonical_key ] = $atts[ $legacy_key ];
    }
}

This is the kind of fix that keeps a publishing team moving without pretending the drift never happened. It also gives you a clear list of content to update when there is time.

Documenting the Contract for the Editorial Team

The final step is not technical. It is editorial. The attribute map you built needs to live somewhere the team can find it. A private page on the site, a shared document, or a comment block in the theme’s functions.php all work. The key is that the map is specific: canonical keys, accepted values, defaults, and examples.

Do not write “The staff_bio shortcode accepts several attributes.” Write:

[staff_bio id="42" show_title="true" layout="compact"]

id — required. The post ID of the staff member. Do not use post_id; it is a legacy alias and will be removed.

show_title — optional. Accepts true or false. Defaults to true. Do not use 1 or 0.

layout — optional. Accepts compact or full. Defaults to compact. Do not use style.

That is a contract. It tells the editor exactly what to type and what to avoid. It also gives the next developer a clear starting point when the shortcode needs to change again.

Editorial team reviewing a documented shortcode attribute contract on a shared screen

FAQ

How do I find every shortcode used in a WordPress site?

Run a database query against wp_posts for the shortcode bracket pattern, or use a script with get_shortcode_regex() to extract all registered shortcode tags from post content. The wp post list WP-CLI command with a --s search flag can also locate posts containing a specific shortcode string. For a full inventory, query the post_content column and parse each post with the regex pattern.

What is the difference between shortcode attributes and shortcode parameters?

In WordPress, the terms are often used interchangeably, but the technical distinction is that attributes are the key-value pairs inside the shortcode tag—[shortcode key="value"]—while parameters are the values passed to the handler function after parsing. The shortcode_atts function merges the parsed attributes with defaults, and the resulting array is the parameter list the callback receives. When reverse-engineering, focus on the attribute keys in the content and the parameter names in the callback.

Why does my shortcode render but ignore some attributes?

The most common cause is a key mismatch between the content and the handler’s defaults array. If the content uses post_id and the handler expects id, the shortcode_atts merge discards the unknown key. Another cause is a boolean value mismatch: the handler checks for true but the content passes 1. Read the handler’s shortcode_atts call and compare the keys and expected values to the actual attributes in the database.

Can I reverse-engineer a shortcode without access to the theme or plugin files?

You can reconstruct the attribute usage from the database alone, but you cannot know the handler’s expected defaults or conditional logic without reading the source. The database shows what content passes; the source shows what the handler accepts. For a complete contract, you need both. If the source is unavailable, you can infer likely defaults by testing the shortcode with different attribute combinations on a staging site and observing the rendered output.

Next Step: Build a Shortcode Attribute Registry

This article is the first step in a larger project: a shortcode attribute registry for your publishing stack. The registry is a single document or database table that lists every shortcode, its canonical attributes, accepted values, defaults, and legacy aliases. It becomes the reference for editors, developers, and anyone debugging a rendering issue. The next article in this series will cover how to build that registry from the attribute maps you create here, including a WP-CLI script that audits production content against the registry and reports drift automatically.

If you are dealing with a site that renders nothing at all, the problem may not be a shortcode. It may be a permalink or query issue. See What to Fix First When a New WordPress Site Says Nothing Found for the diagnostic order that catches the most common silent failures before you touch a single shortcode.