Excluding Posts by meta_value on a CPT Archive Without Corrupting Pagination

The symptom: the filter works, the page count does not

You add a pre_get_posts callback to a custom post type archive. The intent is simple: hide posts whose _editorial_status meta equals archived. The first page looks right. Then someone clicks page 3 and gets a page of posts that should have been excluded, or a 404 where a page should exist, or a pagination link that points past the last real page.

This is not a display bug. It is a query-shape bug. The exclusion changed the number of rows the database returns, but the pagination math was computed from a different shape than the one the template renders. The fix is not to hide rows in the loop. The fix is to make the main query itself return the correct set, and then verify that found_posts and max_num_pages agree with what the template shows.

Trace: where the hook sits in the query lifecycle

pre_get_posts fires after the query variable object is created but before the actual query is run. That is the documented contract, and it matters because it means you are modifying query vars, not filtering results. The hook receives the WP_Query instance by reference.

Inside the callback, use the method on the passed object, not the global conditional function. The WordPress reference is explicit: conditional tags should use the method versions within the passed instance, such as $query->is_main_query() instead of is_main_query(), because the global functions test against the global $wp_query instance, not the passed one. The is_main_query() function documentation goes further and calls the global function inside pre_get_posts circular.

That single detail explains a common failure: a callback that calls is_main_query() without the object method can evaluate against the wrong query, or trigger a _doing_it_wrong() notice, and the guard silently fails open. The filter then applies to a secondary loop in a sidebar or a related-posts block, and the archive pagination is computed from a query that never received the exclusion.

The scoping guard, in order

The documented guidance is to use conditionals so you do not unintentionally change queries for custom loops in sidebars, footers, or elsewhere. For a CPT archive, the guard has three parts and the order matters:

  1. ! is_admin() — do not touch admin screens.
  2. $query->is_main_query() — target the primary loop only.
  3. $query->is_post_type_archive( 'book' ) — target the specific archive.

The third check is a method on the query object. The hook reference shows the pattern with is_post_type_archive( 'movie' ) inside a pre_get_posts callback, so the method form is the documented shape. If you are unsure whether a conditional is safe at this point in the lifecycle, inspect $query->query_vars directly rather than guessing.

Root cause: a meta_query is a JOIN, not a display filter

WP_Meta_Query is the helper that generates JOIN and WHERE subclauses attached to the primary SQL query to filter results by metadata. When you add a meta_query clause in pre_get_posts, you are changing the SQL that produces both the rows and the count. That is the correct tool, but it is also why a malformed clause produces pagination drift rather than a visible error.

The compare argument accepts =, !=, >, >=, <, <=, LIKE, NOT LIKE, IN, NOT IN, BETWEEN, NOT BETWEEN, EXISTS, and NOT EXISTS. The choice between != and NOT EXISTS is not cosmetic. It determines whether posts that lack the meta key entirely are included or dropped, and that changes the count.

Why offset is the wrong tool here

The hook reference states plainly that using the offset argument in any WordPress query can break pagination, and that preserving pagination with offset requires handling pagination manually. Offset shifts the result window; it does not remove rows. If you try to exclude archived posts by skipping a fixed number of results, the window drifts as the excluded set changes, and the page count no longer matches the rows. Use a meta_query clause to remove rows from the set, and keep offset out of the main archive query.

The fix: append, do not overwrite, and choose the comparison deliberately

The main query may already carry a meta_query from another plugin or from the theme. Overwriting it with $query->set( 'meta_query', array( ... ) ) discards whatever was there. Read the existing value, normalize it to an array, append your clause, and set it back. The hook reference includes a user-contributed example that does exactly this normalization before appending.

Here is a labeled example. It is illustrative, not a report of a tested deployment.

add_action( 'pre_get_posts', 'jv_exclude_archived_books' );

function jv_exclude_archived_books( $query ) {
    if ( is_admin() ) {
        return;
    }
    if ( ! $query->is_main_query() ) {
        return;
    }
    if ( ! $query->is_post_type_archive( 'book' ) ) {
        return;
    }

    $meta_query = $query->get( 'meta_query' );
    if ( ! is_array( $meta_query ) ) {
        $meta_query = array();
    }

    $meta_query[] = array(
        'key'     => '_editorial_status',
        'value'   => 'archived',
        'compare' => '!=',
    );

    $query->set( 'meta_query', $meta_query );
}

Two decisions in that snippet deserve scrutiny.

Comparison choice. With compare => '!=', the query returns posts whose meta value is not archived. Posts that have no _editorial_status row at all are a separate case. If your editorial workflow guarantees every book has the key, != is sufficient. If some books predate the workflow, decide explicitly whether they belong on the archive. NOT EXISTS is the operator for the missing-key case, and it is a different query shape. Do not assume the two are interchangeable; verify the count for each.

Append, do not replace. The normalization block is not defensive decoration. If another callback already set a meta_query and you overwrite it, you have silently removed a filter that may have been controlling which posts appear. The archive then shows more posts than the other filter intended, and the pagination count reflects the larger set.

What the count actually means

WP_Query exposes found_posts as the total number of posts found matching the current query parameters, and max_num_pages as the result of found_posts / posts_per_page. Those are the numbers the pagination links are built from. If your meta_query clause is applied to the main query, both numbers reflect the filtered set. If it is applied to a secondary query, or if the guard fails and the clause lands on the wrong query, the template may render filtered rows while the pagination links were computed from the unfiltered main query. That is the drift.

Verification: prove the count before you trust the page

Do not verify by clicking through pages and eyeballing. Verify the numbers.

1. Confirm the hook fires once, on the main query

On a staging copy, dump the query vars inside the callback and log the call count. A callback that fires more than once for the archive request, or fires for a query whose post_type is not your CPT, is mis-scoped. The hook reference warns that without conditionals you can change custom loops in sidebars, footers, or elsewhere; the log is how you catch that.

function jv_debug_archive_query( $query ) {
    if ( ! $query->is_main_query() ) {
        return;
    }
    if ( ! $query->is_post_type_archive( 'book' ) ) {
        return;
    }
    error_log( 'main archive query_vars: ' . wp_json_encode( $query->query_vars ) );
}
add_action( 'pre_get_posts', 'jv_debug_archive_query', 999 );

Run it with WP_DEBUG_LOG enabled and read the log. You are looking for one entry per archive request, with the expected post_type and paged values.

2. Compare found_posts before and after

In the template, or in a temporary mu-plugin, print global $wp_query; echo $wp_query->found_posts; and echo $wp_query->max_num_pages; on the archive. Record both with the filter disabled and enabled. The difference should equal the number of posts you intended to exclude. If it does not, the clause is not matching what you think it is matching.

3. Check the SQL shape

Query Monitor exposes the main query SQL. Look at the JOIN and WHERE clauses generated for your meta clause. A != comparison and a NOT EXISTS comparison produce different JOIN shapes, and the row count difference between them is the set of posts with no meta row. If the JOIN is not what you expected, the comparison is not doing what you intended.

4. Confirm the pagination boundary

Request the last page directly by URL, for example /book/page/4/, and confirm it returns rows rather than a 404. Then request one page past the last, and confirm it 404s. If the last real page 404s, max_num_pages is too low. If a page past the end returns rows, it is too high. Both point back to the count, not the template.

What this pattern does not cover

This article is scoped to the main query on a CPT archive. It does not claim anything about how the REST API or the block editor’s query loop handles a pre_get_posts modification, because the retrieved documentation does not establish that behavior. If your archive is also consumed by a REST endpoint or a query loop block, verify those contexts separately rather than assuming the hook applies uniformly.

It also does not claim a performance outcome. Adding a meta clause changes the SQL; whether that is faster or slower depends on indexes and data volume, and the documentation does not provide a number to cite.

FAQ

Why does is_main_query() behave differently inside pre_get_posts?

The global function compares against the global $wp_query, not the query object passed to your callback. The hook reference says to use the method on the passed instance, and the is_main_query() documentation calls the global function circular in this context. Use $query->is_main_query().

Should I use != or NOT EXISTS to exclude a meta value?

They answer different questions. != excludes posts whose meta value equals the target; NOT EXISTS targets posts that lack the key. If some posts have no meta row, the two produce different result sets and different pagination counts. Decide which set you mean and verify found_posts for that choice.

