The Composition Notebook Shirt Is a Printed Cover, Not a Costume: Reading the Speckled Rectangle

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

The shirt does not show a notebook. It shows a printed notebook cover: a black-and-white speckled rectangle, a dark binding strip down the left edge, and a white ruled label headed COMPOSITION with an outlined ghost and pumpkin inside it. That distinction is the whole design. The garment is not a costume that happens to have a school motif; it is a flat, legible reproduction of a familiar writing surface, transferred onto fabric and worn as a group signal.

Most teacher Halloween shirts reach for a slogan — Teaching Is My Boo, Witch Better Have My Coffee — and let the type do the work. This one withholds the sentence. The only words are the label heading, and the label is not a joke. It is a piece of stationery furniture. The ghost and pumpkin sit inside the ruled lines where a student’s name would go. The design’s argument is structural: the notebook cover is the costume, and the wearer is the page.

What the rectangle actually contains

Look at the observed artwork in order. The outer field is a speckled black-and-white ground — the marbled waste-paper texture that composition books have carried for decades. A dark vertical strip runs along the left side, reading as the taped binding or spine. Inside the field, a white rectangle with thin horizontal rules sits near the top, headed by the word COMPOSITION in a plain, slightly condensed face. Below the heading, inside the ruled area, an outlined ghost and an outlined pumpkin are drawn in line, not filled. The ghost is the rounded sheet-with-eyes shape; the pumpkin is a ribbed circle with a stem. Neither is rendered in color. The whole design is essentially two-tone: dark ink on a light speckled ground, with the label as the only high-contrast white block.

That is a specific visual grammar, and it matters for how the shirt reads at a distance. From across a hallway, the speckle and the white label are the recognizable parts. The ghost and pumpkin are small-scale details that resolve only at conversation distance. The design is therefore doing two jobs at once: it signals school supply at ten feet and Halloween at three. A slogan shirt usually inverts that ratio — the words carry across the room, and the graphic is secondary. Here the graphic carries the room and the words are almost incidental.

The costume is a category, not a garment

The retail title calls this a costume, and the phrase teacher group Halloween appears in the same string. That wording is doing more work than it first appears. A group costume is not a single object; it is a coordination protocol. Everyone in the group has to agree on the same visual token, and the token has to be recognizable without a caption. The composition notebook cover is a good token for exactly the reason the design withholds a slogan: it is a shape people already know. You do not have to read it to recognize it. The ghost and pumpkin are the seasonal flag; the speckled rectangle is the shared identifier.

This is where the shirt differs from a generic teacher Halloween tee. A slogan shirt individualizes the wearer — the joke is your joke, and the group is implied by everyone wearing a different variant. A printed cover individualizes no one. It is closer to a uniform patch: the same rectangle on every torso, with the only variation coming from the garment color underneath and the wearer’s own proportions. That is a deliberate trade. You give up the punchline and gain a visual token that works in a row of people.

Why the label is the load-bearing element

The white ruled label is the part of the design that makes the rest legible. Without it, the speckled rectangle is just texture. With it, the rectangle becomes a composition book — a specific object with a specific history in American classrooms. The word COMPOSITION is not decorative; it is the caption that converts an abstract pattern into a recognizable artifact. The ghost and pumpkin then occupy the label’s ruled space, which is normally reserved for a name, a subject, or a date. Putting Halloween figures there is a small substitution joke: the page has been claimed by the holiday.

I read that substitution as the design’s only real wit. The rest is faithful reproduction. The binding strip, the speckle, the rules, the heading — all of it is doing documentary work. The ghost and pumpkin are the one deviation, and they are drawn in outline rather than filled, which keeps them subordinate to the label’s typography. A filled ghost would compete with the word COMPOSITION; an outlined one sits inside the rules like a pencil sketch. The hierarchy is deliberate: label first, holiday second, texture third.

What the shirt is not

It is not a physical notebook, and it does not pretend to be one. The retail observation is explicit: the teacher Halloween costume is a printed notebook cover, not a physical notebook. That sounds like a trivial clarification, but it matters for how you read the object. A shirt printed to look like a notebook is a representation of a school supply, not a wearable supply. The design’s fidelity is visual, not functional. You are not carrying the book; you are quoting its cover.

It is also not a claim about teaching, curriculum, or classroom outcomes. The artwork contains no slogan about teachers, no promise about learning, and no institutional mark. The only text is the label heading. Any reading of the shirt as a statement about education is an inference the design does not make on its own. What it makes is a visual rhyme: the most ordinary object in a classroom, dressed for October.

The group-costume problem, and why this design solves it

Group costumes fail in predictable ways. The token is too obscure, so no one recognizes it without an explanation. The token is too generic, so it reads as a random Halloween shirt rather than a coordinated set. Or the token depends on a caption, which means the group has to keep explaining itself all night. The composition notebook cover avoids all three. It is widely recognized, it is specific enough to read as a deliberate choice, and it needs no caption because the label supplies the caption in print.

The trade-off is that the design is quiet. In a dim room, the speckle and the white label still read, but the ghost and pumpkin may not. That is not a flaw so much as a calibration: the shirt is built for daylight, hallways, and group photos, not for a dark bar. If you want a costume that announces itself across a crowded room, this is not that object. If you want one that reads as a shared visual token among people standing near each other, the printed cover is better suited than a slogan because it does not depend on anyone reading a sentence.

How to read the object before you buy it

The useful question is not is this cute but what does the design ask the viewer to do. This one asks the viewer to recognize a stationery format and then notice the seasonal substitution inside the label. That is a two-step read, and it rewards proximity. A slogan shirt is a one-step read: the words land, the joke is over. The printed cover is slower, which is why it works better as a group token than as an individual statement.

If you are coordinating a teacher group costume, the practical test is whether the token survives without explanation. Show the shirt to someone outside the group and ask what object it represents. If they say composition notebook, the design is doing its job. If they say speckled shirt with a ghost, the label is not carrying enough weight at the distance you need. That is a legibility question, not a quality judgment, and it is the one that matters for a group.

The shirt is available as a standard t-shirt through the listing: Composition Notebook Costume Teacher Group Halloween. The design is the argument. Read the label first, then the ghost, then the speckle — and notice that the order is the same one the artwork uses.

Injecting a Class into core/group Based on a Child Heading Level, Using render_block_data

The symptom is familiar: a publishing team wants one group of blocks to look different because it opens with an h2, and another group to look different because it opens with an h3. They do not want to edit every post, and they do not want an editor-side convention that contributors will forget. They want the server to decide at render time.

The obvious move is to filter render_block and run a regular expression over the HTML. That works until it doesn’t: the regex matches a heading inside a nested group, or inside a column, or inside a block that renders its own heading from a template, and the class lands on the wrong wrapper. The less obvious move — filtering render_block_data and inspecting the parsed block tree — is more precise, provided you understand what is and is not in that tree when the filter runs.

Where render_block_data sits in the pipeline

WordPress documents render_block_data as filtering “the block being rendered in render_block(), before it’s processed” (developer.wordpress.org). The hook was introduced in 5.1.0; the $parent_block argument was added in 5.9.0. Its signature is:

apply_filters( 'render_block_data', array $parsed_block, array $source_block, WP_Block|null $parent_block )

The core function render_block() applies this filter to the parsed block, then constructs a WP_Block and calls render() on it. The source of render_block() shows the order plainly: $parsed_block = apply_filters( 'render_block_data', $parsed_block, $source_block, $parent_block ); comes before new WP_Block( $parsed_block, $context ) and before $block->render() (developer.wordpress.org).

Inner blocks get the same treatment. WP_Block::render() iterates over inner_content, and for each inner block it applies render_block_data to that inner block’s parsed data before rendering it (developer.wordpress.org). So the filter fires for the parent and for each descendant, with $parent_block set when the block is nested.

This timing is the whole reason the technique works. By the time render_block fires, you have a string of HTML and must reverse-engineer structure from markup. By contrast, render_block_data hands you the parsed block array — blockName, attrs, innerBlocks, innerHTML, innerContent — as documented on the hook reference. The heading-level check becomes a structural test on arrays, not a string search.

The shape of the parsed block

The hook reference lists the keys explicitly: blockName (string or null), attrs (array from block comment delimiters), innerBlocks (an array of arrays with the same structure), innerHTML (string from inside the delimiters), and innerContent (a list of string fragments and null markers where inner blocks were found). A user-contributed note on the same page shows a concrete example of that nesting, including a child block inside innerBlocks with its own innerHTML and innerContent.

Two consequences follow. First, innerBlocks is already parsed when the parent’s render_block_data callback runs. You do not need to call parse_blocks() yourself. Second, innerBlocks contains parsed arrays, not rendered HTML. If you want to know whether a group contains a heading at a given level, you inspect the child’s blockName and attrs, not the child’s output.

For core/heading, the level is stored in the level attribute. The core blocks reference lists level among the Heading block’s attributes (developer.wordpress.org). In parsed data it appears as $child['attrs']['level']. Treat a missing level as “not the target level” unless you have verified your site’s saved content always includes it; the safe default is to require an explicit match.

A minimal callback

The following is a complete, runnable filter. It targets core/group, scans the group’s direct innerBlocks for a core/heading whose level equals a chosen integer, and appends a class to the group’s className attribute. It does not recurse; the limitation is discussed below.

<?php
/**
 * Add a class to core/group when it directly contains a core/heading
 * at a specific level.
 *
 * @param array $parsed_block The parsed block being rendered.
 * @return array Modified parsed block.
 */
