D
P
0
← All articles Baca dalam Bahasa Indonesia

WordPress & PHP in Production

`meta_query` `NOT EXISTS` Cut the Feed From 356 Posts to 147: An Unticked Checkbox Still Saves an Empty Value

· · 9 min read
`meta_query` `NOT EXISTS` Cut the Feed From 356 Posts to 147: An Unticked Checkbox Still Saves an Empty Value

The request arrived in the second round of revisions and sounded like a one-clause job. The client wanted interviews to stop showing up in the news listing, because interviews already had their own rail on the homepage and their own tab. One interview series on the site was stored as ordinary posts, so its pieces were landing in the news listing purely because they were posts.

The signal to tell them apart already existed. The interviews feed, which the homepage rail and the interviews page share so the two never drift, only picks up posts whose interview flag meta is '1'. I deliberately put the news exclusion on that same flag. That way the two pages agree by construction: anything that appears on the interviews page is automatically absent from the news listing.

The obvious clause for the job is NOT EXISTS, and it is wrong.

The obvious clause

It looks like this, slightly simplified, with the key swapped for a neutral name. The same swap applies to the snippets that follow:

'meta_query' => [
    [ 'key' => '_theme_interview', 'compare' => 'NOT EXISTS' ],
],

Read as a sentence, the clause sounds right: give me the posts that carry no interview flag. Before shipping, I measured it against live data, and this naive version cut the feed from 356 posts to 147. Only 25 interviews were supposed to leave. The form that eventually shipped returns 331 news posts, and 331 plus 25 is 356.

So the naive clause removed 356 minus 147, which is 209 posts, for a job that only needed to remove 25. The difference, 209 minus 25, is 184 ordinary news posts that would have vanished along with the interviews. WordPress would not complain about any of this, because the clause is valid and the query runs normally. The only sign is in the count.

An empty value is still a row

The cause sits on the write side. The metabox writes that key with an empty value whenever the box is unticked, so the key exists on most ordinary posts. The theme's generic save handler runs for every defined field, and its checkbox branch only knows two values:

// checkbox branch of the metabox save handler, trimmed
update_post_meta( $post_id, $meta_key, ( (string) $raw === '1' ) ? '1' : '' );

NOT EXISTS asks whether a meta row exists, not what it contains. A row holding an empty string is still a row, so any post that was saved with the box unticked drops out of that clause, exactly like an interview whose box is ticked.

The flag itself was born for a different job. On 6 May the client asked for a manual selector for the interviews section on the homepage, and the checkbox was first added to the metabox of a custom post type. The client then reported that the checkbox was not visible to them, so the same field was added to the default Post metabox, next to a checkbox that was already there. From then on, every time an ordinary post is saved through that metabox, the handler above writes the flag's value too, ticked or not.

The counts split into three groups. The first group, 147 posts, has no row for that key at all, since only posts like that survive NOT EXISTS. The second group, 25 posts, holds '1'. The third is the rest: 356 minus 147 minus 25, which is 184 posts that carry the key with a value other than '1'. The news listing needs the first and third groups, and 147 plus 184 is 331, exactly the news count of the correct form. NOT EXISTS only returns the first group.

An OR of two clauses, and why both halves matter

The correct form is an OR of NOT EXISTS and value != '1', and that is what shipped for the news listing. Its commit message left a note for whoever touches that filter next: the obvious NOT EXISTS meta query is wrong, the metabox writes the key with an empty value when unticked, and the shipped form ORs NOT EXISTS with value != '1'.

The NOT EXISTS half catches the posts with no row, the group of 147. The != '1' half catches posts that have a row but are not ticked, the group of 184. The second half cannot stand on its own, because a value comparison in meta_query only applies to posts that actually have that meta row. Without its NOT EXISTS twin, the group of 147 is the one that would vanish. I covered that side in my article about listings that never reached the moderation queue, where meta that was never written failed the != comparison. This case is the reverse. The problem is not a missing row but a row with an empty value that turns out to exist on most posts.

The round closed on the evening of 2 August: deployed, pushed, the Cloudflare cache purged, and publicly verified. It is worth separating what was verified where. The 356, 147 and 331 were measured against live data before the code shipped. The public check afterwards covered the whole round, 8 of 8 round-two markers through real DNS with no cookie, rather than a dedicated recount of the news feed.

One helper for two feeds