Can I just use offset to skip the excluded posts?

No. The hook reference states that using offset in any WordPress query can break pagination and that preserving pagination with offset requires manual handling. Offset shifts the window; it does not remove rows from the set.

Why did my exclusion stop working after another plugin update?

A likely cause is that another callback set a meta_query and your callback overwrote it, or the reverse. Read the existing meta_query, normalize it to an array, append your clause, and set it back. Then re-check found_posts.

Where should I look first when the page count is wrong?

At found_posts and max_num_pages on the main query, not at the template. max_num_pages is found_posts / posts_per_page. If the count is wrong, the pagination links are wrong, and the template is doing what it was told.

If the archive is returning nothing at all rather than the wrong count, the failure is earlier in the request and a different diagnostic sequence applies; see what to fix first when a new WordPress site says nothing found.

Sources

The ‘I Had Potential’ Shirt Is a Physics Diagram, Not a Slogan

Editorial note: This publication and Quirky Cotton Collective share an owner.

The shirt prints four words in white capitals — I HAD POTENTIAL — above a white curved ramp. A dashed empty circle sits at the upper left ledge. A solid white ball rests on the lower right flat ledge. The ramp descends between them. That is the whole design, and it is enough to make an argument.

Most slogan shirts ask you to agree with a sentence. This one asks you to read a diagram. The sentence is the caption; the drawing is the claim. If you only read the words, you get a familiar joke about wasted promise. If you read the drawing, you get a specific physical situation: a ball that has already left the high ledge, rolled down a curved track, and come to rest at the bottom. The dashed circle marks where it started. The solid ball marks where it is. The gap between them is the joke, and it is also a small lesson in how potential works.

In physics, potential energy is not a promise. It is a function of position in a field. A ball at the top of a ramp has gravitational potential energy relative to the bottom. That energy is real, but it is only available if the ball moves. The dashed circle is the ball’s initial state: a position with potential. The solid ball is the final state: a position with less potential and more entropy. Nothing in the drawing says the ball was pushed, dropped, or failed. It simply shows the two states and the path between them. The humor comes from treating a human word — potential — as if it were a coordinate.

That is why the curved ramp matters. A straight diagonal would read as a graph of decline, the kind of line you see in a bad slide about performance. A curve reads as a track, a chute, a physical object with a shape. The dashed circle at the upper left is not a ghost or a memory; it is a technical convention for a starting position. The solid ball at the lower right is not a punchline; it is the current state. The design is closer to a free-body diagram than to a motivational poster, and that is the source of its dry appeal.

There is a second reading, and it is the one that makes the shirt useful rather than merely funny. In engineering, potential is a capacity, not an outcome. A system has potential throughput, potential load, potential failure modes. Potential is what the system could do under certain conditions, not what it will do. The shirt’s diagram shows a ball that had potential and now has position. The joke is that the two are not the same thing. Anyone who has maintained a production system knows this gap intimately: the capacity was there, the conditions changed, and the outcome is now a fact on the ground. The shirt does not explain that. It just draws it.

The wording is terse, and the terseness is doing work. “I Had Potential” is past tense. It does not say “I Have Potential” or “I Am Potential.” The past tense closes the account. The ball is at rest. The dashed circle is empty. The sentence and the drawing agree: the moment of possibility is over, and what remains is a position. That is a more disciplined joke than a sarcastic slogan, because it withholds the complaint. It does not blame a boss, a system, or a lack of effort. It presents a state diagram and lets you supply the history.

That restraint is also a limitation. A diagram joke depends on the viewer recognizing the convention. If you read the dashed circle as a hole and the solid ball as a different object, the design becomes a picture of a ball that fell into a gap. If you read the ramp as a slide, it becomes a playground joke. The shirt does not enforce its own reading. It relies on the visual grammar of physics diagrams — dashed for initial, solid for final, curve for path — and that grammar is not universal. The design is legible to people who have seen a potential-energy sketch, and merely odd to people who have not. That is not a flaw, but it is a boundary. The shirt is not a universal slogan; it is a diagram with a caption, and it works best when both are read together.

The object itself is a standard t-shirt from Quirky Cotton Collective, listed as I Had Potential Funny Physics Pun Sarcastic Minimalist Quote. The retail title calls it a physics pun, and the drawing supports that description: the ramp, the dashed start, and the solid end are the visual vocabulary of a potential-energy diagram, simplified to two states and a path. The title also calls it sarcastic and minimalist, which are fair descriptions of the tone and the layout. The design is white on the garment, with no additional illustration or texture described in the observed image. The shirt is the object; the diagram is the argument.

What makes the shirt worth writing about is not the joke but the distinction it draws between a slogan and a usable promise. A slogan asserts a value: “I am potential.” A promise specifies conditions: “If you do X, Y will follow.” The shirt’s diagram specifies a condition — a ball at the top of a ramp — and a result — a ball at the bottom. It does not promise that the result was deserved or avoidable. It simply shows the transition. That is a more honest form of humor than most sarcastic shirts manage, because it does not pretend that potential is a moral quality. Potential is a state. States change. The shirt is a picture of that change, with a caption that admits it.

For technical readers, the appeal is probably not the physics. It is the format. The shirt treats a human complaint as a system diagram, and that is a familiar move. Debugging is the practice of turning a vague symptom into a specific state: what was the input, what was the expected output, what is the actual output, and where did the path diverge? The shirt does the same thing in miniature. It shows an initial state, a final state, and a path, and it lets the viewer infer the missing variable. The missing variable is whatever caused the ball to move. The shirt does not name it. That omission is the joke, and it is also the discipline. A good diagram does not explain everything; it shows enough to make the question precise.

The shirt is not a tutorial, and it would be a mistake to read it as one. It does not demonstrate a software failure, a security boundary, or an engineering result. It is a piece of clothing with a drawing on it, and its argument is visual and verbal at once. The useful observation is that the drawing and the sentence are not redundant. The sentence supplies the human word — potential — and the drawing supplies the physical situation. Neither alone would be as effective. Together they produce a small, dry joke about the difference between capacity and outcome, and they do it without raising their voice.

That is the test I would apply to any slogan shirt: does the image add something the words cannot? Here it does. The words say what was lost. The drawing shows where it went. The dashed circle is empty, the ball is at rest, and the ramp is curved, not straight. The design is not a generic gift; it is a specific diagram with a specific caption, and it earns its place by making the viewer read both.

The ‘Let’s Root For Each Other’ Shirt Is a Diagram of Mutual Support, Not a Growth Mandate

The artwork reads LET’S ROOT FOR EACH OTHER AND WATCH EACH OTHER GROW, set in a ring around four smiling root vegetables: a carrot, a radish, a beet, and an onion. Their roots are visible in soil. The lettering is the frame; the vegetables are the content. That arrangement is the first thing worth noticing, because it makes the shirt a small diagram rather than a slogan with decoration attached.

I want to argue something specific about this design: it separates two ideas that work culture usually collapses into one. The first idea is rooting for someone — an act of support that costs the supporter nothing except attention and goodwill. The second is watching someone grow — an outcome that belongs to the person growing, not to the person watching. The shirt puts both in the same sentence, but the grammar keeps them apart. Let’s root for each other is a mutual commitment. And watch each other grow is a mutual permission. The first is a promise you make; the second is a promise you keep by not interfering.

That distinction matters because the common workplace version of this sentiment is worse. We grow together sounds collaborative but often means your growth is now a shared performance metric. Let’s root for each other is lighter. It asks for encouragement, not surveillance. The shirt’s wording is doing real work here, and it is worth reading closely before deciding whether it belongs in a team channel or a weekend wardrobe.

The vegetables are the argument

Four root vegetables, each with its edible part below the soil line. The design shows the roots. That is not incidental. A carrot’s orange shoulder is the part everyone sees; the part that actually feeds you is underground. The same is true of the radish, the beet, and the onion. The artwork makes the hidden structure visible, which is a quiet claim about growth: the visible part is not the whole story, and the part doing the work is often the part nobody photographs.

I read the smiling faces as a deliberate softening. Root vegetables pulled from the ground are, visually, a little absurd — lumpy, dirt-adjacent, irregular. Giving them smiles turns them into characters rather than produce. That matters for the pun: root works as both a noun (the vegetable’s root) and a verb (to cheer for). The design needs the vegetables to be legible as roots for the verbal joke to land, and it needs them to be friendly for the emotional register to land. Both are present.