function jv_group_class_for_heading_level( $parsed_block ) {
    if ( empty( $parsed_block['blockName'] ) || 'core/group' !== $parsed_block['blockName'] ) {
        return $parsed_block;
    }

    $target_level = 3;
    $class        = 'has-h3-lead';
    $found        = false;

    if ( ! empty( $parsed_block['innerBlocks'] ) && is_array( $parsed_block['innerBlocks'] ) ) {
        foreach ( $parsed_block['innerBlocks'] as $inner ) {
            if ( empty( $inner['blockName'] ) || 'core/heading' !== $inner['blockName'] ) {
                continue;
            }
            if ( isset( $inner['attrs']['level'] ) && (int) $inner['attrs']['level'] === $target_level ) {
                $found = true;
                break;
            }
        }
    }

    if ( ! $found ) {
        return $parsed_block;
    }

    if ( empty( $parsed_block['attrs'] ) || ! is_array( $parsed_block['attrs'] ) ) {
        $parsed_block['attrs'] = array();
    }

    $existing = isset( $parsed_block['attrs']['className'] ) ? (string) $parsed_block['attrs']['className'] : '';
    $classes  = preg_split( '/\s+/', trim( $existing ) );
    $classes  = array_filter( $classes );

    if ( ! in_array( $class, $classes, true ) ) {
        $classes[] = $class;
    }

    $parsed_block['attrs']['className'] = implode( ' ', $classes );

    return $parsed_block;
}
add_filter( 'render_block_data', 'jv_group_class_for_heading_level' );

Why className rather than editing innerHTML or innerContent? Because className is a block attribute consumed by normal block rendering. The hook reference’s own user-contributed example sets $parsed_block['attrs']['className'] and returns the modified array, which is the same pattern used here. Mutating innerHTML would bypass the attribute system and risk producing markup that disagrees with what the block’s save function expects.

Why not render_block?

render_block filters the content of a single block and receives $block_content, $block, and $instance (developer.wordpress.org). It is the documented tool for post-render HTML changes. The Block Filters guide notes that render_block “has no impact on the behavior of blocks in the Editor” and demonstrates adding a class to Paragraph blocks with WP_HTML_Tag_Processor rather than regex (developer.wordpress.org).

For this problem, render_block is the wrong layer. You would have to parse the group’s rendered HTML to find a heading and read its tag name. That is possible with the HTML API, but it is more work and more fragile than reading innerBlocks before rendering. Use render_block_data when the decision depends on block structure; use render_block when the decision depends on final markup that only exists after rendering.

Failure modes to test

The minimal callback above is correct for its stated scope. It is not a general solution. Test these cases before trusting it in production.

Groups with no heading

The callback returns the block unchanged. No class is added. This is the intended behavior, but verify it with a group that contains only paragraphs and images.

Headings nested inside inner groups or columns

A top-level-only scan of innerBlocks will miss a heading inside a nested core/group, core/columns, or core/column. The parsed tree is recursive; the loop above is not. If your editorial pattern routinely nests groups, either recurse or accept that the class applies only to groups whose heading is a direct child.

A recursive variant is straightforward: walk innerBlocks depth-first and return true on the first matching heading. The trade-off is that a group containing a nested group with an h3 will receive the class even if its own direct heading is an h2. Decide which semantics you want before writing the recursion.

Headings whose level attribute is absent

If attrs.level is not set, the callback treats the heading as not matching. That is deliberate. Do not assume a default level; confirm against your saved content. A quick WP-CLI query can show whether any core/heading blocks in the database lack a level attribute:

wp db query "SELECT ID, post_title FROM wp_posts WHERE post_content LIKE '%wp:heading%' AND post_content NOT LIKE '%\"level\"%' AND post_status = 'publish' LIMIT 20;"

Adjust the table prefix if your install does not use wp_. If the query returns rows, those posts contain heading blocks without an explicit level, and the callback will not match them.

Dynamic blocks and innerBlocks

The hook reference describes innerBlocks as parsed from the block comment delimiters. For blocks whose content is generated at render time, the parsed array may not contain the inner structure you expect. The evidence available here does not establish a general rule for every dynamic block, so verify on your own install: log $parsed_block inside the callback for a page that uses the block in question and inspect innerBlocks before relying on it.

Debugging the callback

Do not guess the parsed structure. Log it. The following temporary addition writes the parsed block to the PHP error log when a group is encountered:

if ( 'core/group' === $parsed_block['blockName'] ) {
    error_log( print_r( $parsed_block, true ) );
}

Load the page with WP_DEBUG_LOG enabled and read wp-content/debug.log. Confirm three things: that blockName is core/group, that innerBlocks contains the heading you expect, and that the heading’s attrs.level is the integer you are matching. If the log shows the heading at a deeper level than the loop scans, that is the bug, not the filter.

Query Monitor is useful here for a different reason: it shows which hooks fired and in what order, which helps confirm that render_block_data is running on the front end and not only in some other context. It will not show you the parsed array contents; the error log does that.

What this approach does not do

It does not change the editor. The class is added during front-end rendering and is not saved to post content. Contributors will not see it in the block inspector, and the block’s saved markup will not contain it. If you need editor-visible styling, this is the wrong tool; look at editor-side filters or a block style variation instead.

It does not persist. Every page load re-evaluates the condition. That is usually desirable for a rule based on content structure, but it means the class disappears if the heading is removed, which is the point.

It does not replace render_block for cases where the decision depends on rendered HTML. The two hooks solve different problems at different stages.

Verification checklist

After deploying the callback, verify in this order:

  1. Load a page containing a group with a direct h3. Inspect the group wrapper in DevTools. The class should appear alongside the existing wp-block-group class.
  2. Load a page containing a group with a direct h2. The class should not appear.
  3. Load a page containing a group with no heading. The class should not appear.
  4. Load a page containing a group with a nested group that contains an h3. With the non-recursive callback, the outer group should not receive the class. If it does, your recursion or your scan is broader than intended.
  5. Check the error log for PHP notices from the callback. An undefined index warning on attrs or level means a guard is missing.

If step 1 fails, log $parsed_block and compare the actual structure to the assumptions above. The filter is reliable; the assumptions about the tree are where the bugs live.

Why Your Child Theme’s theme.json Overrides settings.spacing.units — and How to Read the Merged Value

The symptom is small and specific. A spacing control in the block editor offers a unit your parent theme’s theme.json never listed, or a unit the parent listed has quietly disappeared. The file on disk says one thing; the editor behaves as if it read another. Nothing in the browser console is red. Nothing in the debug log is complaining. The site is not broken, which is the annoying part.

This is not a bug in the merge. It is the merge working as designed, and the design is worth reading before you edit either file. The useful question is not “did the child theme override my units?” It almost certainly did. The useful question is which origin supplied the value I am reading, because once you know that, the fix is a one-line decision rather than a specificity escalation.

Trace: theme.json is a merge, not a file swap

WordPress does not read a single theme.json. It assembles settings from several origins and merges them in a fixed order. The theme.json specification is shared across those origins: core, themes, and users all use the same format, and themes override core defaults by shipping a theme.json, while users can override theme or core preferences through the site editor (Global Settings & Styles (theme.json)).

For a child theme, the resolver does something slightly more literal than “child wins.” When the active theme has a parent, core reads the parent’s theme.json, then merges the child’s theme.json into it. The source comment is explicit: “Merge the child theme.json into the parent theme.json. The child theme takes precedence over the parent” (WP_Theme_JSON_Resolver). So the child is not a replacement layer sitting beside the parent. It is an incoming layer applied on top.

That distinction matters because the merge rule for spacing.units is not additive. WP_Theme_JSON::merge() uses array_replace_recursive() for the general case, but then explicitly handles sequential arrays: “For leaf values that are sequential arrays it will use the numeric indexes for replacement. We rather replace the existing with the incoming value, if it exists. This is the case of spacing.units” (WP_Theme_JSON::merge). The same method then walks each setting node and, if the incoming data provides spacing.units, sets it wholesale:

// Replace the spacing.units.
$path = $node['path'];
$path[] = 'spacing';
$path[] = 'units';
$content = _wp_array_get( $incoming_data, $path, null );
if ( isset( $content ) ) {
    _wp_array_set( $this->theme_json, $path, $content );
}

So a child theme that declares settings.spacing.units is not appending to the parent’s list. It is replacing it. If the parent lists px, em, rem and the child lists rem only, the merged value is rem only. Reasoning about this as an additive override produces the wrong fix — usually adding the missing units in both files, which then produces a second, quieter disagreement later.

For reference, the documented default for settings.spacing.units is ["px","em","rem","vh","vw","%"], described as “List of units the user can use for spacing values” (Theme.json Version 3 Reference). If neither theme declares units, that default is what you are seeing. If one of them does, the incoming list wins for that node.

Inspect: read the merged value, not the file

The supported inspection tool is wp_get_global_settings(). Its own description is the whole argument: “Gets the settings resulting of merging core, theme, and user data” (wp_get_global_settings()). It takes a $path array and a $context array. The context accepts a block_name and an origin; the origin is either 'all' (core, theme, and user) or 'base' (core and theme), and defaults to 'all' when empty or unknown.

Start by dumping everything, then narrow. The function’s own user-contributed note recommends exactly this: call it with no path, var_dump the result, locate the setting, then convert the nested keys into a flat path array.

$settings = wp_get_global_settings();
var_dump( $settings['spacing']['units'] );

Then query the specific path directly:

$units = wp_get_global_settings( array( 'spacing', 'units' ) );
var_dump( $units );

That tells you what the merged value is. It does not tell you who supplied it. For that, query the same path twice with different origins:

// Core + theme only.
$base = wp_get_global_settings(
    array( 'spacing', 'units' ),
    array( 'origin' => 'base' )
);

