A client's news site landed on my desk with an odd report: several category pages showed "00 stories" in the header and a completely empty article grid below it. Odd, because the moment I opened wp-admin, those categories clearly had published posts. Dozens of them. The content existed; the page insisted there was none.
My first suspect was the counter itself. Turned out it was honest. The "00" was just found_posts from the main query, formatted with a leading zero. The query genuinely returned zero rows. So this was not a display bug; something between "published posts assigned to the category" and "the category page query" was throwing everything away.
Dump the SQL, do not stare at the template
When a listing is empty but the content exists, the biggest temptation is to start taking the template apart. That is a waste of time. WP_Query keeps the final SQL it executed in the $query->request property, and that is the first place to look:
add_action( 'wp', function () {
if ( is_category() ) {
global $wp_query;
error_log( $wp_query->request );
}
} );The output looked roughly like this (simplified):
SELECT SQL_CALC_FOUND_ROWS wp_posts.ID
FROM wp_posts
INNER JOIN wp_term_relationships
ON ( wp_posts.ID = wp_term_relationships.object_id )
INNER JOIN wp_postmeta
ON ( wp_posts.ID = wp_postmeta.post_id )
WHERE 1=1
AND wp_term_relationships.term_taxonomy_id IN (12)
AND wp_postmeta.meta_key = '_demo_source_id'
AND wp_posts.post_type = 'post'
AND wp_posts.post_status = 'publish'
GROUP BY wp_posts.ID
ORDER BY wp_posts.post_date DESC
LIMIT 0, 12Two lines jumped out immediately: the INNER JOIN wp_postmeta and the meta_key = '_demo_source_id' condition. I never wrote that meta key. No plugin I knew of used it. One grep through the theme later, there was the culprit:
add_action( 'pre_get_posts', function ( $query ) {
if ( $query->is_category() && $query->is_main_query() ) {
$query->set( 'meta_query', array(
array(
'key' => '_demo_source_id',
'compare' => 'EXISTS',
),
) );
}
} );Root cause: a theme raised on demo data
The theme was originally built against seeder-generated content. That seeder stamped every demo post with the _demo_source_id meta key. At some point, presumably to scope things to demo content, someone added a meta_query to EVERY category query requiring that key to EXISTS.
During development everything looked fine, because every post came from the seeder and every one of them carried the key. The moment the editorial team started publishing real articles, none of them had _demo_source_id, and that INNER JOIN dutifully excluded all of them. The categories were never empty. The query was rigged.
What made this bug so durable: it never errors. No warning, no fatal, nothing in the logs. The query is valid, the result is zero, and the template renders the empty state gracefully. Every component "works".
The fix
One principle: real content must never depend on seeder artifacts. I made the meta filter conditional, only active in an explicit demo context. If the team no longer needs a demo mode at all, delete it outright:
add_action( 'pre_get_posts', function ( $query ) {
if ( ! $query->is_category() || ! $query->is_main_query() ) {
return;
}
// Seeder metadata is a dev-only concern. Never require it in production.
if ( defined( 'THEME_DEMO_PREVIEW' ) && THEME_DEMO_PREVIEW ) {
$query->set( 'meta_query', array(
array(
'key' => '_demo_source_id',
'compare' => 'EXISTS',
),
) );
}
} );After that, found_posts immediately reported the right number and the grid filled up the way it should have all along.
The bonus bug: category thumbnails that never appeared
While I was in the same file, I found a second bug from the same family. The site has a per-category thumbnail option, and not a single one had ever shown up. Now it was obvious why:
// Admin side, saving the thumbnail:
update_option( 'category_thumb_' . $term_id, $attachment_id );
// Front end, reading it back:
$thumb_id = get_option( 'category_thumbnail_' . $term_id ); // always falseThe saving code used one option name pattern, the reading code used another. The setter and the getter never pointed at the same row, so get_option always returned false and the template fell back to a placeholder, forever, silently. Same species as the first bug: a hand-typed string key living in two places, drifting apart.
The fix: one source of truth.
function category_thumbnail_option_name( $term_id ) {
return 'category_thumb_' . (int) $term_id;
}
// Setter and getter now share one source of truth:
update_option( category_thumbnail_option_name( $term_id ), $attachment_id );
$thumb_id = get_option( category_thumbnail_option_name( $term_id ) );Now the setter and the getter cannot drift, because the option name is born in exactly one place.
The checklist
- Listing empty but the content exists? Do not guess. Dump
$query->requestand read the SQL. Look for aJOINontowp_postmetayou did not expect. - Themes built on top of demo data often quietly hard-depend on seeder metadata. Production content must never be required to carry seeder artifacts, period.
- Any string key used in more than one place, whether an option name, a meta key, or a transient, belongs in a single constant or helper. Two hand-typed copies will disagree eventually.
Since this case, whenever I see a zero where there should be a crowd, my first question is no longer "where did the content go" but "what is this query quietly requiring".