What the artwork does not show is a garden bed, a trellis, a harvest basket, or a growth chart. There is no upward arrow, no sunburst, no level up iconography. The vegetables are in soil, roots visible, smiling. That is the entire scene. The restraint is the point. A design that wanted to sell you relentless improvement would have drawn a ladder. This one drew a cross-section.

Two meanings of rooting, and why the shirt needs both

The pun only works because English lets root mean both the underground part of a plant and to support enthusiastically. The shirt uses both senses at once, and the visual observation confirms it: the roots are drawn, and the word root is printed. Neither sense is subordinate. If you read the shirt as a gardening joke, the vegetables carry it. If you read it as a team-culture message, the verb carries it. The design is built to survive both readings, which is why it works as an off-duty object for people who spend their working hours reading diagrams.

There is a third sense lurking, which the shirt does not use: root as in root cause, the thing you trace a symptom back to. I mention it only to set it aside. The shirt is not making a debugging joke, and it would be a mistake to force one onto it. The vegetables are not a stack trace. The smiles are not a status code. The design is about mutual encouragement and visible hidden structure, and that is enough.

Where the shirt becomes genuinely useful as an object — rather than just a pleasant pun — is in the gap between the two clauses. Root for each other is easy to say and easy to fake. Watch each other grow is harder, because watching requires you to accept that the growth is not yours to direct. A team that roots for each other but cannot watch each other grow is a team that applauds in public and micromanages in private. The shirt’s sentence structure quietly rules that out. The two clauses are joined by and, not so that. The growth is not the reward for the rooting. They are parallel commitments.

What the shirt is not

It is not a productivity tool. It does not claim that wearing it improves team dynamics, accelerates career development, or increases vegetable intake. It is a printed design on a garment, and the design makes an argument about how support and growth relate. That argument is worth taking seriously as a piece of visual rhetoric. It is not worth mistaking for evidence.

It is also not a generic gift-roundup item. The reason to single it out is the specific wording and the specific drawing: four named root vegetables, roots visible, smiles present, two clauses held in parallel. A shirt that said Grow Together over a stock photo of a seedling would be a different object with a different argument. This one is more careful than that, and the care is visible in what it leaves out.

Who this reading is for

If you spend your days tracing symptoms to root causes, you already have a trained eye for hidden structure. The shirt rewards that eye. The roots below the soil line are the part of the drawing that carries the most meaning, and they are the part a casual glance would miss. That is a small pleasure, but it is a real one, and it is the kind of pleasure that makes an object worth keeping rather than merely wearing.

The shirt is available as a standard t-shirt from Quirky Cotton Collective. If you are considering it for a team or a gardening friend, the question to ask is not whether the pun is clever — it is — but whether the two clauses describe the culture you actually want. Root for each other is the easy half. Watch each other grow is the half that requires you to stay out of the way.

Editorial note: This publication and Quirky Cotton Collective share an owner.

The Large Lowercase ‘meh.’ Shirt Is a Typographic Shrug, Not a Personality

The native garment preview shows four lowercase letters and a period, set in a heavy white sans-serif on a dark shirt: meh. No illustration. No secondary line of copy. No animal, no mascot, no pride variant. The word is large enough to read across a room, and the period is doing more work than the letters are.

That period is the whole argument. A shirt that said meh without punctuation would be a noise — a vowel sound, a shrug rendered as text. The period converts it into a sentence. It is a complete, closed, grammatically finished statement of disinterest. The typography is loud and the message is quiet, and the gap between those two things is the design.

I want to read this object the way I read a log line: what is it asserting, what is it withholding, and what would break if you changed one character.

The period is a terminator, not decoration

In prose, a period ends a declarative sentence. It signals that the speaker has finished and does not intend to elaborate. On a shirt, that signal is unusually strong because the garment has no room for a follow-up clause. You cannot append a caveat to a chest. So the period is not stylistic; it is structural. It tells the reader that the four letters are the entire payload.

This is why the design reads as dry rather than sad. A bare meh invites interpretation — is the wearer tired? bored? depressed? The period forecloses the question. It is not an emotion seeking sympathy; it is a verdict, delivered and closed. The visual loudness — large bold lowercase, high contrast — is the only thing that keeps the verdict from being invisible. The shirt has to shout the shrug, or the shrug would not register at all.

Lowercase matters here too. Capital MEH. would read as a command or a brand. Lowercase meh. reads as a muttered aside that has been typeset at billboard scale. The mismatch between the volume of the type and the flatness of the word is the joke, and it is a typographic joke rather than a verbal one.

What the shirt is not

It is not an introvert identity badge, despite the retail title’s adjacency to that vocabulary. The artwork contains no claim about social battery, no reference to crowds, no diagram of a person leaving a party. It is one word and a full stop. Reading introversion into it is a plausible inference about why someone might buy it, but it is not something the design states. I am distinguishing my interpretation from a claim about the wearer.

It is also not a slogan in the operational sense. A slogan makes a promise — just do it, think different — and a promise can be kept or broken. meh. promises nothing. That is its integrity. It is a refusal dressed as a statement, and the period is the refusal.

If you want the object itself, the listing is here: Meh Funny Sarcastic Saying Introvert Dry Humor. The design is the large bold white lowercase meh. on a dark shirt, and that is the entire visual program.

The useful question for a team that ships words

I keep a small test for any message that goes out under a team’s name: strip the punctuation and see whether the meaning survives. meh survives as a sound. meh. survives as a position. The period is load-bearing. Most production copy has the opposite problem — it is over-punctuated and under-said, with exclamation marks doing the work that a clear declarative should do. This shirt is a reminder that a single terminal mark can carry the entire tone of a message, and that volume in the typeface is not the same as volume in the claim.

The design is loud and the sentence is finished. That combination is harder to pull off than it looks, and it is the reason the shirt works as an object rather than as a novelty. The word is dismissive; the typesetting is not. The period is what makes the dismissal legible.

Editorial note: This publication and Quirky Cotton Collective share an owner.

wp_remote_get() Returns 200 With an Empty Body: Tracing the gzip Response Through WP_Http and Requests

A 200 with an empty body is not a server problem until you have proven it is. The status code tells you the origin answered. It says nothing about whether the bytes survived WordPress’s argument handling, the Requests decompression step, a filter on http_response, or your own retrieval code. Each of those layers can independently hand you an empty or unreadable body while leaving the 200 intact.

This is a walkthrough of that pipeline, in the order the bytes travel, with an observation point at each stage. The failure shape is specific: wp_remote_get() returns an array, wp_remote_retrieve_response_code() says 200, the headers include Content-Encoding: gzip, and wp_remote_retrieve_body() returns '' — or returns bytes that are not the text you expected.

Symptom: the response array is not the body

wp_remote_get() performs an HTTP GET and returns an array or a WP_Error. The documented way to read the body is wp_remote_retrieve_body(). That function is deliberately defensive: it returns an empty string if the response is a WP_Error or if the body key is not set. Its source is four lines long, and the return docblock says “Empty string if no body or incorrect parameter given.”

So the first distinction to make is not “is the body empty” but “is there a body key at all.” Those are different failures with different causes, and wp_remote_retrieve_body() collapses them into the same ''.

$response = wp_remote_get( 'https://example.com/feed.json' );

if ( is_wp_error( $response ) ) {
    error_log( 'WP_Error: ' . $response->get_error_message() );
    return;
}

error_log( sprintf(
    'code=%s body_key=%s body_len=%d encoding=%s',
    wp_remote_retrieve_response_code( $response ),
    array_key_exists( 'body', $response ) ? 'yes' : 'no',
    strlen( (string) ( $response['body'] ?? '' ) ),
    (string) wp_remote_retrieve_header( $response, 'content-encoding' )
) );

If body_key=no, you are not looking at a compression problem. Something replaced the response array — a filter, a short-circuit, or a transport that returned a shape you did not expect. If body_key=yes and body_len=0, the transport delivered zero bytes and the question moves upstream. If body_len is nonzero but the content is garbage, you are in the decompression layer, which is the interesting case for a Content-Encoding: gzip header.

Trace: where the bytes actually go