// Core + theme + user.
$all = wp_get_global_settings(
    array( 'spacing', 'units' ),
    array( 'origin' => 'all' )
);

var_dump( $base, $all );

If $base and $all differ, a saved user layer is involved and editing either theme.json will not change what the editor offers. If they match, the value came from core plus the theme stack, and the child theme is the likely supplier. This two-query check is cheap and it prevents the most common wasted afternoon: rewriting a child theme.json while a saved global styles record quietly wins.

If you want to see the raw merged data rather than the settings slice, WP_Theme_JSON_Resolver::get_merged_data() is what wp_get_global_settings() calls internally. But the resolver class is documented as internal core API: “This class is for internal core usage and is not supposed to be used by extenders (plugins and/or themes). This is a low-level API that may need to do breaking changes. Please, use get_global_settings(), get_global_styles(), and get_global_stylesheet() instead” (WP_Theme_JSON_Resolver). Read it, do not ship a dependency on it.

Root cause: the cache is part of the merge story

There is a second reason a merged value can look wrong after a file edit, and it is not merge logic. wp_get_global_settings() caches its result in the theme_json cache group, and the source marks that group as non-persistent across requests. The cache key is built from the origin, and the function skips the cache entirely when development mode is set to theme:

$can_use_cached = ! wp_is_development_mode( 'theme' );

That line is in the function source on the reference page. The practical consequence: if you edit theme.json and re-read the merged value in the same request lifecycle without development mode on, you may be reading a cached array. The cache is non-persistent, so a fresh request usually clears it, but “usually” is not a diagnostic. If a value looks stale, confirm development mode before you conclude the merge is wrong.

There is also a filter layer above the file. The resolver applies wp_theme_json_data_default to core data and wp_theme_json_data_theme to theme data before merging (visible in the resolver source). A plugin that hooks either filter can change spacing.units without touching any file on disk. The sources establish that these filters exist; they do not establish that a filter is your cause. Treat it as a layer to rule out, not a default suspect.

Fix: decide which layer owns the list

Because spacing.units is replaced wholesale, the fix is an ownership decision, not a merge trick. Pick one layer to own the list and remove the declaration from the other.

  • Parent owns the units. Remove settings.spacing.units from the child theme.json. The parent’s list survives the merge because the child no longer supplies an incoming value for that node.
  • Child owns the units. Keep the list in the child and remove it from the parent, or accept that the parent’s list is documentation only. Do not maintain two lists; the second one is a future bug report.
  • Neither should own it. Remove the declaration from both and let the documented default apply.

What you should not do is add the missing units to both files and assume the union is what the editor sees. It is not. The incoming list replaces the existing one at that node, so the union exists only in your head.

If the two-query check showed that $base and $all differ, the fix is not in either theme.json. A saved user layer is supplying the value, and the correct action is to decide whether that saved customization should be kept, revised, or removed through the site editor’s styles workflow. That is an editorial decision about the site’s design, not a code change, and it should be made by whoever owns the design intent.

Verification: re-read after each change

After editing either file, re-run the same two queries. The verification is not “the editor looks right” — it is that the merged value at the path you care about changed to the value you intended, and that the base and all reads agree (or that the difference is the saved layer you deliberately kept).

// After the edit, in a request where development mode is 'theme'
// or after a fresh request:
$base = wp_get_global_settings(
    array( 'spacing', 'units' ),
    array( 'origin' => 'base' )
);
$all = wp_get_global_settings(
    array( 'spacing', 'units' ),
    array( 'origin' => 'all' )
);

// Expected: the list you decided on, and no unexplained divergence.
var_dump( $base, $all );

If the value is still wrong after a confirmed fresh read, the next layer to check is a filter on wp_theme_json_data_theme or wp_theme_json_data_default. Disable plugins that touch global styles one at a time and re-read. Do not escalate to !important in CSS; the unit selector is a settings-layer question, and CSS specificity has no opinion about it.

Why this failure mode is worth understanding

Most theme.json confusion comes from treating the file as a single source of truth. It is not. It is one input to a deterministic merge across origins, and the merge has explicit rules for which kinds of values are replaced and which are combined. spacing.units is in the replaced category, and the child theme is a layer applied on top of the parent, not a sibling. Once you read the merged value with wp_get_global_settings() and separate base from all, the question stops being “why is WordPress ignoring my file” and becomes “which layer did I actually ask to own this list.” That is a question with an answer.

FAQ

Does a child theme’s theme.json replace the parent’s entire file?

No. Core reads the parent theme.json and merges the child’s into it, with the child taking precedence. The replacement behavior is per-node: for sequential arrays like spacing.units, the incoming list replaces the existing one at that node rather than being concatenated.

Why does the editor offer a unit my parent theme.json does not list?

Either the child theme supplies a different spacing.units list, or a saved user layer supplies one, or neither theme declares units and the documented default applies. Query wp_get_global_settings( array( 'spacing', 'units' ) ) with origin 'base' and 'all' to tell those cases apart.

Can I just add the missing units to both files?

You can, but the merged value will still be whichever list arrives last at that node, not the union. Pick one layer to own the list and remove the declaration from the other.

Why did my edit not change the merged value?

Check two things before suspecting the merge: whether development mode is set to theme (which bypasses the theme_json cache), and whether a plugin is filtering wp_theme_json_data_theme or wp_theme_json_data_default. Both can produce a value that does not match the file you just saved.

Should I call WP_Theme_JSON_Resolver directly to debug?

Read it if you need to understand the merge, but do not ship code that depends on it. The class is documented as internal core API subject to breaking changes, with get_global_settings(), get_global_styles(), and get_global_stylesheet() named as the supported alternatives.

Why wp_options Grows Unbounded When update_option() Runs in a Loop — and How to Find the Autoloaded Rows Slowing Your Admin

Slow admin pages rarely announce themselves as an options problem. They arrive as a two-second delay on wp-admin/edit.php, a dashboard that hangs before the widgets paint, and a wp_options table that has quietly become the largest thing in the database. The usual suspect is a plugin calling update_option() inside a loop. That phrase describes two different failures, and they need different diagnostics.

Symptom

You have a self-maintained install. Admin pages are slow, the front end is tolerable, and wp_options is bloated. Someone on the team says “the plugin calls update_option() in a loop.” That is not yet a diagnosis. It is a description of a code shape that can produce two distinct outcomes:

  • Mode A — row-count growth. The loop iterates over distinct option names. Each new name becomes a new row, because update_option() creates the option if it does not exist. The table grows in width, not depth.
  • Mode B — payload growth. The loop iterates over one option name, writing an accumulating value. Row count stays flat. The stored value grows, and if that option is autoloaded, every request pays for it.

Only mode B, combined with autoload, directly inflates the payload that wp_load_alloptions() pulls on every request. Mode A can still matter — thousands of rows are not free — but row count alone is a misleading metric. A table with thousands of non-autoloaded rows can be harmless to page load, while a single large autoloaded row can dominate request cost. Triage should measure autoloaded bytes first, then row count.

Trace

Start with what core actually does. The update_option() reference states plainly: “If the option does not exist, it will be created.” That is the mechanism behind mode A. A loop over unique keys — say, one option per post, per user, or per cron event — will insert a row for every key it has not seen before. The function is not misbehaving; it is doing what its documentation says.

Mode B is subtler. The same option name is written repeatedly. The row count does not move. The option_value column grows. If the option was created with autoload enabled, or if the autoload heuristic decided to enable it, that growing value is part of the alloptions payload.

Here is the core path that matters. wp_load_alloptions() is documented as loading and caching “all autoloaded options, if available or all options.” Its source queries the options table filtering on the set of autoload values returned by wp_autoload_values_to_autoload():

$alloptions_db = $wpdb->get_results( "SELECT option_name, option_value FROM $wpdb->options WHERE autoload IN ( '" . implode( "', '", esc_sql( wp_autoload_values_to_autoload() ) ) . "' )" );

Note the fallback in the same function: if that query returns nothing, core selects all options. A diagnostic that assumes wp_load_alloptions() output is strictly “autoloaded only” can misattribute non-autoloaded rows. Keep that in mind before you blame a row you found through that function.

Why does this matter per request? The WordPress optimization guide is direct: “your site’s options data needs to be available for each page view. Without a persistent object cache, your web server must read those options from the database to handle every page view.” It goes on to say those extra trips “slow down your web server’s response times such as ‘Time to first byte’ (TTFB) and can quickly overwhelm your database server during traffic spikes.” The autoload payload is not a background concern; it is on the critical path of every admin page load.

The update_option() reference also carries the warning that matters for prevention: “Autoloading too many options can lead to performance problems, especially if the options are not frequently used.” And it gives the rule: options accessed across several places in the frontend should autoload; options accessed only on a few specific URLs should not. The add_option() reference repeats the same warning and the same recommendation to pass false for options read on few specific URLs.

Root cause

The root cause is not “a loop.” It is a loop whose key strategy and value strategy were never checked against the autoload contract.

Mode A root cause: the loop uses variable option names. Each iteration writes a new key. update_option() creates it. The table accumulates rows that may never be read again, and if the autoload heuristic or an explicit true marked them autoloaded, they join the alloptions payload permanently.

Mode B root cause: the loop uses a constant option name and an accumulating value — appending to an array, concatenating a log, storing a growing serialized structure. The row count is stable, which is why this mode hides from table-size checks. The payload grows without bound, and if the option is autoloaded, the cost is paid on every request.

There is a third trap in remediation. The update_option() reference states: “For existing options, $autoload can only be updated using update_option() if $value is also changed.” A cleanup script that calls update_option( $name, $same_value, false ) will silently fail to flip autoload off, because the value did not change. The autoload column stays as it was. You will believe you fixed it and the admin will still be slow.