The homepage news block had the same problem, and there it was already visible: two of the four latest rows in that block really were interviews. The next night I shipped the fix as a single helper shared by the homepage and the news listing, so the two feeds cannot drift apart. Its docblock opens with the same warning (trimmed, names neutralized):

/**
 * NOT a bare NOT EXISTS. The metabox writes _theme_interview with
 * an EMPTY value whenever the box is left unticked, so the key is present on
 * most ordinary posts: measured live, NOT EXISTS alone cuts 429 posts to 172.
 */
function theme_exclude_interview_flag() {
    return [
        'relation' => 'OR',
        [ 'key' => '_theme_interview', 'compare' => 'NOT EXISTS' ],
        [ 'key' => '_theme_interview', 'value' => '1', 'compare' => '!=' ],
    ];
}

The 429 in that docblock comes from measuring again on this surface before trusting the helper. A bare NOT EXISTS cut 429 posts to 172, while the OR form returned 404, and 404 plus 25 flagged interviews is 429. On this surface the naive clause would have dropped 429 minus 172, which is 257 posts, to remove 25.

A comment in the homepage template states the reason for the exclusion: the block is labelled as news, and the interviews rail below it already carries the interviews. For why NOT EXISTS alone is wrong there, the comment points to the helper. The cover fallbacks on the homepage are filtered too, but an editorial pin placed on purpose is still honoured, whatever it is. The commit message repeats the rule once more: an OR of NOT EXISTS and value != '1', never a bare NOT EXISTS. The re-audit that night closed with everything live, and that commit was among the ones already pushed.

So the same warning is now written in, or pointed to from, four places: two commit messages, the helper's docblock, and the comment in the homepage template.

Since the exclusion and the interviews feed read the same flag, the checkbox now decides two things at once. Ticked means the post enters the interviews feed and leaves the news feeds; unticked means the opposite. The help text under that checkbox in the editor asks for it to be ticked on every interview.

For a taxonomy, NOT IN is enough

On 10 August a similar pattern came from the taxonomy side. Someone on the client's side published a piece in a particular format, saw it appear in the homepage news list, took that as their own tagging mistake, and unpublished it. The tag was in fact correct. The news listing had simply never filtered by format.

The exclusion was built as a tax_query helper placed on three surfaces: the homepage cover fallback, the homepage latest list, and the news listing. It deliberately mirrors the interview helper's decision, one taxonomy along. The difference is that this time there is no NOT EXISTS twin. For a taxonomy, NOT IN does not need one, because WordPress compiles it into the subquery ID NOT IN (SELECT object_id ...), so posts with no term at all still pass.

Here the danger runs the other way. A comment in that code records that at the time only 1 post out of 435 had a term in the format taxonomy, so an EXISTS-shaped mistake, one that requires a post to have a term, would have emptied the news feed rather than trimmed it. On production the exclusion was proven to take 444 posts to 443, exactly one post leaving, and with a topic filter active, 31 to 30.

One more decision sits in the news listing. There the exclusion clause is merged into tax_query, never assigned, because the reader's topic filter builds a tax_query of its own. If either one were assigned over the other, the exclusion would silently disappear exactly when a reader narrows the feed. A simplified version, with names neutralized:

// Minimal example; function, taxonomy and term names neutralized.
function theme_news_format_exclusion() {
    return [
        [
            'taxonomy' => 'content_format',
            'field'    => 'slug',
            'terms'    => [ 'format-a' ],
            'operator' => 'NOT IN',
        ],
    ];
}
 
// Built up rather than assigned: the topic filter below adds a clause of its
// own, and assigning either one over the other would silently drop the exclusion
// exactly when a reader narrows the feed.
$tax_query = theme_news_format_exclusion();
if ( $topic_clause ) {
    $tax_query[] = $topic_clause;
}

The 31 to 30 above is the proof for this part: the exclusion still holds while a topic filter is active.

The counts have to add up

The working rule I wrote down from this case is short: verify a meta filter against real counts before trusting it. What makes the rule usable right away is one equation that holds for every number above. The filter's result plus the number of posts you deliberately meant to exclude has to equal the total before the filter. On the news listing, 331 plus 25 is 356. On the homepage, 404 plus 25 is 429. On the taxonomy side, 443 plus 1 is 444. The naive clause fails that same equation, because the gap between 356 and 147 is 209, not 25.

The equation does not care how WordPress compiles the clause. It only needs three numbers you can count before the code goes up: the total, the filtered result, and how many posts you actually want out. If the gap between the first two does not match the third, the clause has not earned your trust yet, however sensible it reads.