The call path is short and worth holding in your head. wp_remote_get() grabs the shared WP_Http instance and calls its get() method, which routes into WP_Http::request(). That method merges defaults, applies http_request_args, checks pre_http_request for a short-circuit, and then hands the request to the bundled Requests library via WpOrg\Requests\Requests::request(). The result is wrapped in a WP_HTTP_Requests_Response and converted to the array you receive.

Two defaults in that chain matter here. First, decompress defaults to true in the WP_Http::request() argument list. Second, the response array is documented to carry headers, body, response (code and message), cookies, and filename, plus the original http_response object. If any of those are missing when you inspect the array, a filter or a short-circuit is the suspect, not the remote server.

The decompression itself lives in Requests::decompress(). Read its source rather than its reputation. It does the following, in order:

  1. Rejects non-string input.
  2. Returns the input unchanged if trim( $data ) === '' — an empty body is not decompressed, it is passed through.
  3. Reads the first two bytes and looks them up in a magic-header table. If they do not match a known compression signature, it returns the data unchanged, with the source comment “Not actually compressed. Probably cURL ruining this for us.”
  4. Tries gzdecode(), then gzinflate(), then a compatibility inflate, then gzuncompress().
  5. If every attempt fails, returns the original $data.

That last line is the one to underline. Requests::decompress() fails open. It does not throw when it cannot decode. It hands you back the bytes it received. A decompression mismatch therefore does not surface as an error; it surfaces as a body that is present, nonzero, and unreadable — or, if the transport already stripped the payload, as an empty string that looks like a server problem.

The gzip format itself is unambiguous about what a valid stream starts with: RFC 1952 fixes the identification bytes at ID1 = 31 (0x1f) and ID2 = 139 (0x8b). If the first two bytes of the body are not those, Requests::decompress() will not attempt gzip decoding at all, regardless of what the Content-Encoding header claims. That is the seam where a header and a payload can disagree.

Root cause: the header is a claim, not a guarantee

RFC 9110 defines Content-Encoding as representation metadata describing how the content is intended to be interpreted by the recipient. It is a statement about the payload, not a proof of it. A response can carry Content-Encoding: gzip while the body is already decoded — because an intermediary decoded it and forwarded the header, because the origin misconfigured its compression module, or because the transport negotiated encoding and then handed WordPress a body that no longer matches the header.

When that happens, Requests::decompress() sees a body whose first two bytes are not 0x1f 0x8b, returns it unchanged, and WordPress passes it to your code. Your code, trusting the header, tries to gzdecode() it, fails, and reports an empty result. The 200 is real. The header is real. The body is real. The mismatch is between the header and the bytes.

The other root cause is the inverse: the body is genuinely gzip-encoded, but something in the pipeline removed it before decompression. The two candidates worth checking are stream and limit_response_size. If stream is true, the body is written to a file and the response array’s body is not where you think it is. If limit_response_size is set, the transport truncates the body at that byte count, and a truncated gzip stream fails every decode attempt in Requests::decompress(), which then returns the truncated bytes unchanged. You get a nonzero, unreadable body and a 200.

Neither of these is a core defect. They are argument interactions, and they are visible if you look at the right layer.

Fix: observe at http_response before changing anything

The http_response filter fires on a successful HTTP API response immediately before the response is returned. It receives the response array, the parsed arguments, and the URL. That is the highest-value single observation point for this class of bug, because it sits after transport and after decompression but before your calling code. Whatever you log there is what your code would have received.

add_filter( 'http_response', function ( $response, $args, $url ) {
    if ( false === strpos( $url, 'example.com' ) ) {
        return $response;
    }

    $body = isset( $response['body'] ) ? (string) $response['body'] : '';
    $head = substr( $body, 0, 2 );

    error_log( sprintf(
        'http_response url=%s code=%s len=%d magic=%s encoding=%s decompress=%s',
        $url,
        $response['response']['code'] ?? 'none',
        strlen( $body ),
        bin2hex( $head ),
        $response['headers']['content-encoding'] ?? 'none',
        ! empty( $args['decompress'] ) ? 'true' : 'false'
    ) );

    return $response;
}, 10, 3 );

Read the log line as a decision table:

  • len=0 and encoding=gzip: the transport delivered nothing. The remote or an intermediary is the suspect. Compare against a direct fetch (below).
  • len>0, magic=1f8b, and your code still gets garbage: decompression did not run or did not succeed. Check decompress=false in the args, and check whether a later filter replaced the body.
  • len>0, magic is something else, and encoding=gzip: the header and payload disagree. The body is already decoded or is not gzip at all. Do not call gzdecode() on it.
  • len>0, magic=1f8b, and decompress=true: the body reached the filter still compressed. That means decompression was skipped or failed silently. This is the case where you read Requests::decompress() against the actual bytes.