Fix

Work in this order. Measure autoloaded bytes first, rank the individual options second, verify against the database third, and only then look at row count.

Step 1 — total autoloaded bytes

Since WordPress 6.6.0, WP_Site_Health::get_autoloaded_options_size() calculates the total amount of autoloaded data and returns it in bytes. Its source sums the string length of each autoloaded value, serializing arrays and objects first:

$total_length += strlen( (string) $option_value );

Run it from WP-CLI:

wp eval '$health = WP_Site_Health::get_instance(); echo $health->get_autoloaded_options_size() . " bytes\n";'

This gives you a number to compare against after remediation. It does not tell you which option is responsible. That is the next step.

Step 2 — rank autoloaded options by value length

wp_load_alloptions() is the right enumeration primitive because it mirrors what core loads. Sort the result by serialized length and print the top offenders:

wp eval '
$all = wp_load_alloptions();
$rows = array();
foreach ( $all as $name => $value ) {
    $rows[ $name ] = strlen( (string) maybe_serialize( $value ) );
}
arsort( $rows );
$i = 0;
foreach ( $rows as $name => $len ) {
    printf( "%10d  %s\n", $len, $name );
    if ( ++$i >= 25 ) { break; }
}
'

If a single option dominates the list, you are looking at mode B. If the list is long and flat, you are looking at mode A with autoload enabled across many small keys.

Step 3 — verify against the database

The alloptions cache can be stale, and the fallback path in wp_load_alloptions() can return non-autoloaded rows. Confirm with direct SQL filtered on the autoload values core treats as autoloaded. The $wpdb class documentation notes that $wpdb can read from any table in the WordPress database, not just core tables, so this is a supported diagnostic path:

SELECT option_name,
       LENGTH(option_value) AS bytes,
       autoload
FROM wp_options
WHERE autoload IN ( 'yes', 'on', 'auto-on', 'auto' )
ORDER BY bytes DESC
LIMIT 25;

Adjust the autoload value list to match what wp_autoload_values_to_autoload() returns on your version. The point is to query the table directly rather than trusting a cached array. If you want to run it through WordPress, wpdb::query() returns the number of rows affected or selected for non-DDL queries and false on error, so you can check the result before iterating.

Step 4 — row count, last

Only after the autoload picture is clear should you look at row count:

SELECT COUNT(*) AS total_rows,
       SUM(autoload IN ( 'yes', 'on', 'auto-on', 'auto' )) AS autoloaded_rows
FROM wp_options;

A large total with a small autoloaded count is a different problem from a small total with a large autoloaded count. Treat them separately.

Step 5 — flip autoload correctly

Because update_option() only changes the autoload flag when the value also changes, a remediation that passes the same value will not work. Two safe options:

  • Pass a genuinely changed value along with false, then verify the autoload column afterward.
  • Use wp_set_option_autoload_values() where it fits your version and workflow, and verify the column afterward either way.

Do not assume the flag flipped. Query it:

SELECT option_name, autoload FROM wp_options WHERE option_name = 'your_option_name';

If the row still shows an autoload value in the autoloaded set, the change did not take.

Step 6 — fix the code, not just the data

Cleaning the table without fixing the loop means the table refills. The code review question is simple: in any loop that writes options, is the key constant or variable?

  • Variable key: you are in mode A. Ask whether each key needs to be a separate option at all. Often a single option holding an array, or a custom table, is the correct shape.
  • Constant key with growing value: you are in mode B. Ask whether the value needs to grow without bound. Logs, histories, and append-only structures belong in a custom table, not in an autoloaded option.
  • Autoload argument: make it explicit. The documentation recommends true for options read across several places and false for options read only on specific URLs. Leaving it to the heuristic is how options you never intended to autoload end up in the alloptions payload.

Verification

Re-run the measurement from step 1 and compare. Then confirm the specific rows changed:

wp eval '$health = WP_Site_Health::get_instance(); echo $health->get_autoloaded_options_size() . " bytes\n";'

And confirm the autoload column for the options you touched:

SELECT option_name, autoload, LENGTH(option_value) AS bytes
FROM wp_options
WHERE option_name IN ( 'option_a', 'option_b' );

If the byte total dropped and the autoload column shows a non-autoloaded value, the fix took. If the byte total is unchanged, the most likely cause is the update_option() autoload caveat: the value did not change, so the flag did not change either.

For a related failure mode on fresh installs — where the symptom is “Nothing Found” rather than slow admin — see What to Fix First When a New WordPress Site Says Nothing Found. It is a different trace, but the same discipline applies: read the core path before installing around it.

FAQ

Does update_option() in a loop always bloat wp_options?

No. The documentation does not say the loop itself is a bug. The outcome depends on key strategy and value growth. A loop over a constant key with a bounded value is fine. A loop over variable keys grows row count. A loop over a constant key with an unbounded value grows payload. Only the payload case, combined with autoload, directly inflates the alloptions load.

Why did my cleanup script not turn autoload off?

Because update_option() only updates the autoload flag for an existing option when the value also changes. If you passed the same value with false, the function returned without touching the autoload column. Verify with a direct SELECT on the autoload column.

Is wp_load_alloptions() output always autoloaded options only?

Not guaranteed. The function is documented as loading autoloaded options “if available or all options,” and its source falls back to selecting all options when the autoload-filtered query returns nothing. Treat its output as a diagnostic aid, not as an authoritative autoload manifest, and confirm against the database.

What is the right order to triage this?

Total autoloaded bytes first, per-option length ranking second, direct SQL against the options table filtered by autoload values third, row count last. Row count alone will mislead you in both directions.

Where do I find the autoload values core treats as autoloaded?

They are returned by wp_autoload_values_to_autoload(), which wp_load_alloptions() uses in its query. Read that function on your installed version rather than hardcoding a list from a tutorial; the accepted values have changed across releases, and the update_option() changelog notes that 'yes' and 'no' were deprecated in 6.7.0.

The ‘There Is Motion At Your Front Door’ Shirt Is a Notification, Not a Promise

The shirt shows a white rounded notification banner with a bell. Inside the banner, in plain sans-serif, it reads: There is motion at your front door. Below the banner sits a circular, green-tinted view — the kind of low-light, slightly fish-eyed frame a doorbell camera produces — of a wide-eyed orange tabby pressed close to the lens, with porch siding and lights visible behind it.

That is the whole design. No slogan, no punchline caption, no brand mark. Just the alert and the frame that triggered it. The joke is not that a cat is at the door. The joke is that the notification is technically correct, and that correctness is the entire problem.

I keep coming back to this shirt because it is a rare piece of visual writing about a specific failure mode: the message that is accurate and useless at the same time. The banner does not say a cat is at your door. It says there is motion. Motion is what the sensor detects. The cat is what the human sees. The gap between those two statements is where the humor lives, and it is also where a lot of operational writing goes wrong.

What the artwork actually shows

Look at the composition in order. The banner is on top, white and rounded, with a small bell icon at the left. The text is sentence case, not all caps, not stylized. It reads like a system string, not a greeting. Below it, the circular frame is tinted green — the infrared or night-vision cast that cheap doorbell cameras produce — and the cat’s face fills most of the circle. The eyes are wide. The porch siding runs vertically behind the animal, and lights are visible in the background.

The design is doing something deliberate with hierarchy. The notification comes first because that is the order of experience: the phone buzzes, you read the alert, then you open the camera feed. The shirt reproduces that sequence as a static image. The banner is the promise; the circle is the delivery. And the delivery is a cat staring directly into a lens at close range, which is not what the banner led you to expect.

I am reading the green tint as a doorbell-camera convention, not as a claim about the cat’s actual color. The cat is orange. The tint is the camera’s. That distinction matters if you are the kind of person who reads interface copy for a living, because it is the same distinction between what a system reports and what is actually there.

The banner is a contract, and the circle is the breach

Every notification is a small contract. It says: here is a fact you asked to know. The fact is supposed to be sufficient. When the banner says there is motion at your front door, it is fulfilling the contract at the lowest possible resolution. Motion is true. Motion is also what a leaf produces, what a passing car produces, what a change in sunlight produces. The banner is not lying. It is under-specifying.

The circle below is the correction. It shows you what the motion was, and the answer is a cat with its face against the glass. The humor is not that the system failed. The humor is that the system succeeded at exactly the level it was designed for, and that level was never going to be enough.

This is the same failure mode I see in monitoring dashboards and log lines. A check returns status: degraded. The alert fires. Someone opens the dashboard and finds that the degradation is a single slow query on a table nobody reads. The alert was correct. The alert was also not the information anyone needed. The gap between the banner and the circle is the gap between a threshold and a diagnosis.

The shirt is funny because it compresses that gap into two visual elements and lets you feel the mismatch in under a second. It does not explain the joke. It does not caption the cat. It trusts you to read the banner, read the circle, and notice that the second thing is not what the first thing promised.

Why the cat has to be close to the lens

The framing is not incidental. A cat at the far edge of a porch would be a different shirt. A cat sitting politely on a mat would be a different shirt. This cat is pressed against the camera, eyes wide, filling the circle. The proximity is what makes the notification feel absurd. The sensor reported motion; the motion turned out to be a face.

There is a long tradition of doorbell-camera footage as comedy, and I am not going to pretend I can trace where this specific phrasing started. What I can say is that the design uses the visual grammar of that genre — the circular frame, the green cast, the porch background — without needing to cite it. The grammar is legible on its own. You do not need to have seen a particular video to understand that this is a camera view and that the camera view is the punchline.