Note that the filter can also be the cause. The documented example for http_response replaces the body for non-200/301/302 codes. Any plugin on the site can do the same for a 200. If your log line shows a body length that does not match what the remote sends, search the codebase for add_filter( 'http_response' before you touch request arguments.

Verification: compare the transport against the WP_Http path

Once you have an observation at http_response, the next step is to establish what the remote actually sends, independent of WordPress. Use a direct transport fetch with the same URL and the same headers, and compare the raw bytes.

curl -sS -D - -o /tmp/body.bin \
  -H 'Accept-Encoding: gzip' \
  'https://example.com/feed.json'

printf 'first bytes: '; xxd -l 2 /tmp/body.bin
printf 'size: '; wc -c < /tmp/body.bin

If xxd shows 1f8b, the remote is sending gzip and the header is honest. If it shows anything else while the response headers include Content-Encoding: gzip, you have found the mismatch at the origin or an intermediary, and no WordPress argument will fix it. If the file is zero bytes, the remote is sending an empty payload with a 200, and the problem is upstream of WordPress entirely.

To confirm the WordPress side is not the cause, run the same request through wp_remote_get() from WP-CLI with the http_response logger active, and compare the logged len and magic against the curl output. If they match, WordPress is faithfully delivering what the remote sent, and the bug is in your caller’s decoding assumptions. If they differ, the difference is in the argument handling or a filter, and you now have the layer isolated.

wp eval '
$r = wp_remote_get( "https://example.com/feed.json", array( "timeout" => 15 ) );
if ( is_wp_error( $r ) ) { echo $r->get_error_message(); exit; }
printf( "code=%s len=%d magic=%s\n",
    wp_remote_retrieve_response_code( $r ),
    strlen( wp_remote_retrieve_body( $r ) ),
    bin2hex( substr( wp_remote_retrieve_body( $r ), 0, 2 ) )
);
'

If the WP-CLI run and the curl run agree, stop changing request arguments. The remaining work is in the caller: check whether it calls gzdecode() unconditionally, whether it assumes Content-Encoding implies a compressed body, and whether it treats an empty string from wp_remote_retrieve_body() as a transport failure when it may be a legitimate empty payload.

What not to do first

Do not set decompress => false to “see what happens.” The WP_Http::request() docs note that if you disable decompression and compressed content is returned anyway, it must be separately decompressed — which means you have moved the problem into your code without measuring it. Do not add a Content-Encoding header to the request to force a particular encoding; the request header and the response header are different fields with different semantics. Do not install a plugin to “fix gzip.” The pipeline is four functions deep and observable at each step.

The order that works is: log at http_response, compare against a direct transport fetch, then and only then adjust decompress, stream, or limit_response_size. Each of those arguments changes what the transport does; none of them changes what the remote sent. If the remote sent an empty body with a 200, no argument will conjure content. If the remote sent gzip and the header says gzip, the decompression layer is where the bytes are lost, and Requests::decompress()‘s fail-open behavior is why you did not get an error.

FAQ

Why does wp_remote_retrieve_body() return an empty string instead of an error?

Because it is designed to. Its source returns '' when the response is a WP_Error or when the body key is missing, and the docblock states “Empty string if no body or incorrect parameter given.” It does not distinguish between a missing key, an empty payload, and a failed decompression. Inspect the response array directly before calling it.

Does WordPress decompress gzip responses automatically?

WP_Http::request() defaults decompress to true, and the bundled Requests library implements the decoding in Requests::decompress(). That function returns the input unchanged when the leading bytes do not match a known compression signature, and returns the original data if every decode attempt fails. So “automatic” is accurate only when the payload actually matches the header.

Can a 200 response legitimately have an empty body?

Yes. RFC 9110 defines status codes as describing the response, not guaranteeing a payload. A 200 with zero bytes is a valid response. The question is whether the remote intended to send content and something in the pipeline removed it, which is why the http_response log line and the direct curl comparison are the two measurements that settle it.

Where should I put the diagnostic filter?

In a small must-use plugin, not in the theme. The filter needs to be active for the request that fails, including requests made during cron runs and REST API calls, and a theme’s functions.php is not loaded for every one of those. A mu-plugin also keeps the logger out of the way when you remove it.

What if the body is compressed but decompress is true and the log still shows 1f8b?

Then decompression was skipped or failed silently, and the next step is to read Requests::decompress() against the actual bytes: check the first two bytes, check whether the stream is truncated (compare len against the remote’s Content-Length if present), and check whether a later filter on http_response replaced the body after decompression. The filter runs after the transport, so a filter that rewrites the body is indistinguishable from a transport that never decoded it unless you log both the length and the magic bytes.

Template Lock Is an Editor Contract, Not a Storage Contract

The failure mode is quiet. A container block ships with an InnerBlocks template and templateLock="all". Editors behave. Then someone with edit_theme_options opens the same post, the lock affordance is gone or overridable, and the saved markup no longer matches the template. Nothing throws. The front end renders whatever was stored.

That is the part worth internalizing: templateLock is a constraint on the editing session, not on the database. The Block Editor handbook describes templates as specifying a “default initial state for an editor session.”1 A default state is not an invariant. If your theme or plugin assumes the stored block tree will always match the template, you have assumed something the editor never promised.

Symptom

A custom block, say acme/callout, registers an InnerBlocks template of exactly one core/paragraph and locks it with templateLock="all". The rendered output wraps that paragraph in a styled aside. One day the aside renders empty, or with three paragraphs, or with a heading where the paragraph should be. The post was saved through the editor by a user who could unlock, or saved through the REST API, or created programmatically by a migration script that never touched the editor at all.

The lock did its job for the users it was designed for. It did not do the job you assumed it did for everyone else.

Trace

Three layers are involved, and they are not the same layer.

Editor-side locking. templateLock on InnerBlocks, template_lock on a post type registration, and the per-block lock attribute all constrain what the editor UI permits. The handbook is explicit about the values: all “prevents all operations. It is not possible to insert new blocks, move existing blocks, or delete blocks.” insert prevents inserting or removing but allows moving. contentOnly restricts editing to content attributes.1 These are descriptions of UI behavior.

Inheritance. Locks propagate. “If templateLock is not set in an InnerBlocks area, the locking of the parent InnerBlocks area is used. If the block is a top level block, the locking configuration of the current post type is used.”1 Inheritance is convenient and also means a lock can be absent at the level you are inspecting while still being present somewhere above it, or vice versa.

Storage. What actually lands in wp_posts.post_content is serialized block markup. The block parser stores attributes from the comment delimiters in the parsed block structure.2 Nothing in that parse step consults templateLock. The parser does not know what template you intended; it only knows what delimiters and attributes are present.

Render. The front end walks the stored tree and renders it. The render_block filter runs there. The handbook notes it “filters the front-end content of any block” and “has no impact on the behavior of blocks in the Editor.”3 That sentence is the whole argument in miniature: the editor and the renderer are separate surfaces, and only one of them is where your output contract can be enforced.

Root cause

The premise in the question — that a user with edit_theme_options can bypass templateLock — is plausible but not something the retrieved documentation establishes. current_user_can() “returns whether the current user has the specified capability” and accepts an object ID for meta capabilities.4 That tells you how to check a capability. It does not tell you which capability the editor consults before honoring a lock, and the handbook’s locking pages describe the lock values without documenting a capability gate on them.

So treat the specific capability claim as a hypothesis to verify in your own install, not a documented fact. The durable point does not depend on it: any path that writes post_content without going through the locked editor — a REST update, a WP-CLI post update, an import, a programmatic wp_insert_post(), a future editor change — produces stored markup the lock never saw. If your invariant matters, it cannot live only in the editor.

Two tiers are useful here:

  • Editor locks reduce accidental damage for the users the UI is designed for. Keep them.
  • Render-time enforcement asserts the output contract for everyone, including writers who never open the editor.

Fix: enforce at render time

The render_block callback receives three arguments: the block content, the full block including name and attributes, and the WP_Block instance.3 That is enough to inspect structure and decide what to emit. For a single block type, render_block_{namespace/block} is the narrower form.3

The pattern below is illustrative, not a claim about a specific site. It asserts that acme/callout contains exactly one core/paragraph child and emits a safe fallback otherwise. It does not mutate stored content; it only changes what the front end receives.

add_filter( 'render_block_acme/callout', function ( $block_content, $block ) {
    $inner = $block['innerBlocks'] ?? array();

    $paragraphs = array_filter( $inner, function ( $child ) {
        return isset( $child['blockName'] ) && 'core/paragraph' === $child['blockName'];
    } );

    if ( 1 !== count( $inner ) || 1 !== count( $paragraphs ) ) {
        // Structural violation: emit a minimal, safe wrapper instead of the stored tree.
        return '<aside class="acme-callout acme-callout--invalid"></aside>';
    }

    return $block_content;
}, 10, 2 );

Two details matter. First, $block['innerBlocks'] is the parsed child tree, so you are inspecting structure rather than regexing HTML. Second, returning a fallback rather than throwing keeps a single malformed post from taking down a page; if you would rather fail loudly in staging, log the violation and let the fallback stand in production.

If the invariant is about attributes rather than children — say a variant attribute that must be one of a fixed set — inspect $block['attrs'] the same way. The block attributes reference documents how attributes are declared and where their values are sourced from, which is what makes $block['attrs'] reliable to read at render time.5

Why not enforce at save time?

Save-time enforcement means rejecting or rewriting post_content on write. It sounds stronger and is usually worse. It has to cover every write path — REST, WP-CLI, imports, programmatic inserts — and it changes stored data, which means a later change to your rules leaves old posts in a state you have to migrate. Render-time enforcement covers every read path with one filter and leaves the database alone. The tradeoff is that the database can hold content your renderer will not display; that is a deliberate choice, and it is the same choice core makes when it lets render_block alter output without touching storage.3

Do not try to enforce structure through blocks.getSaveContent.extraProps or similar editor-side save filters. The handbook warns that modifying existing content through those filters causes block validation errors the next time the post is edited, and points to render_block as the correct place for server-side modification.3

Verification

You cannot verify a render-time guard by editing in the UI, because the UI is the surface you are no longer trusting. Verify by writing content the editor would not have produced.

First, confirm the stored tree for a suspect post. This query shows the raw serialized content; look for the acme/callout delimiters and count the child block comments between them.

wp db query "SELECT ID, post_title, post_content
FROM wp_posts
WHERE post_content LIKE '%wp:acme/callout%'
AND post_status = 'publish'
LIMIT 20;"

Second, create a violation deliberately. Take a post that renders correctly, copy its post_content, remove the inner paragraph block comments, and write it back with WP-CLI. This simulates any write path that bypassed the editor.

wp post update 123 --post_content="$(cat broken-callout.txt)"

Third, load the post on the front end. With the filter active you should see the fallback wrapper and no stored children. With the filter removed you should see the malformed output. That difference is the proof that enforcement lives at render time and not in the editor.

Fourth, if you want to test the capability hypothesis directly, create a user with edit_theme_options and without edit_posts on the target post type, open a locked post as that user, and observe whether the lock affordance is present and whether the block tree can be changed. Record what you see. Do not assume the answer from the capability name; the documentation does not settle it.

What to keep in the editor

None of this argues for removing templateLock. It argues for knowing what it is for. Locks keep ordinary editing sessions inside the shape you designed, which is most of the value. The render filter is the backstop for the sessions the lock does not cover. If you are still bringing a new install up and deciding which failures to chase first, the ordering in what to fix first when a new WordPress site says nothing found is a reasonable companion for the earlier stage of that work.

The rule of thumb: if a structural property is merely nice for editors, keep it in the editor. If it is a contract your front end depends on, assert it in render_block and test it by writing content the editor would never have allowed.

FAQ

Does templateLock get enforced when a post is saved through the REST API?

The retrieved documentation does not describe any server-side enforcement of templateLock on save. The locking pages describe editor behavior, and the parser stores whatever delimiters and attributes are present.12 Treat REST writes as a path that can produce content the lock did not shape.

Can I just check current_user_can( 'edit_theme_options' ) and tighten the lock for those users?

You can check the capability, but the documentation does not establish that the editor consults it before honoring a lock, so a check you add is your own policy, not a fix for a documented bypass. A render-time assertion covers every writer regardless of capability, which is why it is the more durable layer.

Will a render filter break block validation in the editor?

No. render_block “has no impact on the behavior of blocks in the Editor.”3 Validation compares stored markup to the block’s save() output; a front-end filter is downstream of both.

What about contentOnly locks?

contentOnly is a lock value with its own semantics — it restricts editing to content attributes and, unlike the other lock types, is not overridable by children.1 It is still an editor-side constraint. If the content attributes it protects feed a front-end contract, assert that contract at render time too.

Sources

  1. Block Editor Handbook — Block Templates (template as default editor state; template_lock values; lock inheritance).
  2. WordPress Reference — WP_Block_Parser_Block (attributes from block comment delimiters stored in parsed structure).
  3. Block Editor Handbook — Block Filters (render_block and render_block_{namespace/block}; callback arguments; no editor impact; save-filter validation warning).
  4. WordPress Reference — current_user_can() (capability check semantics).
  5. Block Editor Handbook — Attributes (attribute declaration and value sources).

The Small Lowercase ‘meh.’ Shirt Is a Typographic Shrug, Not a Personality

The native garment preview shows a dark shirt with very small, centered lowercase lettering: meh. No animal illustration. No pride variant. No slogan in a bold display face. The period is there, and the scale is the argument. This is not a shirt that announces a mood; it is a shirt that files one.

That distinction matters because the retail title stacks three adjectives — funny, sarcastic, introvert — onto a design that visually refuses all three. The printed artwork is a single word in a quiet face, set small and centered. The title is metadata; the shirt is typography. If you are buying the meh shirt expecting a punchline across the chest, the preview will correct you. The joke, such as it is, lives in the gap between how little ink is used and how much the word implies.

Scale as the load-bearing element

Most text shirts fail because the type is sized to be read across a room. That is a billboard contract: the wearer agrees to be a moving sign. This design does the opposite. The lettering is small enough that a reader has to be within conversational distance to resolve it, which means the shirt only performs for people already close enough to warrant a response. The period does the rest. A bare meh is a sound; meh. is a verdict, closed and filed.

I read the lowercase as deliberate understatement rather than laziness. Lowercase removes the emphasis that capitals would add, and the small point size removes the emphasis that scale would add. What remains is a word that has already given up on being impressive. That is a coherent design decision, and it is the only thing the preview actually proves. Whether it reads as sarcastic, introverted, or merely tired depends on the wearer and the room, not on the garment.

The title is doing work the shirt refuses

“Funny Sarcastic Introvert Minimalist Saying” is a search-friendly description, not a printed claim. The word minimalist is the only one the artwork supports on its own. Funny and sarcastic are audience attributions — they describe how a buyer might use the shirt, not what the ink says. Introvert is the most speculative of the three: a small word on a dark shirt does not encode a social preference, and treating it as a personality label is a reading, not a fact.

This is the same category error I see in software when a function name promises behavior the implementation never delivers. The name is a contract; the body is the truth. Here the title is the name and the preview is the body. The body says: one word, small, lowercase, period. Everything else is marketing metadata that the garment does not have to honor.

What a terse message can and cannot do

There is a real use case for clothing that communicates a boundary without a speech. A small meh. functions as a low-stakes signal: not hostile, not enthusiastic, not available for a longer conversation. It is the visual equivalent of a one-line reply that closes a thread. For technical readers who spend the day writing terse commit messages and terse status updates, the appeal is legible — it is the same economy of language applied to a social interaction.

But I want to be precise about what the shirt does not do. It does not improve anyone’s mood, cure introversion, or guarantee that strangers respect the signal. A printed word is not an API; it has no enforcement mechanism. The most honest claim is that the design is internally consistent: the scale, the case, and the punctuation all point in the same direction. That consistency is the craft. The rest is interpretation.

Reading the object without overreading it

The preview establishes the object: small centered lowercase meh. on a dark shirt, no animal, no variant. That is enough to judge the design on its own terms. The word is set quietly, the period is present, and the composition is centered rather than offset. If you want a shirt that says a lot with very little, this is a coherent example. If you want a shirt that makes a joke at distance, this is not it — and the preview tells you so before you buy.

The useful argument is not that the shirt is funny or that it suits introverts. It is that the design’s restraint is the point, and the retail title is a separate layer that should not be mistaken for the artwork. Read the preview, not the adjectives. The preview is the contract; the adjectives are the pitch.

Editorial note: This publication and Quirky Cotton Collective share an owner; this is not an independent review.

The ‘I Wear This Shirt Periodically’ Shirt Is a Diagram, Not a Sentence: Reading the Element Tiles

The shirt does not spell the sentence. It diagrams it. Across the chest, four periodic-table-style tiles sit in a row: 53 I, 74 W, 22 Ti, 16 S. Above or beside them, the retail wording reads I WEAR THIS SHIRT PERIODICALLY. The tiles themselves give you I, W, Ti, S. That is not a literal transcription of the phrase. It is a compressed notation that asks you to do the reading.

That gap between the printed tiles and the sentence is the whole design. Most slogan shirts hand you a finished sentence and ask only that you agree or laugh. This one hands you a small parsing problem: four element symbols, four atomic numbers, one pun. The joke is not that chemistry is hard. The joke is that the sentence has been routed through a notation system that was never meant to carry it, and the result is legible only if you know how to read the table.

What the tiles actually say

Read literally, the four tiles are:

  • 53 I — iodine
  • 74 W — tungsten, from Wolfram
  • 22 Ti — titanium
  • 16 S — sulfur

String the symbols together and you get I W Ti S. Say it aloud and it collapses toward “I wear this” — not exactly, but close enough that the eye completes the phrase. The final S is doing double duty: it is the sulfur tile and the last letter of the sentence. The Ti tile is the hinge. It is the one place where the notation refuses to be a single letter, and that refusal is what makes the pun work. A shirt that printed I W T S would be a rebus with a hole in it. The Ti fills the hole.

This is worth stating plainly because retail descriptions often smooth the design into “periodic table shirt” and move on. The specific arrangement matters. The tiles are not decorative filler around a slogan. They are the slogan, compressed into a notation that only resolves when you read the symbols as sounds rather than as element names.

Why the compression is the joke

There is a particular pleasure in a design that withholds its own punchline by exactly one step. The shirt does not say “I wear this shirt periodically” in plain English and then add element tiles as garnish. It makes the tiles carry the sentence. That means the wearer is not just displaying a fact about their wardrobe. They are displaying a small act of translation.

I think that is why the design reads as a diagram rather than a slogan. A slogan is a closed statement. A diagram is an invitation to trace. The four tiles are a trace: atomic number, symbol, atomic number, symbol. The sentence is the output. The pun is the fact that the output is also a frequency claim — periodically — which the periodic table happens to literalize. The shirt is not making a chemistry argument. It is making a notation argument, and the notation happens to be chemistry-shaped.

That distinction matters for anyone who spends their working life reading systems that compress meaning into symbols. A configuration file, a schema, a query plan: each is a notation that only resolves if you know the grammar. The shirt is a low-stakes version of the same cognitive move. You see four tiles. You do not see a sentence until you read them correctly. The pleasure is in the resolution, not in the initial glance.

The difference between a slogan and a usable promise

There is a broader point here about terse messages and what they can carry. A slogan asserts. A usable promise specifies. “I wear this shirt periodically” is a joke about frequency, not a commitment to a schedule. The shirt does not tell you when. It does not tell you how often. It tells you that the wearing is periodic, and then it makes you read the word periodically through a table that organizes elements by period. The promise is deliberately underspecified. That is what makes it funny rather than pedantic.

In work culture, the same distinction shows up constantly. A team that says “we ship regularly” has made a slogan. A team that says “we deploy on Tuesdays and Thursdays, and here is the rollback procedure” has made a usable promise. The shirt sits on the slogan side, but it is a good slogan because it knows it is a slogan. It does not pretend to be a specification. It compresses, and it lets the compression be the point.

That is a useful thing to notice about boundary wording, too. The recent “Not Friendly Do Not Touch” shirt made a boundary into a printed sign. This shirt makes a frequency claim into a notation puzzle. Both are terse. Both rely on the reader to complete the message. The difference is that the boundary shirt wants to be read instantly, as a warning, while this one wants to be read slowly, as a joke. Terse messages are not all the same kind of object. Some are doors. Some are diagrams.

Reading the object without overclaiming it

I am not going to tell you the shirt is comfortable, or that it fits well, or that it survives a wash cycle. I have not worn it. What I can read is the printed artwork: four tiles, four atomic numbers, four symbols, one sentence that resolves only when you sound out the symbols. That is the object. The rest is retail.

The design is available as a standard t-shirt from Quirky Cotton Collective. If you are the kind of reader who enjoys a notation that withholds its own punchline, the shirt is a small, wearable version of that pleasure. If you want a shirt that states its message outright, this is not that shirt. It is a shirt that asks you to read the tiles first.

Editorial note: This publication and Quirky Cotton Collective share an owner.

The ‘Not Friendly Do Not Touch’ Shirt Is a Boundary, Not a Bug Report

The shirt reads NOT FRIENDLY DO NOT TOUCH in warning-sign lettering, with a yellow caution triangle and a crossed-out hand symbol. That is the whole design. No punchline, no mascot, no secondary caption. It borrows the visual grammar of industrial signage — the triangle, the imperative, the prohibition mark — and applies it to a person.

I want to argue something specific about that move: the design works because it is a boundary statement, not a personality claim. The distinction matters, and it is the same distinction that separates a usable system contract from a slogan taped to a monitor. A slogan describes a mood. A boundary describes an interface.

What the sign language actually says

Look at the three elements separately. The caution triangle is a hazard marker: it says something here can hurt you, not something here is broken. The crossed-out hand is a prohibition glyph: it says do not initiate contact, not contact is impossible. The lettering is imperative and unhedged: DO NOT TOUCH, not please avoid touching.

Read together, the design is not claiming the wearer is hostile. It is declaring a contact policy. That is a narrower and more defensible claim than “I am unfriendly,” which is how the phrase is usually paraphrased. The shirt says: the default state is closed to unsolicited input. It does not say the interface has no endpoints.

That reading is my interpretation of the printed artwork, not a historical claim about warning-sign conventions or a statement about introversion as a category. I am reading the graphic the way I would read any other interface label: what does it promise, what does it prohibit, and what does it leave unspecified?

The difference between a slogan and a usable promise

In systems work, a slogan is a sentence that sounds like a guarantee and behaves like a vibe. “Production-ready.” “Secure by default.” “Zero-config.” None of them tell you what happens at the boundary. A usable promise does: it names the input, the condition, and the outcome. POST /orders returns 201 or 422. That is a contract. “We care about reliability” is not.

The shirt is interesting because it is closer to the contract end of that spectrum than most slogan apparel. It names the input (touch), the condition (unsolicited), and the outcome (prohibited). It is terse, but it is not vague. The crossed-out hand is doing the work a status code does: it tells you what the response will be before you send the request.

What it deliberately leaves unspecified is the exception path. There is no “unless” clause, no allowlist, no “friends and family” carve-out printed anywhere. That is not a flaw in the design; it is the point. The sign is the default policy, and defaults are supposed to be legible without a meeting.

Why the caution triangle is the right glyph and the wrong metaphor

Here is where I want to be careful, because it would be easy to overreach. The caution triangle is a hazard symbol, and it is tempting to read the shirt as saying the wearer is dangerous. I do not think the artwork supports that. The triangle marks the zone, not the person. It is the same grammar as a wet-floor sign: the hazard is the condition, not the janitor.

That is a meaningful distinction for anyone who has watched a team confuse a boundary with an insult. “Do not page me after 7pm” is not a claim that the on-call engineer is unfriendly. It is a statement about the interface between work and rest. The shirt makes the same move in fabric: it marks the zone and states the rule, and it declines to apologize for either.

I would not claim the design demonstrates anything about introversion, social psychology, or workplace dynamics. It is a graphic. But as a piece of visual rhetoric, it is unusually disciplined: three elements, one message, no hedging.

The terse message as a design constraint

There is a craft argument here that technical readers will recognize. Terse messages are hard. A status line, a commit subject, a runbook heading — each has to carry the full meaning in the fewest tokens, because the reader is mid-task and will not read a paragraph. The shirt’s lettering is doing that job. NOT FRIENDLY sets the state. DO NOT TOUCH sets the rule. The glyphs carry the rest.

Most slogan shirts fail this test because they try to be funny and clear at the same time and end up neither. This one is funny because it is clear. The joke is the mismatch between industrial signage and a human torso, and the mismatch only lands if the sign is read literally first. That is a real constraint, and the design respects it.

If you want the object itself, it is the Not Friendly Do Not Touch warning sign shirt from Quirky Cotton Collective, and the listing matches the artwork described above: the caution triangle, the crossed-out hand, the blunt imperative.

What the shirt is not

It is not a security control. It is not an access-management policy. It is not evidence that the wearer has any particular temperament, and it does not cure or improve anything. It is a printed boundary, worn in public, in the visual language of a hazard sign. That is a modest thing to be, and it is enough.

The useful argument is this: the design earns its place by being a contract rather than a slogan. It states a default, names the prohibited input, and leaves the exception path unstated on purpose. Most apparel copy does the opposite — it gestures at a personality and leaves the reader to guess the rule. This one guesses nothing. It tells you where the zone ends and what happens if you cross it, and then it stops.

That is a good discipline for a shirt. It is also a good discipline for a system. The sign is legible because someone decided what it would not say.

Editorial note: this publication and Quirky Cotton Collective share an owner.

A Revisions-Count REST Field Without the N+1 Tax

A Revisions-Count REST Field Without the N+1 Tax

Symptom. A custom revision_count field appears in /wp-json/wp/v2/posts. The response is correct. Query Monitor, however, shows one SELECT COUNT(*) against wp_posts per post in the collection — ten posts, ten counts; a hundred posts, a hundred counts. The endpoint is not slow enough to page anyone at 3 a.m., which is precisely why it survives until the archive page starts rendering a hundred rows.

Trace. The field was registered with register_rest_field() and a get_callback that calls a counting function. The callback contract is the problem: register_rest_field() documents that the get callback “will be passed the prepared object data” — one prepared post at a time. The REST server invokes that callback once for each item it serializes. A query inside the callback therefore runs once per item. That is the N+1 shape, and no amount of cleverness inside the callback removes it, because the callback never sees the collection.

Root cause. Per-item work placed in a per-item hook. The fix is not a faster count; it is moving the count out of the callback and into a batched step that runs once per request, keyed on the IDs the request is about to serialize. The callback then reads from a prebuilt map.

What register_rest_field() actually gives you

The function registers a field on an existing object type and accepts three arguments that matter here: get_callback, update_callback, and schema. The documentation is explicit that the get callback receives the prepared object data, and that omitting update_callback makes the field read-only. It also notes that fields should be registered on rest_api_init, not init, so registration does not run on requests that never touch the REST API.

That is the whole surface. There is no collection-level callback, no “before serialize” hook in the registration arguments, and no batch parameter. If you want batching, you supply it yourself.

The naive field, and its query shape

Here is the version that produces the symptom. It is deliberately ordinary.

add_action( 'rest_api_init', function () {
    register_rest_field(
        'post',
        'revision_count',
        array(
            'get_callback' => function ( $post_arr ) {
                global $wpdb;
                return (int) $wpdb->get_var(
                    $wpdb->prepare(
                        "SELECT COUNT(*) FROM {$wpdb->posts}
                         WHERE post_parent = %d
                           AND post_type = 'revision'
                           AND post_status = 'inherit'",
                        $post_arr['id']
                    )
                );
            },
            'schema' => array(
                'description' => 'Number of stored revisions for the post.',
                'type'        => 'integer',
                'context'     => array( 'view', 'edit' ),
                'readonly'    => true,
            ),
        )
    );
} );

Two things are correct here and should stay: the value is cast to an integer, and the post ID goes through $wpdb->prepare() with a %d placeholder. The wpdb documentation is unambiguous that all untrusted values in a SQL statement must be escaped, and that prepare() accepts %s, %d, and %f placeholders. The post ID is not attacker-controlled in most requests, but “most” is not a security posture.

What is wrong is the placement. The callback is invoked per item, so the query is per item. The count itself is cheap on a small site and linear on a large one; the cost is not the individual COUNT, it is the round trips multiplied by page size.

The batching move

The collection’s post IDs are known before the per-item callbacks run. The REST posts controller builds a WP_Query, and the resulting post objects are what get prepared and serialized. If you can observe the query results once, you can compute a map of post_id => revision_count in a single grouped query, stash it in a request-scoped variable, and have the per-item callback read from that map.

The hook that makes this practical is the_posts, which fires after a WP_Query has fetched its posts. It receives the array of posts and the query object. You can inspect the query to decide whether it is a REST posts collection, collect the IDs, run one grouped query, and store the result.

/**
 * Request-scoped map of post ID => revision count.
 *
 * @var array<int,int>
 */
function jooom_revision_count_map( ?array $set = null ) {
    static $map = array();
    if ( null !== $set ) {
        $map = $set;
    }
    return $map;
}

add_action( 'the_posts', function ( $posts, $query ) {
    if ( ! $query->get( 'rest_route' ) && ! ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) {
        return;
    }
    if ( empty( $posts ) ) {
        return;
    }

    $ids = array();
    foreach ( $posts as $post ) {
        if ( $post instanceof WP_Post && 'revision' !== $post->post_type ) {
            $ids[] = (int) $post->ID;
        }
    }
    if ( empty( $ids ) ) {
        return;
    }

    global $wpdb;
    $placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) );
    $sql = $wpdb->prepare(
        "SELECT post_parent, COUNT(*) AS revision_count
         FROM {$wpdb->posts}
         WHERE post_parent IN ($placeholders)
           AND post_type = 'revision'
           AND post_status = 'inherit'
         GROUP BY post_parent",
        $ids
    );

    $rows = $wpdb->get_results( $sql, ARRAY_A );
    $map  = array_fill_keys( $ids, 0 );
    foreach ( $rows as $row ) {
        $map[ (int) $row['post_parent'] ] = (int) $row['revision_count'];
    }

    jooom_revision_count_map( $map );
}, 10, 2 );

Then the field callback becomes a lookup:

'get_callback' => function ( $post_arr ) {
    $map = jooom_revision_count_map();
    $id  = (int) $post_arr['id'];
    return isset( $map[ $id ] ) ? $map[ $id ] : 0;
},

Three details keep this honest. First, the map is initialized with zero for every requested ID, so posts with no revisions return 0 rather than null or a missing key. Second, the grouped query is driven by the collection’s IDs, so its cost scales with page size, not with the size of the posts table. Third, the IN list is built from placeholders and passed through prepare(); the wpdb documentation warns that % characters inside SQL string literals must be double-escaped, which is why the query avoids LIKE entirely.

One caveat about the hook: the_posts fires for every WP_Query, including queries that have nothing to do with the REST request. The guard above checks REST_REQUEST or the rest_route query var, but a plugin that runs its own WP_Query during a REST request will also trigger the batch. That is wasted work, not incorrect data, because the map is keyed by ID and the callback only reads IDs it asked for. If you want to be stricter, check $query->get( 'post_type' ) against the post types whose REST controllers you registered the field on.

Why not just call a core helper?

There is a natural instinct to reach for a core function that counts revisions. The retrieved sources for this article do not include a fetched page documenting such a helper’s signature, so this article does not assert one exists or recommend calling it. The batched query above is presented as an illustrative shape, not as a claim about how core implements revision counting internally. If you do find a core helper that accepts a post ID and returns a count, it will still be a per-post call, and the N+1 problem returns. The batching is the point, not the specific SQL.