The wide eyes matter too. A sleeping cat would read as a false alarm. A hissing cat would read as a threat. Wide eyes read as surprise, and surprise is the emotion the notification itself is guilty of. The banner is surprised by motion. The cat is surprised by the lens. They are both reacting to the same event from opposite ends of the same wire.

The wording is a system string, not a slogan

There is motion at your front door is not a sentence a human writes to another human. It is a sentence a device writes. The passive construction, the vague noun motion, the prepositional phrase locating it — this is the register of firmware. That register is what makes the shirt work as a piece of writing. If the banner said Your cat is at the door again, the joke would collapse into sentiment. The system string keeps it cold. The coldness is the setup; the cat is the release.

I have written a lot of interface copy in my life, and the hardest part is always deciding how much to say. Say too little and the user has to open the camera. Say too much and you have made a promise you cannot keep. The banner on this shirt is the minimal viable notification. It is technically complete. It is practically useless. The circle is what happens when you click through.

That is the argument I would make for the shirt as an object: it is a diagram of the difference between a signal and a message. A signal says something happened. A message says here is what happened and what to do about it. The banner is a signal. The cat is the message. The shirt puts them in the same frame and lets the gap do the work.

Who this is for, and what it is not

If you spend your days reading alerts, dashboards, and log lines, the shirt is a small, wearable version of a problem you already know. It does not require you to explain it. The banner and the cat are enough. If you do not spend your days that way, the shirt is still legible as a joke about a cat setting off a doorbell camera, which is a perfectly good joke on its own.

What the shirt is not: it is not a statement about security, it is not a comment on surveillance, and it is not a claim that doorbell cameras are good or bad. It is a visual joke about a notification that is correct and insufficient. I am not going to load it with more than that. The design does not ask for it.

The shirt is available as a standard t-shirt through the Amazon listing. The listing title is There Is Motion At Your Front Door Funny Cat Doorbell Meme, and the brand is Quirky Cotton Collective. I have not worn it, washed it, or tested it. I have looked at the artwork, and the artwork is the thing worth writing about.

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

The takeaway

The next time you write a notification, a status line, or an alert, look at what you have written and ask whether it is a banner or a circle. A banner says motion. A circle shows the cat. Both are true. Only one of them is useful. The shirt is funny because it shows you the moment before you click through, when all you have is the banner and the vague sense that something is at the door.

That is a small, specific joke, and it is executed cleanly. The banner is white and rounded. The bell is there. The text is plain. The cat is close. The porch is behind it. Nothing is explained. Nothing needs to be.

The ‘I Had Potential’ Shirt Draws a Ramp, Not a Promise

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 that descends from an upper-left ledge to a lower-right flat. A dashed empty circle sits at the top of the ramp. A solid white ball rests at the bottom. The ramp is curved, not a straight diagonal. That curve is the whole argument.

Most slogan shirts ask you to agree with a sentence. This one asks you to read a diagram. The sentence and the drawing are not redundant; they are two halves of a joke that only works if you notice the ball is not where the dashed circle is. The dashed circle marks where the ball was supposed to be. The solid ball marks where it ended up. The gap between those two positions is the punchline, and the curve is what makes the gap legible.

Why the curve matters

A straight diagonal would read as a slide: object goes down, object stops. A curved ramp reads as a trajectory — something that started with a plan and lost energy along the way. The dashed circle at the upper ledge is the plan. The solid ball at the lower ledge is the outcome. The curve is the distance between them, drawn as a single continuous gesture.

That is a different kind of joke from a caption. A caption tells you what to think. A diagram lets you reconstruct the thought yourself. The shirt does not say I wasted my potential or my potential rolled away. It shows you a starting position, an ending position, and a path. You supply the narrative. The shirt supplies the geometry.

This is why the design works as a minimalist object rather than a loud one. There is no background, no texture, no secondary illustration. The white-on-garment palette keeps the drawing at the level of a sketch — the kind of thing you would draw on a napkin to explain a physics problem, except the problem is you.

The double meaning is structural, not verbal

The word potential does double duty here. In ordinary speech it means unrealized capacity — the thing you could have been. In physics it names a stored quantity, a state function, something that depends on position rather than path. The shirt does not explain either meaning. It puts a ball on a ramp and lets the word sit above it, and the viewer does the work of holding both senses at once.

That is a more interesting move than a pun that announces itself. The design does not say potential energy or potential difference. It says I had potential, which is a sentence about a person, and then it draws a diagram that could be about an object. The joke is that the person and the object are in the same position: both started high, both ended low, and neither is coming back up without outside intervention.

I am not claiming the shirt teaches physics or that the curve represents a specific equation. The observation is narrower: the drawing uses the visual grammar of a physics diagram — ledge, ramp, dashed marker, solid object — to make a sentence about personal disappointment feel like a solved problem. The dashed circle is the variable. The solid ball is the value. The curve is the function that got you from one to the other.

What the dashed circle actually does

The dashed circle is the most important element on the shirt, and it is the one most likely to be missed. A solid circle at the top would read as the ball is still there. A dashed circle reads as the ball was supposed to be there. Dashed lines in technical drawing conventionally mark hidden, projected, or absent geometry — the thing that is not currently occupying the space but is still part of the specification.

That convention is doing real work here. The dashed circle is the specification. The solid ball is the as-built condition. The shirt is a tiny acceptance test that failed, printed in white on cotton. The humor comes from the gap between the drawing and the outcome, and the dashed line is what makes that gap visible rather than merely implied.

Without the dashed circle, the shirt would be a picture of a ball at the bottom of a ramp. With it, the shirt is a picture of a ball that is not where it was supposed to be. That is a different object, and a better one.

The sentence is terse because the drawing is doing the talking

I HAD POTENTIAL is four words in capitals, past tense, no punctuation. The past tense is load-bearing: it closes the question before the viewer can ask it. I have potential would be a statement of hope. I had potential is a statement of fact about a closed interval. The drawing then shows you the interval.

The capitals matter too. Lowercase would soften the sentence into self-deprecation. Capitals make it a label — the kind of heading you would put on a chart. The shirt is not confessing; it is captioning. That tonal difference is why the design reads as dry rather than sad. It is the same register as a commit message that says revert previous change: factual, slightly resigned, not asking for sympathy.

This is also why the shirt works as an off-duty object for people who spend their days reading diagrams. The joke is not that the wearer is a failure. The joke is that the wearer recognizes the shape of a plan that did not survive contact with the ramp. That recognition is a shared professional experience, and the shirt is a way of wearing it without giving a speech about it.

A slogan versus a usable promise

There is a difference between a slogan and a usable promise, and this shirt is on the slogan side by design. A usable promise tells you what will happen and gives you a way to check. A slogan tells you what to feel and gives you a way to signal it. The shirt is not making a claim about your career, your intelligence, or your future. It is making a claim about a drawing: the ball is not where the dashed circle is, and that is funny if you have ever been the ball.

That distinction matters in work culture, where slogans are often asked to do the job of promises. A team that says we value potential without a ramp, a ledge, and a way to measure the distance between them is just printing capitals. The shirt is honest about being a slogan. It does not pretend the diagram is a plan. It shows you the plan and the outcome in the same frame and lets you decide which one you are looking at.

The design is available as the I Had Potential shirt from Quirky Cotton Collective. The listing title calls it a funny physics pun and a sarcastic minimalist quote, which is accurate as far as retail prose goes, but the visual observation is more specific: white capitals, a curved descending ramp, a dashed empty circle at the upper left ledge, and a solid white ball at the lower right flat. Those are the elements that make the joke work, and they are the elements worth looking at before you decide whether the sentence is about you or about the ball.

How to read it in one pass

Start at the dashed circle. That is the intention. Follow the curve down to the solid ball. That is the result. Read the sentence above. That is the caption. The shirt is a three-step argument with no conclusion, which is why it stays interesting after the first read. The first time you see it, you laugh at the gap. The second time, you notice the curve. The third time, you realize the dashed circle is the only part of the drawing that is not solid, and that is the point.

The shirt does not resolve the gap between potential and outcome. It draws it, labels it, and puts it on a garment. That is a modest thing to do, and it is enough. A diagram that admits the ball rolled is more useful than a slogan that insists it did not.

The ‘Let’s Root For Each Other’ Shirt Reads as a Diagram, Not a Promise

The artwork is a ring of text around four smiling root vegetables. LET’S ROOT FOR EACH OTHER AND WATCH EACH OTHER GROW wraps the group; inside it sit a carrot, a radish, a beet, and an onion, each with roots drawn down into visible soil. The word root is doing double duty — the horticultural verb and the encouragement — and the design leans on that pun rather than resolving it. That unresolved pun is the interesting part. The shirt is often searched as the lets root for each other shirt, but the printed object is less a slogan than a small diagram of mutual dependence, and diagrams make claims that slogans don’t have to.

Read the composition literally first. The vegetables are arranged as peers, not as a hierarchy: no vegetable is larger, no root system is deeper, and the soil line is shared. The roots are the only part of the drawing that reaches below the surface, and they are drawn as a common substrate rather than as separate pots. The text encircles them, which means the sentence and the picture are not in a caption relationship — the sentence is the frame, and the vegetables are the content. That is a different rhetorical structure from the usual gardening tee, where a single tomato or a watering can sits under a line of type. Here the type is the border and the plants are the argument.

The two halves of the sentence are not the same kind of claim. Let’s root for each other is an offer of support: cheap to say, hard to disprove, and compatible with almost any group. And watch each other grow is a different thing. It assumes growth is happening, that it is visible, and that watching is a legitimate part of the relationship. In a work culture, that second clause is where the trouble starts. Support is a stance; watching growth is an expectation with a measurement hidden inside it. The shirt prints both clauses in the same typeface and the same ring, which flattens the difference. That flattening is the design’s real subject, whether or not anyone intended it.