The revisions-specific wrinkle

Revisions are posts. The core revisions controller queries them with post_parent set to the parent ID, post_type set to 'revision', and post_status set to 'inherit', and it only does so after checking wp_revisions_enabled( $parent ). It also exposes the total revision count in the X-WP-Total response header for the revisions collection endpoint.

That header is useful context but not a substitute for the field. It belongs to a different request — /wp/v2/posts/<id>/revisions — and reading it would require a second HTTP round trip per post, which is a worse N+1 than the one you started with. The custom field exists so a collection response can carry the count inline. Do not try to reuse the revisions controller’s internals; treat it as a separate endpoint with its own contract.

There is also a correctness question the field should answer explicitly: does “revision count” include autosaves? Autosaves are stored as revisions with a post_name containing -autosave, and core’s save routine skips them when pruning. The query above counts every row with post_type = 'revision' and post_status = 'inherit', which includes autosaves. If your editorial workflow treats autosaves as noise, add AND post_name NOT LIKE '%%-autosave%%' — note the doubled percent signs required by prepare() — and document the choice in the field description. Either answer is defensible; an undocumented one is not.

Schema and read-only discipline

The schema should say what the field is: an integer, described, and read-only. Omitting update_callback is what makes it read-only, per the REST API handbook. A revision count is derived data; accepting writes to it would invite clients to set a number that core’s revision system will immediately contradict.