This is where the root-vegetable choice earns its place. Root vegetables are the crops you cannot judge from above. The leaves tell you something, but the edible part is underground, and the only way to know how it is doing is to pull one up — which ends the growth you were watching. A carrot does not display its progress on a dashboard. The drawing makes the roots visible, which is a convention of garden diagrams and children’s books, not a claim about transparency. In the picture, growth is legible because the artist cut away the soil. In an actual garden, and in an actual team, it isn’t. The shirt is charming precisely because it draws the thing you normally can’t see, and that is also why it should not be read as a management principle.

The four vegetables also resist the usual metaphor creep. A carrot, a radish, a beet, and an onion are not a team in any functional sense; they are four different crops that happen to share a bed. They have different spacing needs, different harvest times, and different tolerances for the same soil. The drawing gives them identical smiles and a shared root zone, which is a visual simplification. If you wanted to be literal about it, the onion would be the one quietly changing the chemistry of the bed while the others were being encouraged. That is not a criticism of the artwork — it is a reminder that the picture is a diagram of togetherness, not a horticultural plan, and the two should not be confused.

The phrase watch each other grow also carries an ambiguity the design does not resolve. Watching can be generous — noticing someone’s progress and saying so — or it can be surveillance dressed as mentorship. The shirt does not distinguish. In a technical team, that distinction matters, because growth is often measured in artifacts: merged pull requests, resolved incidents, reviews given. Those are observable, and they are not the same as growth. A shirt that says watch each other grow is not a performance-review framework, and it should not be adopted as one. It is a friendly sentence on a garment, and the friendly reading is the one the drawing supports: four smiling vegetables, roots in the same soil, no ranking implied.

What the artwork does well is keep the two meanings of root in the same frame without explaining the joke. The roots are drawn; the encouragement is printed; the pun is left for the reader. That is a more disciplined piece of graphic design than a lot of slogan tees manage, because it trusts the picture to carry half the meaning. The text is not a caption and the picture is not an illustration. They are two halves of one sentence, and the sentence only works if you notice that the vegetables are, in fact, rooting — both senses — in the same soil.

If you are buying it, buy it for that. The shirt is available as a standard t-shirt through the listing: Let’s Root For Each Other And Watch Each Other Grow Garden. It is a gardening pun with a diagram inside it, and the diagram is the part worth looking at. The slogan is the frame; the roots are the argument. Read the roots first.

Editorial note: this publication and Quirky Cotton Collective share an owner. This column is an owner-authorized reader-interest piece, not an independent review.

The useSelect Infinite Loop: When a Selector Returns a New Reference on Every Render

The editor is not supposed to re-render when you type in an unrelated input. When it does, and the React console is either silent or repeating The result of getSnapshot should be cached, the first place to look is not the block editor store. It is the return value of your useSelect callback.

This is a failure-mode walkthrough: the symptom, the trace through @wordpress/data and React, the root cause, the fix, and a verification procedure that does not depend on vibes.

Symptom

You have a plugin or theme component that reads from core/block-editor, core/editor, or core. It works. Then one of the following happens:

  • The component re-renders on every dispatch, including dispatches that have nothing to do with the selected data.
  • React throws The result of getSnapshot should be cached and the editor becomes unusable.
  • In development mode, a useSelect warning appears in the console about returning a new object or array reference on each call.
  • Performance degrades as the post grows, because every block change triggers a render of a component that only cares about one boolean.

These are the same bug wearing different hats. The common thread is that the callback passed to useSelect returns a value that is not referentially stable between calls.

Trace: what useSelect actually compares

The WordPress Developer News article on useSelect states the precise condition for a re-render: it happens “when the change causes the new return value of the useSelect callback to not be shallowly equal to the previous one.” The comparison is shallow, implemented through @wordpress/is-shallow-equal. It is not a deep equality check, and it is not a reference check on the callback itself.

That distinction matters. A new object literal is never === to the previous object literal, even if every property inside it is identical. Shallow equality compares the fields one by one, but the container is new. For arrays, the same applies: a new array instance is a new value.

React’s useSyncExternalStore documentation describes the same contract from the React side. getSnapshot must return the same value while the store has not changed. The docs are explicit about the consequence: “React will re-render the component if getSnapshot return value is different from the last time. This is why, if you always return a different value, you will enter an infinite loop and get this error.” The WordPress data layer is built on this contract, so the failure mode transfers directly.

In other words: useSelect is not broken. It is doing exactly what it promises. The selector is returning a value that looks changed on every call.

Two failure shapes that look identical

There are two distinct ways to produce an unstable return value, and they require different fixes. Treating them as one bug leads to sprinkling useMemo everywhere, which is not the same as making the selector stable.

Shape A: a new container even when nothing changed

The canonical example is defaulting a null selector result to an empty array inside the callback:

const { reusableBlocks } = useSelect( ( select ) => {
  const blocks = select( 'core' ).getEntityRecords( 'postType', 'wp_block' );
  return { reusableBlocks: blocks ?? [] };
}, [] );

When blocks is null, the ?? [] expression creates a new array on every invocation. The store has not changed. The data is still absent. But the return value is a new object containing a new array, so shallow equality fails and the component re-renders.

This is not hypothetical. Gutenberg PR #46117 describes exactly this pattern in useBlockEditorSettings: “When the selector returns null, it defaults to an empty array. But that empty array is a new object on every call, leading to useSelect always returning a different result, triggering a component rerender on each action dispatch.” The fix in that PR was to extract a constant empty array.

Shape B: a new container because the data genuinely changed

The second shape is subtler because the underlying data may be changing, but the container is new every time regardless. The WordPress news article gives the canonical example:

const { tagNames } = useSelect( ( select ) => {
  const tags = select( 'taxonomies' ).getTags();
  return { tagNames: tags.map( ( t ) => t.name ) };
}, [] );

The article explains the mechanism: “every invocation of the callback calls .map on the tags array, and every such call returns a new array instance. Even though the raw tags array in the store hasn’t changed, and the returned array is semantically the same, the returned array is not equal ( === ) to the previous value. It is therefore detected as a change that needs to trigger a re-render.”

The distinction between Shape A and Shape B is not cosmetic. Shape A is fixed by hoisting a constant. Shape B is fixed by moving the transformation outside the hook. Applying the Shape A fix to Shape B leaves the loop in place; applying the Shape B fix to Shape A adds unnecessary memoization.

Root cause: referential identity, not data identity

The root cause is that the selector callback is a function that runs on every relevant store change, and its return value is compared by identity at the container level. If the callback constructs a new object or array on every invocation, the comparison always reports a change.

This is a referential-identity bug in the selector, not a defect in useSelect or in the block editor store. The hook’s contract is to re-render when the returned value is not shallowly equal. The author’s job is to make the return value stable when the underlying data is stable.

There is a second contributing factor: the dependency array. The WordPress news article notes that if the dependency array has not changed since the previous call, useSelect “can reuse the previous function and the previous value computed by it.” Omitting the dependency array, or passing a new array literal on every render, defeats that reuse. The dependency array is optional only for backward compatibility; the article recommends always specifying it.

Fix: stable selector patterns, in order of preference

1. Return primitives directly

If you need one value, return that value, not an object wrapping it. A string, number, or boolean is compared by value, so a new primitive with the same content is equal.

const hasTitle = useSelect( ( select ) => {
  return select( 'core/editor' ).getEditedPostAttribute( 'title' ).length > 0;
}, [] );

The WordPress news article makes the same point about derived booleans: calculating the boolean inside the callback means the component re-renders only when the boolean actually changes.

2. Hoist constant empty defaults

For Shape A, define the empty value once, outside the component or at module scope, and return that same reference:

const EMPTY_BLOCKS = [];

const { reusableBlocks } = useSelect( ( select ) => {
  const blocks = select( 'core' ).getEntityRecords( 'postType', 'wp_block' );
  return { reusableBlocks: blocks ?? EMPTY_BLOCKS };
}, [] );

This is the fix Gutenberg applied in PR #46117. The constant is stable, so the shallow comparison sees the same array reference when the store still has no records.

3. Return the raw store value and transform outside the hook

For Shape B, do not call .map, .filter, or object construction inside the callback. Select the raw value, then transform it with useMemo outside:

const { tags } = useSelect( ( select ) => ( {
  tags: select( 'taxonomies' ).getTags(),
} ), [] );

const tagNames = useMemo( () => {
  return tags.map( ( t ) => t.name );
}, [ tags ] );

The WordPress news article states the result: “This will cause a re-render only when the raw tags really change.” The raw array from the store is stable until the store changes, so the useMemo dependency is stable, and the derived array is recomputed only when needed.

4. Return an object of primitives when you need multiple values

If you genuinely need several values from the same store, return an object whose properties are primitives. The object container is new, but shallow equality compares the properties, and primitives compare by value:

const { title, blockCount } = useSelect( ( select ) => {
  const editor = select( 'core/editor' );
  return {
    title: editor.getEditedPostAttribute( 'title' ),
    blockCount: select( 'core/block-editor' ).getBlockCount(),
  };
}, [] );

The WordPress news article notes that returning an object to be destructured is “guaranteed to be always safe” for value types, whereas returning a custom class instance can fail shallow comparison because private fields are not enumerable. The practical rule: keep the container’s properties to primitives, and do not put arrays or objects inside it unless they are stable references.

5. Always pass the dependency array

The dependency array is what lets useSelect reuse the previous callback and value when the inputs have not changed. Pass [] for a selector that depends on nothing, and pass the actual dependencies when the callback closes over props or state:

const block = useSelect( ( select ) => {
  return select( 'core/block-editor' ).getBlock( clientId );
}, [ clientId ] );

Without the dependency array, the callback is a new function on every render, and useSelect has no way to know that it would return the same value for the same inputs.

The event-handler exception

If you only need the selector inside an event handler, you do not need a subscription at all. The WordPress news article describes a non-reactive form:

const { getTitle } = useSelect( 'site' );

This returns the set of selectors without establishing a subscription. The article notes that this is useful “not only in event handlers, but also in other handlers triggered by external events, like timers or promise resolutions.” Using the reactive form for a value that is only read on click creates a subscription that does nothing useful and can contribute to unnecessary re-renders.

Verification

Do not guess whether the selector is stable. Verify it.

Enable the development warning

Gutenberg PR #53666 added a development-mode warning for useSelect hooks that return a new object or array reference on each mapSelect call. The PR describes the mechanism: “Call the mapSelect function again and compare the returned results with the previous call.” The warning is only displayed in development mode. If it fires, the callback is returning a new reference; do not suppress it. Bisect the return expression until the unstable branch is isolated.

Check the React error

If the console shows The result of getSnapshot should be cached, the selector is returning a new value on every call. The React documentation’s troubleshooting section identifies the cause directly: getSnapshot returns a new object every time it is called. The fix is to return the same snapshot when the store has not changed.

Count renders before and after

Add a temporary render counter to the component and dispatch an unrelated action, such as opening a sidebar or typing in a non-editor input. If the counter increments on every dispatch, the selector is unstable. After applying the fix, the counter should increment only when the selected data actually changes.

const renderCount = useRef( 0 );
renderCount.current += 1;
console.log( 'renders', renderCount.current );

This is a diagnostic, not a permanent addition. Remove it once the selector is stable.

Inspect the selector return value directly

In the browser console, you can call the selector outside React and compare references:

const a = wp.data.select( 'core' ).getEntityRecords( 'postType', 'wp_block' );
const b = wp.data.select( 'core' ).getEntityRecords( 'postType', 'wp_block' );
console.log( a === b );

If the store has not changed, this should log true. If it logs false, the selector itself is returning a new reference, and the fix belongs in the selector or in how you consume it. This check does not prove your useSelect callback is stable, but it separates store-level instability from callback-level instability.

What not to do

Do not wrap the entire callback in useCallback and assume the problem is solved. The callback identity is not the comparison target; the return value is. A stable callback that returns a new object still triggers the loop.

Do not use deep equality as a workaround. The comparison is shallow by design, and deep equality on every store change is expensive. Fix the reference, not the comparison.

Do not disable the development warning. It is the cheapest diagnostic available, and suppressing it hides the next occurrence of the same bug.

FAQ

Is this a bug in useSelect?

No. useSelect re-renders when the returned value is not shallowly equal to the previous one. That is its documented behavior. The bug is in the selector callback returning a new container on every call.

Why does returning an object literal cause a loop but returning a string does not?

Shallow equality compares the fields of an object, but the object container itself is a new reference. A string is a primitive and compares by value, so two identical strings are equal. An object literal with identical properties is still a new object.

Does useMemo fix every case?

No. useMemo is appropriate for Shape B, where the transformation is expensive and the raw store value is stable. For Shape A, where the problem is a new empty default, hoisting a constant is simpler and more direct. Using useMemo to recreate the same empty array on every render does not help.

What about selecting from multiple stores in one callback?

The WordPress news article recommends doing all selections from the same store in one callback to avoid multiple subscriptions. When selecting from multiple stores, the same stability rules apply to the returned value: keep the container’s properties to primitives or stable references.

Where can I read the primary sources?

The WordPress Developer News article How to work effectively with the useSelect hook covers the shallow-equality condition, the .map example, the useMemo fix, and the non-reactive form. The Gutenberg PRs #46117 and #53666 document the empty-array default bug and the development warning. React’s useSyncExternalStore reference documents the snapshot caching requirement and the infinite-loop error.

If you are still tracing why a fresh install shows an empty editor or a blank front end, the failure is usually earlier in the stack than useSelect; this checklist for a new WordPress site that says nothing found is the place to start before blaming the data layer.

Your Taxonomy’s Rewrite Rules Didn’t Flush: Inspecting rewrite_rules Instead of Guessing

Your Taxonomy’s Rewrite Rules Didn’t Flush: Inspecting rewrite_rules Instead of Guessing

The symptom is familiar. You register a custom taxonomy on init at priority 20, deploy, and the term archives return 404s. The taxonomy appears in the admin. Terms save. The REST API exposes it. But /genre/noir/ resolves to nothing, or worse, to a single post whose slug happens to match.

The reflex is to add flush_rewrite_rules() to the registration callback. That sometimes works, which is the problem: it teaches you the wrong lesson. The real question is not whether to flush, but whether the taxonomy’s rules were present in the registry at the moment the flush ran. The rewrite_rules option is the observable record of that moment. Read it before you touch anything.

Trace: what actually happens when you register a taxonomy

register_taxonomy() is documented with a blunt constraint: do not use it before the init hook. The rewrite argument is what triggers rule generation for the taxonomy. It defaults to true, using the taxonomy key as the slug, and can be set to false to prevent rewrite entirely. It also accepts an array with slug, with_front, hierarchical, and ep_mask keys.

Registration does not write to the database. It populates the global taxonomy registry and, through WP_Taxonomy::add_rewrite_rules(), adds a permastruct and rewrite tags to the $wp_rewrite object in memory. The rewrite_rules option is only written when something calls flush_rewrite_rules() or WP_Rewrite::flush_rules(). That distinction is the whole failure mode: registration is in-memory, flushing is persistent, and the two can happen in either order.

The init hook fires after WordPress has finished loading but before headers are sent, and many plugins instantiate themselves on it. Rewrite rules are usually called inside init, and the documentation for WP_Rewrite notes that permalinks must be refreshed before rewrite changes take effect. Priority 20 is not inherently wrong. It is simply late enough that any callback at the default priority 10 which flushes rules will run before your taxonomy exists in the registry. The flush then writes a rules array that has no entry for your taxonomy, and the option looks flushed while being wrong.

Root cause: the flush ran before the rules existed

flush_rewrite_rules() removes the rewrite rules and recreates them. Its $hard parameter controls whether .htaccess is updated or only the rewrite_rules option. The function documentation is explicit that this is an expensive operation and should only be used when necessary. It regenerates from whatever is registered at the moment of the call. If your taxonomy is registered at priority 20 and a plugin flushes at priority 10, the regenerated array is missing your taxonomy’s permastruct. Nothing errors. The option is simply incomplete.

This is why adding a flush inside your own registration callback often appears to fix the problem. Your callback runs at priority 20, after registration, so the flush sees the taxonomy. But it also runs on every request, which turns a one-time correctness problem into a permanent performance problem. The documentation’s warning about cost is not decorative.

There is a second, less common cause worth ruling out before you change priorities: the rewrite argument itself. If rewrite is false, or the slug collides with an existing permastruct, or with_front produces a path that conflicts with the permalink structure, the rules may be absent from the option after a correctly timed flush. Inspecting the option distinguishes these cases.

Inspect the rewrite_rules option directly

The option stores an array of regex patterns mapped to query strings. get_option() unserializes stored array values and returns them as arrays, so you can read it without decoding. It is autoloaded, so reading it does not add a query on a normal request.

From WP-CLI, the fastest check is:

wp option get rewrite_rules --format=json | jq 'keys[]' | grep -i 'genre'

Replace genre with your taxonomy slug. If nothing matches, the taxonomy’s rules are not in the stored option. If matches appear, the rules exist and the problem is elsewhere: a conflicting rule earlier in the array, a missing rewrite tag, or a query var that is not registered.

To see the full structure without JSON tooling:

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

For a direct database read, bypassing the options cache:

SELECT option_value FROM wp_options WHERE option_name = 'rewrite_rules';

The value is a serialized PHP array. It is useful for confirming the option exists and is not empty, but the WP-CLI or get_option() route is easier to compare against expected patterns.

What should you expect to see? For a taxonomy registered with slug genre and default with_front, the rules include a pattern resembling genre/([^/]+)/?$ mapping to index.php?genre=$matches[1], plus feed and pagination variants. The exact regex depends on the permalink structure and the hierarchical argument. Compare the stored keys against the permastruct the taxonomy should have produced, not against a remembered string.

Diagnostic sequence

Work through these in order. Each step narrows the cause rather than re-flushing and hoping.

  1. Confirm the taxonomy is registered on the current request. Run wp eval 'var_dump( taxonomy_exists( "genre" ) );'. If false, the registration callback is not running where you think it is, and rewrite rules are a distraction.
  2. Dump the stored rules and search for the taxonomy slug. Use the WP-CLI commands above. Absence means the last flush did not see the taxonomy. Presence means the rules were written and the failure is downstream.
  3. Check the flush timing. If the rules are absent, find every flush_rewrite_rules() call in plugins and theme code and note its hook and priority. A flush at init priority 10 will not see a taxonomy registered at priority 20. This is the most common cause and the one the priority-20 detail points toward, but confirm it rather than assuming it.
  4. Check the rewrite argument. If the rules are absent even after a flush that demonstrably ran after registration, inspect the registration arguments. rewrite => false, a slug collision, or a with_front setting that places the taxonomy under a path already claimed by another rule will produce missing or shadowed rules.
  5. Check for rule conflicts. If the rules are present but requests still 404, look at rule order. Rules are matched in order, and a broader pattern earlier in the array can consume the request before your taxonomy rule is reached. The add_rewrite_rule() documentation notes that rules are applied in the order added, regardless of top or bottom placement, and that a broad rule can prevent a later, more specific one from ever matching.