The handbook also draws a line worth repeating: changing or removing data from core REST responses can break plugins or core behavior and should be avoided, while adding fields is described as not dangerous. A new revision_count field is an addition. Do not “improve” it by filtering rest_prepare_post to rewrite an existing core field.

Verification

Query Monitor is the instrument. Load /wp-json/wp/v2/posts?per_page=20 with the plugin active and filter the queries panel to wp_posts. Before the fix you should see one SELECT COUNT(*) per post, all with the same shape and different post_parent values. After the fix you should see a single grouped query with an IN (...) list and a GROUP BY post_parent, plus the normal posts query. The count of COUNT(*) queries should be one, not twenty.

From the command line, the same check without a browser:

wp eval '
$request = new WP_REST_Request( "GET", "/wp/v2/posts" );
$request->set_param( "per_page", 20 );
$response = rest_do_request( $request );
$data = $response->get_data();
foreach ( $data as $post ) {
    echo $post["id"] . " => " . $post["revision_count"] . PHP_EOL;
}
'

Run it with and without the batching hook and compare the query log. If your site has no revisions on the sampled posts, seed a few with wp post update <id> --post_content="..." before measuring, or the test proves only that zero equals zero.

Finally, confirm the field is absent from non-REST requests. Because registration is on rest_api_init, a normal page load should not run the registration callback at all. If it does, something is calling register_rest_field() on init, and that is a separate bug worth fixing while you are in the file.

Checklist

  • Register on rest_api_init, not init.
  • Keep the per-item callback free of queries; it reads from a request-scoped map.
  • Build the map with one grouped query keyed on the collection’s IDs.
  • Use $wpdb->prepare() with placeholders for every value, including the IN list.
  • Declare the field as an integer with a description and no update_callback.
  • Decide explicitly whether autosaves count, and say so in the schema description.
  • Verify with Query Monitor or wp eval that the number of COUNT queries equals one, not the page size.

FAQ

Does register_rest_field() have a batch callback?

No. The documented arguments are get_callback, update_callback, and schema. The get callback receives one prepared object at a time. Batching has to be supplied by the surrounding code.

Can I cache the counts in a transient instead?

You can, but a revision count changes on every save, so the transient would need invalidation on every revision creation and deletion. A request-scoped map avoids that problem entirely: it is computed once per request from current data and discarded. Persistent caching is a different optimization with a different failure mode — stale counts — and should be a deliberate choice, not a default.

Why not query the revisions endpoint and read X-WP-Total?

Because that is one HTTP request per post, which is a worse version of the same N+1. The header is useful when you are already fetching a revisions collection; it is not a substitute for an inline field on the posts collection.

Is the the_posts hook the only place to batch this?

No. It is a convenient place because it sees the fetched posts before serialization. A custom rest_{$post_type}_query filter or a controller-level preparation step could also work, but those are more invasive and more likely to collide with other plugins. The principle is the same: compute once per request, read per item.

What if the field is registered on a custom post type?

The registration and the query are unchanged; only the object type string passed to register_rest_field() changes. The guard in the batching hook should be widened to include that post type, or the map will be empty and every post will report zero.