Fix: flush once, after registration, gated on a stored version

The correct pattern is a one-time flush that runs after the taxonomy is registered and only when the rules need regenerating. The documentation’s user-contributed notes show the shape: read the rewrite_rules option, check whether the expected regex is present, and flush only if it is not. A stored version option is more robust because it also handles slug changes.

add_action( 'init', 'myplugin_register_genre_taxonomy', 20 );
function myplugin_register_genre_taxonomy() {
    register_taxonomy( 'genre', array( 'post' ), array(
        'public'       => true,
        'show_in_rest' => true,
        'rewrite'      => array( 'slug' => 'genre', 'with_front' => false ),
    ) );
}

add_action( 'init', 'myplugin_maybe_flush_genre_rules', 99 );
function myplugin_maybe_flush_genre_rules() {
    $version = '2026-10-genre-v1';
    if ( get_option( 'myplugin_genre_rules_version' ) === $version ) {
        return;
    }
    flush_rewrite_rules( false );
    update_option( 'myplugin_genre_rules_version', $version );
}

Two details matter. The flush runs at priority 99, after the taxonomy registration at 20, so the registry is populated. The false argument performs a soft flush, updating only the rewrite_rules option rather than writing .htaccess; use a hard flush only when the server configuration genuinely needs rewriting. The version string is bumped whenever the slug or rewrite arguments change, which is the deployable artifact the option represents.

If you cannot control the other plugin’s flush at priority 10, moving your registration earlier is the alternative. Registering at the default priority 10 or lower places the taxonomy in the registry before most flushes. Priority 20 is a legitimate choice when you need to run after another registration, but it carries this ordering cost and should be paired with a gated flush at a later priority.

Verification

After deploying the fix, verify the stored option rather than the browser. Run the WP-CLI search for the taxonomy slug and confirm the expected regex keys are present. Then request a term archive and check the query vars in Query Monitor’s request panel: the genre query var should be set and the main query should resolve to a taxonomy archive, not a 404 or a single post.

If the rules are present and the query var is set but the template is wrong, the problem has moved to template hierarchy or template_include, not rewrite. If the rules are present and the query var is absent, check that the taxonomy is publicly queryable and that its query_var argument has not been disabled. The register_taxonomy() documentation warns that setting query_var to true rather than a string can produce 404s; use the taxonomy key or a custom string.

One more check belongs in the verification step: confirm the taxonomy is exposed in the block editor if editors rely on it. show_in_rest => true is required for the taxonomy to appear in the block editor’s term panels. A taxonomy that saves terms in the classic editor but not in the block editor is a REST registration problem, not a rewrite problem, and the rewrite_rules option will look perfectly healthy while it happens.

Why not flush on every init

Flushing on every request is the failure mode that hides the original bug. It works, in the sense that the rules are always current, but it regenerates the entire rules array on every page load. The documentation calls the operation expensive and says it should only be used when necessary. On a small publishing site the cost may be tolerable; on a site with a large permalink structure and several plugins adding rules, it is a measurable tax paid to avoid diagnosing an ordering problem once.

The gated flush is not more complex. It is a version option and a conditional. The difference is that it fails visibly when the version is not bumped, which is a deploy-time mistake you can catch, rather than silently masking a registration-order bug indefinitely.

FAQ

Does priority 20 on init itself prevent rewrite rules from flushing?

No. Priority 20 does not block anything by itself. It places your registration after callbacks at priority 10, so a flush triggered at priority 10 runs before your taxonomy exists in the registry. The failure is ordering, not the priority number in isolation. Confirm by checking whether the taxonomy’s rules are absent from the stored option and whether another component flushes earlier.

Why do the rules appear after I click Save on the Permalinks screen?

Saving the Permalinks screen triggers a flush in the admin context, after all plugins have loaded and registered their taxonomies. That flush sees your taxonomy and writes the correct rules. It is a manual version of the gated flush described above. The fact that it works is evidence that registration is fine and the automatic flush timing is the problem.

Can I just delete the rewrite_rules option instead of flushing?

Deleting the option forces WordPress to regenerate the rules on the next request, after plugins have loaded. A user-contributed note on the flush_rewrite_rules() documentation page recommends this as a workaround for activation hooks where the taxonomy is not yet registered. It is effective, but it is a workaround for the same ordering problem. The gated flush with a version option is more explicit about when regeneration happens and why.

What if the rules are present but the archive still 404s?

Check rule order and query var registration. A broader rule earlier in the array can consume the request. A taxonomy with query_var disabled or set to true can fail to resolve. Inspect the matched rule in Query Monitor and confirm the genre query var is set before the main query runs.

For a broader triage of front-end resolution failures on a new install, the sequence in What to Fix First When a New WordPress Site Says Nothing Found covers the permalink and query layers that sit beneath taxonomy-specific rules.

Sources

  • WordPress Developer Reference, register_taxonomy(): the init constraint and the rewrite argument.
  • WordPress Developer Reference, flush_rewrite_rules(): hard versus soft flush and the cost warning.
  • WordPress Developer Reference, init hook: when it fires and how plugins use it.
  • WordPress Developer Reference, WP_Rewrite: rules called inside init and the need to refresh permalinks.
  • WordPress Developer Reference, get_option(): array unserialization behavior.
  • WordPress Developer Reference, add_rewrite_rule(): rule ordering and the user-contributed note on checking rewrite_rules before flushing.

The Meh Shirt’s Lowercase ‘meh.’ Is a Typographic Contract, Not a Mood

The native garment preview shows four letters and a period: meh. — lowercase, bold, white, set large on a dark shirt. No animal, no illustration, no secondary slogan. The period is doing more work than the word. A word alone is a mood; a word with a full stop is a statement that has already finished deliberating and declined to elaborate.

That is the argument I want to make about this shirt, and it is a typographic one rather than a psychological one. The design is not selling introversion, sarcasm, or dry humor as personality traits, even though the listing title gestures at all three. It is selling a punctuation mark. The lowercase meh supplies the affect; the period supplies the finality. Together they read less like “I am tired” and more like “the review is complete and the verdict is no.”

I have written before about the lowercase meh. as a typographic contract, and I want to push that reading further here rather than repeat it. The interesting thing about this particular garment is the scale. The lettering is large and bold — loud, visually. The word is dismissive — quiet, semantically. That mismatch is the entire design. If the type were small and gray, the shirt would be a whisper. If the word were NO in the same weight, it would be aggression. The combination of maximum visual volume with minimum verbal commitment is what makes it legible as a joke rather than a threat.

What the period actually does

In running prose, a period is invisible infrastructure. You do not notice it; you notice its absence. On a shirt, a period is a design element. It occupies space. It closes the phrase with a visual thud. The word meh without punctuation trails off — it invites a follow-up question, a “meh what?” The period forecloses that. It is the typographic equivalent of a function that returns void and documents no side effects. The call has been made; there is nothing further to await.

This is why I read the shirt as a work-culture object rather than a novelty. Technical teams spend a lot of energy on the difference between a signal and a promise. A status update that says “looking into it” is a signal. A status update that says “won’t fix” is a promise — it is a decision with a boundary. The meh. on this shirt is closer to the second category than the first. It is not indecision. It is a terse, final, low-effort verdict. The period is the boundary marker.

The visual loudness is the joke, not the message

I want to be careful here, because it would be easy to overread the design. The shirt does not demonstrate anything about software, security, or engineering outcomes. It is a garment with five printed characters. My interpretation is that the scale of the type is what makes the dismissiveness funny — the shirt shouts a shrug. That is an aesthetic reading, not a claim about the wearer’s personality or the shirt’s effect on anyone’s mood.

The listing title calls it “Funny Sarcastic Saying Introvert Dry Humor,” and the related queries people use include “meh introvert shirt” and “meh sarcastic shirt.” Those phrases describe search intent, not printed content. What is actually printed is meh. — lowercase, bold, white, on a dark shirt, with no animal illustration. The retail prose is broader than the artwork. That gap is worth noticing if you are the kind of reader who cares about contracts: the shirt’s visual contract is narrower and more precise than its marketing copy.

If you want the object itself, it is the Meh Funny Sarcastic Saying Introvert Dry Humor shirt from Quirky Cotton Collective. I am describing the printed design as observed in the native garment preview, not reporting a wearing experience.

Why a period is a better boundary than a sentence

There is a practical reason this design works as off-duty wear for people who spend their days writing precise messages. A full sentence on a shirt — “I don’t want to talk about it,” “Please leave me alone,” “I am not excited” — is a script. It tells the reader what to say back, or at least what not to say. A single word with a period is a fragment. It leaves the response open while closing the statement. That is a harder trick than it looks, and it is the same trick good error messages pull: state the condition, stop, do not narrate.

The lowercase meh also matters. Uppercase MEH. would read as shouting a dismissal — hostile, or at least performatively grumpy. Lowercase keeps the volume in the type weight rather than the letterforms. The word stays casual; the boldness stays visual. The period then lands the verdict without raising the voice. It is a small piece of typographic engineering: three variables — case, weight, punctuation — tuned so the shirt reads as dry rather than rude.

What the shirt is not

It is not a pride variant, and it is not an animal design. The preview shows no illustration beyond the lettering. It is not a diagram joke, a slogan about a product, or a piece of merch tied to a named institution. It is a typographic object with a narrow, readable contract: a word, a case, a weight, a period, a color scheme. The rest is interpretation.

That narrowness is the point. A shirt that says meh. is not trying to start a conversation. It is trying to end one politely, in advance, with correct punctuation. For readers who spend their working hours distinguishing signals from promises, that is a legible and slightly funny thing to wear — not because it proves anything about engineering, but because it gets the typography of a boundary right.

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