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.

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 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.

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.

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

The native garment preview shows a dark shirt with very small, centered lowercase meh. lettering. No animal illustration. No pride variant. No slogan in a display face. Just four letters and a period, set small enough that the shirt reads as a dark rectangle before it reads as a joke.

That scale is the argument. A meh shirt that shouted would contradict itself. The design works because the typography performs the word instead of illustrating it. The period does the rest of the work: it closes the sentence, declines to elaborate, and refuses the exclamation point that most novelty shirts reach for.

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

What the artwork actually commits to

Strip away the retail title and the object is a typographic specimen. Lowercase m, e, h, a full stop, centered on a dark ground. The letters are small relative to the chest area. There is no border, no banner, no secondary line of copy, no mascot. The visual observation supplied with the listing is the whole design brief.

I read that as a deliberate constraint rather than a shortage of ideas. Small type forces a viewing distance. Up close it is a word. Across a room it is a smudge that happens to be centered. The shirt does not announce; it waits to be noticed, which is a different social contract from a graphic that does the introducing for you.

Whether the original designer reasoned this way is not something I can verify, and I am not going to pretend otherwise. What I can say is what the preview shows: scale, case, centering, punctuation, and the absence of an animal or a pride variant. Those are the observable facts. The interpretation that follows is mine.

The period is the load-bearing character

Most sarcastic shirts end in an exclamation point or no punctuation at all. The exclamation point is a stage direction: laugh here. No punctuation is a caption. The period is a full stop in the grammatical sense and a conversational one. It says the sentence is finished and no further discussion is required.

That is why the design reads as introvert-coded without printing the word introvert. The retail title includes it, but the artwork does not. The shirt makes its point through restraint, and restraint is the one thing a loud garment cannot fake. A period at small scale is the typographic equivalent of leaving a meeting without announcing that you are leaving.

I would not claim the period is a recognized symbol with a fixed meaning. It is a period. But in the context of a one-word sentence set small and centered, it functions as a boundary marker: the statement is complete, the speaker is done, and the reader is not owed a follow-up.

Why the missing animal matters

The observation is explicit that no animal illustration is present. That negative is worth stating because the novelty-shirt market is dense with creatures doing the emotional labor: the tired sloth, the unimpressed cat, the capybara in a hoodie. Those designs outsource the joke to a face. This one does not.

Without a mascot, the word has to carry the entire tone, and the typography has to carry the word. That is a harder design problem than it looks. A sloth can be drawn badly and still read as a sloth. A lowercase meh. set at the wrong weight, the wrong tracking, or the wrong size becomes either illegible or aggressive. The preview shows a small centered mark, which suggests the scale was chosen rather than defaulted.

The absence of a pride variant is also part of the object’s identity. This is not a shirt about identity in the flag sense. It is a shirt about affect: the flat, unbothered register that the word names. Conflating the two would be a category error, and the listing does not make that claim.

A slogan is a promise; this is a refusal

Work-culture merchandise usually makes a promise. Hustle, rise and grind, team player — each is a claim about future behavior, printed on cotton. The meh. shirt promises nothing. It is closer to a status code than a slogan: a 204 No Content, delivered in lowercase.

That distinction is useful for anyone who has sat through a values workshop. A slogan is a contract the wearer is expected to honor. A refusal is a contract the wearer declines to sign. The small type and the period put this shirt firmly in the second category. It is not a manifesto. It is a boundary with a hem.

I find that more honest than most office-adjacent apparel. The shirt does not ask the wearer to be enthusiastic, grateful, or part of a journey. It asks them to be left alone, politely, in a font size that does not require a conversation.

Reading the object without overclaiming it

It would be easy to turn this into a parable about burnout, introversion, or the modern workplace. I am not going to do that. The artwork does not demonstrate a psychological finding, and a shirt is not evidence about the person wearing it. What the preview supports is narrower and more interesting: a design that uses typographic restraint to make a social claim, and does so without a mascot, a banner, or an exclamation point.

If you are shopping for the meh shirt, the thing to evaluate is whether that restraint matches how you actually want to be read. The listing title mentions sarcasm and introversion; the garment itself mentions neither. It prints four letters and a period, small and centered, on a dark ground. Everything else is context you bring.

That is the useful argument here. The design is not a joke about being tired. It is a typographic contract: I have said the word, I have punctuated it, and I am not going to explain it. The period is the signature. The scale is the tone of voice. And the missing animal is the proof that the word was expected to do the work on its own.

The Element Tiles on the ‘I Wear This Shirt Periodically’ Shirt Don’t Spell the Sentence — That’s the Joke

The shirt reads I WEAR THIS SHIRT PERIODICALLY, and beneath the words sit four periodic-table-style tiles: 53 I, 74 W, 22 Ti, 16 S. Read the symbols in order and you get I W Ti S. That is not a literal spelling of the sentence. It is a compressed one: the tiles supply the first letter of each word, and the sentence supplies the rest. The gap between the two is the entire design.

Most slogan shirts ask you to read a sentence and agree with it. This one asks you to read a sentence, then read a second layer underneath it, then notice that the second layer is incomplete on purpose. The joke is not the word periodically. The joke is that the shirt has been typeset as if it were a compound, and the compound doesn’t resolve cleanly. That is a more interesting object than a pun on its own.

What the tiles actually encode

Each tile follows the visual grammar of a periodic-table cell: atomic number in the upper corner, element symbol in the center. The numbers are real atomic numbers — 53 is iodine, 74 is tungsten, 22 is titanium, 16 is sulfur — and the symbols match. So the tiles are not decorative. They are chemically legible. A reader who knows the table can decode them without the sentence.

But decode them and you get I W Ti S, which is not a word. It is a sequence of element symbols that happens to start with the same letters as the four words above it. The design is doing two things at once: it is pretending to be a chemical formula, and it is admitting, through the mismatch, that it is a sentence wearing a formula as a costume. The periodically in the headline is the tell. The shirt is not claiming to be periodic. It is claiming to be periodically — occasionally, from time to time — and the table tiles are the visual evidence of the pun.

I want to be precise about what this is and isn’t. It is a visual arrangement of four element tiles under a sentence. It is not a claim about chemistry, a demonstration of anything, or a scientific statement. The numbers and symbols are correct as far as they go, but the sequence they form is a joke, not a compound. The design’s argument is grammatical, not chemical.

Why the mismatch is the useful part

There is a category of technical humor that works by almost resolving. A diagram that is 90% correct, a command that is one flag away from doing what you meant, a config file that parses but doesn’t behave. The pleasure is in the near-miss, and the near-miss only works if the reader can see both the intended reading and the literal one at the same time.

This shirt is built on that structure. The intended reading is the sentence. The literal reading is the tile sequence. Neither one is wrong, and neither one is complete without the other. If the tiles spelled the sentence exactly, there would be no joke — it would just be a sentence in a strange font. If the sentence were absent, the tiles would be a fragment. The design needs both layers to be legible and neither to be sufficient.

That is a harder thing to pull off than a standard pun shirt, and it is why the artwork is worth looking at closely rather than glancing at. The four tiles are not a decoration added to a slogan. They are a second text, and the relationship between the two texts is the product.

The boundary between a slogan and a usable promise

Technical readers spend a lot of time around messages that look like commitments. A status page, a changelog entry, a comment in a config file, a README badge. Most of them are slogans dressed as promises, and the useful skill is telling the difference. A slogan is a sentence that sounds like a guarantee. A usable promise is one you can verify.

This shirt is a small, low-stakes exercise in that distinction. The sentence I wear this shirt periodically is a claim about behavior. The tiles are a claim about composition. They are not the same kind of claim, and the design does not pretend they are. The sentence is the promise; the tiles are the decoration that makes the promise funny. If you read the tiles as a literal specification, you get a fragment. If you read the sentence as a literal specification, you get a statement about laundry rotation. The joke lives in the space between those two readings, and that space is exactly where a lot of real technical communication goes wrong.

I am not going to claim the shirt teaches you anything about systems. It doesn’t. It is a shirt. But the structure it uses — two layers, neither complete, the mismatch doing the work — is the same structure you find in a lot of interfaces that are almost right. The value of noticing it on a shirt is that it is cheap to notice here. You can look at the tiles, see the gap, and move on. The same gap in a production system is more expensive.

Reading the object without overreading it

The shirt is sold as I Wear This Shirt Periodically Funny Chemistry Science Pun by Quirky Cotton Collective. The retail title is doing what retail titles do: stacking keywords. The artwork itself is more restrained than the title. Four tiles, one sentence, no additional decoration visible in the supplied observation. The restraint is what makes the joke land. A busier design would bury the mismatch under ornament.

If you want the object, it is the Quirky Cotton Collective listing for the periodically shirt. I am not going to tell you it is comfortable, or that it fits well, or that people compliment it. I have not worn it. What I can tell you is what the artwork does, because the artwork is the thing being sold and the artwork is legible from the observation.

The design’s argument, stated plainly: a sentence and a formula can occupy the same space if neither is allowed to fully resolve the other. The tiles are not a spelling. The sentence is not a specification. The joke is the gap. That is a small, well-built thing, and it is worth reading closely for the same reason any well-built thing is worth reading closely — because the structure is the point, and the structure is visible if you look at it for more than a second.

Disclosure: This publication and Quirky Cotton Collective share an owner.

The Crossed-Out Hand on the ‘Not Friendly Do Not Touch’ Shirt Is a Boundary, Not a Bug

The shirt reads NOT FRIENDLY across the top, DO NOT TOUCH beneath it, and between the two lines sits a yellow caution triangle containing a hand with a diagonal slash through it. The lettering is set in the visual grammar of a hazard placard: heavy capitals, a bordered triangle, a prohibition mark. It is a warning sign, reproduced on cotton, and the first thing worth noticing is that it is doing two jobs at once — it is a joke, and it is a contract.

That double duty is the argument. A warning sign is the most compressed form of a boundary we have: it states a condition, names the prohibited action, and implies a consequence without spelling one out. The not friendly do not touch shirt borrows that grammar and points it at a person rather than a machine or a wet floor. The humor lands because the format is absurdly formal for the message. The message lands because the format is exactly right for it.

Reading the three elements as a single statement

Take the parts in order. The caution triangle is the frame: it says attention required before any specific instruction arrives. The crossed-out hand is the prohibition: a gesture, a reach, a touch, negated by the slash. The words are the plain-language restatement of what the symbols already said. Redundancy here is not sloppiness. Safety signage is deliberately redundant because it has to survive being read at a glance, in poor light, by someone who is already moving. The shirt copies that redundancy on purpose, and the copy is the joke.

What the design does not do is specify a consequence. There is no or else. That omission is where the design is smarter than it first appears. A real hazard sign usually pairs the prohibition with an outcome — injury, fine, prosecution. This one stops at the boundary itself. The refusal is the whole content. I read that as the point: the wearer is not threatening anything, just declining an interaction in advance, in the most legible format available.

Why a sign is a better vehicle than a sentence

A slogan like I’m shy or introvert asks the reader to interpret a personality. A sign asks the reader to comply with an instruction. The difference matters. Personality claims invite comment — you don’t seem shy, everyone’s a bit introverted — because they are propositions about a person and therefore arguable. A prohibition is not a proposition about anyone. It is an operational rule, and rules are followed or broken rather than debated. Putting the message in sign language moves it out of the category of things strangers get to have opinions about.

This is the same reason terse messages work well in engineering contexts. A good error message names the condition and the prohibited action and stops. It does not explain the philosophy of the failure. The shirt applies that discipline to a social situation, and the result reads as dry rather than hostile — which is, I suspect, the intended register. The crossed-out hand is doing the work a well-placed return does: it ends the interaction at the boundary instead of letting it proceed and fail later.

The distinction between a slogan and a usable promise

Most novelty shirts are slogans. They assert a mood or a tribe and leave the reader to do whatever they like with it. This one is closer to a promise, in the narrow sense that it commits the wearer to a stated behavior: no approach, no contact. That commitment is what makes it usable as an object rather than just a joke. A slogan can be ignored without contradiction. A posted boundary that gets ignored is a small breach, and the sign format makes that breach visible to everyone in the room, not just the two people involved.

I would not overclaim here. The shirt does not enforce anything, and it is not a security control. It is a piece of printed cloth that states a preference in a format borrowed from places where preferences are mandatory. The borrowing is the entire mechanism, and it works because the reader already knows how to read a caution triangle before they read a single word of the shirt.

What the design gets right, and what it leaves open

The composition is tight: two lines of capitals, one symbol between them, no decorative filler. The yellow triangle is the only color departure from the lettering, which keeps the eye moving from the frame to the prohibition to the words in the order a sign is meant to be read. Nothing on the garment asks to be interpreted as a portrait of the wearer. It is a placard, and placards are impersonal by design.

What it leaves open is tone. The same three elements could read as weary, as deadpan, or as genuinely unfriendly, and the design does not resolve which. That ambiguity is probably why the format keeps getting reused: it lets the wearer choose how much of the joke to mean. The sign says do not touch; whether that is a boundary, a bit, or both is left to the person inside it.

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

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

The garment image shows a black-and-white speckled rectangle with a dark binding strip down the left edge. Inside that rectangle sits a white ruled label headed COMPOSITION, and inside the label, an outlined ghost and pumpkin. That is the whole design. The shirt is not a notebook; it is a printed representation of a notebook cover, worn on a body. The distinction matters because most commentary on teacher Halloween shirts treats the object as a costume in the theatrical sense — a thing you put on to become something else. This one is closer to a diagram: a familiar writing surface transferred onto a person, with the label doing the labeling.

I want to read that transfer carefully, because the design is doing something more specific than a slogan. A slogan asserts. A diagram describes. The composition notebook cover is a diagram of a school supply, and the ghost and pumpkin are the only concession to the holiday. The rest is stationery. If you are the kind of reader who spends the day tracing symptoms to root causes, the appeal is legible: the shirt is a labeled object, and the label is the point.

The Label Is the Argument

Look at the hierarchy. The speckled field is the largest element, but it is background. The dark binding strip is structural — it tells you which edge is the spine. The white ruled label is the focal point, and the word COMPOSITION is the only text. The ghost and pumpkin sit inside the ruled lines, subordinate to the label. The design does not say teacher anywhere. It says composition, which is a subject, a book type, and a verb. That ambiguity is the useful part.

In systems terms, the label is a contract. It declares what the object is for. A composition notebook is for writing; the ruled lines enforce the contract; the speckled cover identifies the brand of contract. When you print that cover on a shirt, you are wearing the contract, not the notebook. The ghost and pumpkin are a seasonal override — a small, bounded exception to the otherwise plain stationery. That is a recognizable pattern in production systems too: a stable base with a narrow, time-boxed exception layered on top. The design does not let the exception swallow the base. The ghost is outlined, not filled; the pumpkin is small; the label still reads COMPOSITION first.

This is where the shirt differs from a generic school slogan. A slogan would put a sentence across the chest — something about teaching, or Halloween, or both. This design puts a surface across the chest and lets the surface carry the meaning. The reader has to recognize the surface. That recognition is the joke, and it is also the limit: if you do not know what a composition notebook looks like, the shirt is just a speckled rectangle with a ghost on it. The design assumes a specific visual literacy. It is not trying to be universal.

What the Ghost and Pumpkin Are Doing

The ghost and pumpkin are inside the label, on the ruled lines. That placement is deliberate. They are not floating in the speckled field; they are contained by the label, which is contained by the cover. The nesting is the composition. A ghost on a ruled line is a small violation of the line’s purpose — the line is for writing, and the ghost is a drawing. The pumpkin is likewise a drawing. So the label, which declares the surface for composition, is itself composed of drawings. That is a mild, self-referential joke, and it is the kind of joke that rewards a second look rather than a first glance.

I would not claim the design is making a historical argument about composition books, or that the ghost and pumpkin have a specific origin. The observation is visual: outlined ghost, small pumpkin, white ruled label, black-and-white speckled field, dark binding strip on the left. The interpretation is mine: the nesting reads as a bounded exception inside a stable contract. That is an interpretation, not a fact about the artwork’s intent. The artwork does not come with a statement of intent, and I am not going to invent one.

The Costume That Is Not a Costume

The retail title calls this a costume, and the design supports a group reading: several people wearing the same printed cover become a set of matching notebooks. That is a costume logic — repetition creates the effect. But the object itself is a shirt with a print. The distinction is not pedantic. A costume usually transforms the wearer; this print labels the wearer as a surface. The wearer does not become a notebook. The wearer becomes a cover, which is a different thing: a cover is what you see before you open the object. The shirt is all cover and no pages. That is the joke, and it is also the limitation. There is nothing inside.

For a teacher group, the matching set is the point: the repetition is the costume. For an individual, the shirt is a reference. The design does not resolve which reading is correct, and it does not need to. The label is stable; the context changes. That is a reasonable property for a seasonal garment. It also means the shirt is not making a promise about teaching, or about Halloween, or about anything else. It is a printed cover. The contract is visual, not behavioral.

Where the Object Sits

The composition notebook shirt is listed as a standard t-shirt under the Quirky Cotton Collective brand. The design is the printed cover described above. I am not going to tell you how it wears, because I have not worn it, and the artwork does not establish a wearing experience. I am not going to tell you it is popular, because the artwork does not establish popularity. What the artwork establishes is the visual argument: a labeled surface, a bounded exception, a costume logic that depends on repetition. That is enough to read.

The useful question for a reader of this column is not whether the shirt is good. It is whether the design’s contract holds. Does the label stay legible? Does the ghost stay subordinate? Does the speckled field read as a cover rather than a texture? Those are visual questions, and the image answers them: the label is the focal point, the ghost and pumpkin are contained, the binding strip is structural. The design is coherent. Whether that coherence is what you want on a shirt is a separate question, and it is yours to answer.

Verification

The observation is the retail garment image: a black-and-white speckled notebook-cover rectangle, dark binding strip on the left, white ruled label headed COMPOSITION containing an outlined ghost and pumpkin. The shirt is a printed cover, not a physical notebook. The interpretation — label as contract, ghost and pumpkin as bounded exception, costume logic as repetition — is mine. The design does not establish a wearing experience, a review, popularity, or a publication origin, and I have not invented any. The listing is live in the US marketplace as of the observation date.

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

Why wp_unique_post_slug() Keeps Appending -2: Trashed Rows, Not Live URLs, Reserve post_name

Symptom. You publish a post titled “Launch Notes.” The slug comes back launch-notes-2. You check the front end: /launch-notes/ returns a 404, or a redirect, or nothing at all. There is no live post at that URL. WordPress still refuses the slug.

The instinct is to blame caching, the block editor, or a plugin. The actual answer is duller and more useful: wp_unique_post_slug() is not checking URLs. It is checking rows in wp_posts. A row with post_name = 'launch-notes' exists, and the uniqueness query does not care what post_status that row has. Until the row is gone — or renamed — the slug is taken.

This is a walkthrough of that mechanism: where the suffix is generated, why trashing a post does not free its post_name, what core does to work around it, and how to diagnose the row that is actually holding your slug.

Trace: the suffix is generated by a status-agnostic query

The reference page for wp_unique_post_slug() publishes the function source. Two things in it matter here.

First, the early return. Drafts, pending posts, auto-drafts, revisions, and user_request posts skip uniqueness entirely:

if ( in_array( $post_status, array( 'draft', 'pending', 'auto-draft' ), true )
    || ( 'inherit' === $post_status && 'revision' === $post_type )
    || 'user_request' === $post_type ) {
    return $slug;
}

Second, the flat-post branch. For a non-hierarchical post type, the collision check is:

$check_sql = "SELECT post_name FROM $wpdb->posts
    WHERE post_name = %s AND post_type = %s AND ID != %d LIMIT 1";

Read the WHERE clause carefully. It filters on post_name, post_type, and ID. It does not filter on post_status. A trashed post, a published post, a private post, and a scheduled post are all equally visible to this query. Status is not the gate. The row is.

When a match is found, the function loops suffixes starting at 2:

$suffix = 2;
do {
    $alt_post_name = _truncate_post_slug( $slug, 200 - ( strlen( $suffix ) + 1 ) ) . "-$suffix";
    $post_name_check = $wpdb->get_var( $wpdb->prepare( $check_sql, $alt_post_name, $post_id ) );
    ++$suffix;
} while ( $post_name_check );
$slug = $alt_post_name;

So -2 is not a special case. It is the first candidate in a loop that keeps incrementing until the query returns nothing. If you see -3, there are at least two rows holding the base slug and the first suffixed variant.

The hierarchical branch behaves the same way with a different scope. For pages and other hierarchical types, the query adds post_parent and allows attachment as a sibling type:

$check_sql = "SELECT post_name FROM $wpdb->posts
    WHERE post_name = %s AND post_type IN ( %s, 'attachment' )
    AND ID != %d AND post_parent = %d LIMIT 1";

Again: no post_status predicate. A trashed child page under the same parent reserves the slug for that parent just as firmly as a published one.

The call site is wp_insert_post(), which invokes the function before writing the row. The reference source shows the line:

$post_name = wp_unique_post_slug( $post_name, $post_id, $post_status, $post_type, $post_parent );

That is the whole mechanism. The suffix is a database collision, not a routing decision.

Why trashing does not free the slug

Trashing a post does not delete its row. wp_trash_post() records the previous status and a timestamp in post meta, then updates the post status to trash:

add_post_meta( $post_id, '_wp_trash_meta_status', $previous_status );
add_post_meta( $post_id, '_wp_trash_meta_time', time() );
$post_updated = wp_update_post( array( 'ID' => $post_id, 'post_status' => 'trash' ) );

The row survives. Its post_name survives with it. If nothing else happened, the trashed row would hold the slug indefinitely and every future post with the same title would get -2.

Core does not leave it there. wp_insert_post() contains a branch that fires when a post transitions into trash:

if ( 'trash' === $post_status && 'trash' !== $previous_status && 'new' !== $previous_status ) {
    $post_name = wp_add_trashed_suffix_to_post_name_for_post( $post_id );
}

The helper is private core API, documented for completeness on the reference page. Its job is to rename the trashed post so the slug is available to live posts. The companion function, wp_add_trashed_suffix_to_post_name_for_trashed_posts(), handles the other direction: when a live post claims a slug, it finds trashed posts holding that slug and renames them.

$trashed_posts_with_desired_slug = get_posts( array(
    'name'          => $post_name,
    'post_status'   => 'trash',
    'post_type'     => 'any',
    'nopaging'      => true,
    'post__not_in'  => array( $post_id ),
) );
if ( ! empty( $trashed_posts_with_desired_slug ) ) {
    foreach ( $trashed_posts_with_desired_slug as $_post ) {
        wp_add_trashed_suffix_to_post_name_for_post( $_post );
    }
}

This is gated by a filter introduced in WordPress 5.4.0, per the @since annotation in the wp_insert_post() source:

$add_trashed_suffix = apply_filters( 'add_trashed_suffix_to_trashed_posts', true, $post_name, $post_id );

So the common case is not “trashed posts block slugs forever.” The common case is that core renames the trashed holder and the new post gets the clean slug. If you are still seeing -2, the trashed row was not renamed. That is the diagnostic question, and it has a small set of answers.

Root cause: which row is actually holding the slug

Before theorizing, query the table. The following SQL lists every row matching the base slug across all statuses and types. Run it against the database directly or through WP-CLI:

SELECT ID, post_title, post_name, post_status, post_type, post_parent, post_date
FROM wp_posts
WHERE post_name IN ( 'launch-notes', 'launch-notes-2' )
ORDER BY post_name, ID;

Adjust the table prefix. If you prefer WP-CLI, the equivalent is:

wp db query "SELECT ID, post_title, post_name, post_status, post_type, post_parent FROM wp_posts WHERE post_name LIKE 'launch-notes%' ORDER BY post_name, ID;"

What you find determines the fix. The plausible cases:

  • A trashed row still holds the base slug. The rename did not happen. Check whether add_trashed_suffix_to_trashed_posts is filtered to false somewhere in a theme or plugin. Check whether the trashed post was created or trashed through a path that bypassed wp_insert_post() — direct SQL, an import, a migration script.
  • A row of a different post type holds the slug. The flat query filters on post_type, so a post and a page can share a slug. But a custom post type with the same slug as a post will not collide in the flat branch. If you see a collision across types, confirm which branch your post type takes. Hierarchical types check post_type IN ( %s, 'attachment' ) and post_parent; flat types check a single post_type.
  • A hierarchical row under the same parent holds the slug. Pages are scoped by post_parent. A trashed child page under the same parent reserves the slug for that parent only. A page under a different parent does not.
  • An attachment holds the slug. The attachment branch checks uniqueness across all types: WHERE post_name = %s AND ID != %d. No post_type filter. An attachment named launch-notes will force -2 on a post.
  • A row exists that you did not expect. Imports, staging-to-production migrations, and REST API clients that set slug directly can leave rows behind. The query tells you the truth; the admin list table does not show trashed rows by default.

If the holding row is trashed and you want the slug back, you have two clean options: permanently delete the trashed row, or rename its post_name. Permanent deletion is the correct choice when the content is genuinely disposable. Renaming is the correct choice when you may want to restore it later.

Why “Delete” in the admin may not have deleted anything

This is the part that surprises people. Clicking Delete on a post does not necessarily remove the row. wp_delete_post() falls back to trashing under specific conditions:

if ( ! $force_delete
    && ( 'post' === $post->post_type || 'page' === $post->post_type )
    && 'trash' !== get_post_status( $post_id )
    && EMPTY_TRASH_DAYS ) {
    return wp_trash_post( $post_id );
}

So for posts and pages, with trash enabled and the item not already trashed, wp_delete_post() is a trash operation. The row stays. The post_name stays until the rename branch runs.

The actual row removal is a single database call, visible later in the same source:

$result = $wpdb->delete( $wpdb->posts, array( 'ID' => $post_id ) );

That is the point at which the slug is freed at the row level. Everything before it — meta cleanup, term relationship removal, revision deletion, comment deletion — happens while the row still exists.

Two practical consequences follow. First, “Empty Trash” is the operation that actually frees slugs, not “Trash.” Second, custom post types are a documented exception: the reference page’s user-contributed notes warn that custom post types are deleted rather than trashed regardless of $force_delete, and recommend wp_trash_post() if you want trash behavior. That note is user-contributed, not core documentation, but it matches the conditional above: the trash fallback only applies to post and page.

The untrash contract: _wp_desired_post_slug

If core renames trashed posts to free their slugs, how does restoring a trashed post get its original slug back? Through post meta. wp_insert_post() reads _wp_desired_post_slug when a post leaves trash:

if ( 'trash' === $previous_status && 'trash' !== $post_status ) {
    $desired_post_slug = get_post_meta( $post_id, '_wp_desired_post_slug', true );
    if ( $desired_post_slug ) {
        delete_post_meta( $post_id, '_wp_desired_post_slug' );
        $post_name = $desired_post_slug;
    }
}

The helper that renames trashed posts is documented as storing the desired slug for exactly this purpose: “Store its desired (i.e. current) slug so it can try to reclaim it if the post is untrashed.”

This is the part most cleanup workflows miss. If you permanently delete trashed rows, the meta goes with them and there is nothing to reclaim — which is fine, because the content is gone. But if you bulk-edit post_name values in the database, or run a migration that rewrites slugs without touching _wp_desired_post_slug, you can create a mismatch: the trashed post’s meta points at a slug that now belongs to a live post. On restore, wp_insert_post() will attempt to reclaim it, and wp_unique_post_slug() will suffix it again. The result is a restored post with a different URL than it had before, which is usually not what the editor expected.

To inspect the meta for a specific post:

wp post meta get 1234 _wp_desired_post_slug

Or across all trashed posts:

SELECT p.ID, p.post_name, pm.meta_value AS desired_slug
FROM wp_posts p
LEFT JOIN wp_postmeta pm ON pm.post_id = p.ID AND pm.meta_key = '_wp_desired_post_slug'
WHERE p.post_status = 'trash'
ORDER BY p.ID DESC;

If desired_slug is null for a trashed post, that post will not reclaim a slug on restore. It will get whatever wp_unique_post_slug() assigns based on its current post_name.

Fix: three legitimate interventions

Which fix is correct depends on what the holding row is and whether you intend to restore it.

1. Permanently delete the trashed row

If the content is disposable, force deletion. Through WP-CLI:

wp post delete 1234 --force

Through PHP, with the second argument set to true:

wp_delete_post( 1234, true );

This reaches the $wpdb->delete() call and removes the row. The slug is free on the next insert. Verify with the same SELECT from earlier: the row should be gone.

2. Rename the trashed row’s post_name

If you may restore the content, do not delete it. Rename its post_name to something that will not collide, and update _wp_desired_post_slug to match if you want the restore to be predictable. A direct database update is the blunt instrument:

UPDATE wp_posts SET post_name = 'launch-notes-trashed-2024'
WHERE ID = 1234 AND post_status = 'trash';

Then update the meta so the restore path does not try to reclaim the old slug:

UPDATE wp_postmeta SET meta_value = 'launch-notes-trashed-2024'
WHERE post_id = 1234 AND meta_key = '_wp_desired_post_slug';

Use $wpdb->prepare() if you are doing this in PHP rather than through a database client. The wpdb documentation is explicit that all untrusted values in SQL must be escaped and that prepare() is the mechanism for doing so.

After either update, flush the object cache for that post if you run a persistent cache. clean_post_cache( 1234 ) is the core function; WP-CLI exposes it through wp cache flush for the whole cache, which is heavier but reliable.

3. Short-circuit uniqueness deliberately

Core exposes two filters for overriding slug generation. The first runs before any query:

$override_slug = apply_filters( 'pre_wp_unique_post_slug', null, $slug, $post_id, $post_status, $post_type, $post_parent );
if ( null !== $override_slug ) {
    return $override_slug;
}

The second runs after the suffix loop, on the final value:

return apply_filters( 'wp_unique_post_slug', $slug, $post_id, $post_status, $post_type, $post_parent, $original_slug );

These are escape hatches, not fixes. Returning a slug that collides with an existing row converts a core invariant into an editorial policy decision, and the consequences — two rows with the same post_name, ambiguous permalink resolution, unpredictable REST responses — are yours to manage. If you use pre_wp_unique_post_slug, scope it narrowly: check the post type, check the specific slug, and log when it fires. A filter that silently returns the requested slug for every post is a future debugging session you are scheduling for someone else.

The add_trashed_suffix_to_trashed_posts filter is a different kind of lever. Returning false disables the rename of trashed holders, which means trashed posts keep their slugs and new posts get suffixed. That is almost never what you want on a publishing site, but it is worth knowing the filter exists if you inherit a codebase where it is set.

Verification

After any of the above, confirm the state rather than assuming it. The sequence:

  1. Query wp_posts for the base slug across all statuses. The only row that should match is the one you intend to hold it.
  2. Create or update the post that needs the slug. Check the returned post_name — through the editor, through wp post get <id> --field=post_name, or through the REST API response.
  3. If a persistent object cache is in play, flush it before testing the front end. The database is the source of truth; the cache is a copy.
  4. Request the URL and confirm it resolves to the intended post, not a redirect or a 404. If the site returns “nothing found” for a URL you believe exists, the problem may be routing rather than slug reservation — see what to fix first when a new WordPress site says nothing found for that separate failure mode.

If the suffix persists after the holding row is gone, the next suspect is not wp_unique_post_slug(). It is a filter on pre_wp_unique_post_slug or wp_unique_post_slug, or a code path that is not calling wp_insert_post() at all. Query Monitor’s database panel will show the SELECT post_name FROM wp_posts ... query and its result; if the query returns a row, the row exists. If it returns nothing and the slug is still suffixed, the suffix came from somewhere else.

The mental model to keep

post_name is a row-level reservation in wp_posts, not a URL-level one. The front end can 404, redirect, or hide a post entirely; the uniqueness query does not know or care. It sees rows.

Core’s trashed-suffix machinery exists to keep that model from becoming a permanent leak: when a post is trashed, its slug is renamed so live posts can claim it; when a live post claims a slug, trashed holders are renamed; when a post is restored, _wp_desired_post_slug is consulted to reclaim the original. Those three behaviors are the contract. When the suffix appears anyway, one of them did not run — and the SELECT tells you which row is responsible.

Treat “Delete” as “Trash” until you have confirmed otherwise. Treat “Empty Trash” as the operation that frees slugs. And treat any filter that overrides uniqueness as a documented policy, not a convenience.

FAQ

Does wp_unique_post_slug() check post_status?

Not in the collision queries. The flat branch filters on post_name, post_type, and ID; the hierarchical branch adds post_parent and allows attachment; the attachment branch filters on post_name and ID. The only status handling is the early return for draft, pending, auto-draft, revisions, and user_request. A trashed row is visible to the query.

Why did my new post get -2 if the old post is in the trash?

Because a row with that post_name still exists. Core usually renames trashed holders when a live post claims the slug, so the likely causes are a filtered add_trashed_suffix_to_trashed_posts, a row created outside wp_insert_post(), a different post type or parent scope, or an attachment holding the slug. The SELECT in the trace section identifies which.

Does permanently deleting a trashed post free the slug immediately?

At the database level, yes: wp_delete_post( $id, true ) reaches $wpdb->delete() on the posts table, and the row is gone. If a persistent object cache is in use, flush it before assuming the front end reflects the change. The uniqueness query reads the database, not the cache, so the next insert should see the slug as free.

What is _wp_desired_post_slug for?

It stores the slug a trashed post had before it was renamed, so wp_insert_post() can reassign it when the post leaves trash. If you bulk-edit trashed rows or migrate slugs, update this meta alongside post_name or the restore path will not reclaim the original slug.

Can I just filter the slug to force it?

Yes, through pre_wp_unique_post_slug or wp_unique_post_slug. Both are documented in the function reference. Doing so bypasses the uniqueness check and can produce two rows with the same post_name. Scope the filter narrowly and log when it fires.

Do custom post types behave differently?

The uniqueness branch depends on whether the post type is hierarchical, not on whether it is custom. Hierarchical types are scoped by post_parent; flat types are scoped by post_type. Separately, the trash fallback in wp_delete_post() only applies to post and page, so custom post types may be deleted rather than trashed depending on how deletion is invoked.

Why Deleting 5,000 Posts Crawls: wp_update_term_count_now() and Where wp_defer_term_counting() Belongs

Symptom. A maintenance script loops over a few thousand post IDs and calls wp_delete_post( $id, true ). The first hundred deletions finish quickly. Then the script settles into a rhythm that looks like a hang: one post every few seconds, PHP-FPM workers pinned, MySQL showing a repeating pattern of UPDATE wp_term_taxonomy statements. The script is not stuck. It is doing term counting, one post at a time.

This is a trace of that path through core, followed by the placement rule for wp_defer_term_counting() and a verification step that does not rely on optimism.

Trace: what wp_delete_post() actually calls

The wp_delete_post() reference shows the relevant line early in the function, after the before_delete_post action and the trash-meta cleanup:

wp_delete_object_term_relationships( $post_id, get_object_taxonomies( $post->post_type ) );

That is the handoff. wp_delete_post() does not call wp_update_term_count_now() directly. It removes the post’s term relationships, and the count update happens downstream of that removal. The retrieved source for wp_delete_post() confirms the call site; the internals of wp_delete_object_term_relationships() are not in the retrieved passage, so treat the exact chain from relationship removal to count update as the documented behavior of the term API rather than a line-by-line quote from that function.

What the retrieved sources do establish is the counting layer itself. wp_update_term_count() is the function that decides whether to recalculate now or queue the work. Its source contains the branch that matters:

if ( wp_defer_term_counting() ) {
    if ( ! isset( $_deferred[ $taxonomy ] ) ) {
        $_deferred[ $taxonomy ] = array();
    }
    $_deferred[ $taxonomy ] = array_unique( array_merge( $_deferred[ $taxonomy ], $terms ) );
    return true;
}
return wp_update_term_count_now( $terms, $taxonomy );

When deferral is off — the default — every call to wp_update_term_count() falls through to wp_update_term_count_now(). That function is documented as performing the term count update immediately. Its source dispatches to the taxonomy’s update_count_callback if one is registered, otherwise to _update_post_term_count() or _update_generic_term_count(), then calls clean_term_cache(). In a loop over thousands of posts, each post that shares a taxonomy with previously deleted posts triggers that immediate recount for the affected term taxonomy IDs.

This is the cost model. It is not that deletion is inherently slow. It is that term counting is a per-relationship side effect, and without deferral it runs synchronously inside the deletion call.

Root cause: the default is immediate, and the loop multiplies it

Two facts combine badly in a bulk loop:

  1. wp_update_term_count() calls wp_update_term_count_now() whenever wp_defer_term_counting() returns false.
  2. wp_defer_term_counting() is a static flag that defaults to false. Its source is short enough to read in full: a static $_defer, a setter when a boolean is passed, and a flush when the flag is turned off.

So a naive loop does this, per post: remove relationships, call wp_update_term_count(), fall through to wp_update_term_count_now(), run the count callback, clean the term cache. The count callback is not free — it queries the relationship table for the affected term taxonomy IDs. Multiply by the number of posts and the number of taxonomies attached to the post type, and the loop’s cost is dominated by recounting, not by the DELETE on wp_posts.

There is a second path worth knowing about, because it explains why some plugins make deletion slower than core alone. wp_set_object_terms() calls wp_update_term_count( $new_tt_ids, $taxonomy ) when new relationships are added. Any deleted_post or before_delete_post hook that reassigns terms — for example, a workflow plugin that moves a deleted post’s categories to a fallback term — re-enters the same counting path. Deferral covers that too, provided it is enabled before the hook fires.

Fix: wrap the loop, flush once

The placement rule is simple and easy to get wrong: wp_defer_term_counting( true ) goes before the loop, wp_defer_term_counting( false ) goes after it, and both must run even if the loop throws. The following is a hypothetical script, not a benchmark:

<?php
// Hypothetical bulk-deletion script. Not a measured example.
$post_ids = get_posts( array(
    'post_type'      => 'post',
    'post_status'    => 'any',
    'numberposts'    => -1,
    'fields'         => 'ids',
    'suppress_filters' => true,
) );

wp_defer_term_counting( true );
try {
    foreach ( $post_ids as $post_id ) {
        wp_delete_post( $post_id, true );
    }
} finally {
    wp_defer_term_counting( false );
}

What happens inside that wrapper, per the retrieved sources:

  • Each wp_update_term_count() call sees the deferral flag set, merges the affected term taxonomy IDs into a per-taxonomy array, deduplicates with array_unique(), and returns true without recalculating.
  • wp_defer_term_counting( false ) sets the flag back and calls wp_update_term_count( null, null, true ).
  • That call, with $do_deferred true, iterates the deferred taxonomies and calls wp_update_term_count_now() once per taxonomy.

The mechanism is batching, not skipping. Counts are still recalculated; they are recalculated once per taxonomy after the loop instead of once per post inside it. Because the queue is deduplicated per taxonomy, a term that appears in five hundred deleted posts is counted once at flush time, not five hundred times.

Two placement errors are common:

  • Enabling deferral inside the loop. The first iteration runs with deferral off, and the flag is toggled repeatedly. The batching benefit disappears, and the flush semantics become confusing.
  • Forgetting the flush. If the script exits without wp_defer_term_counting( false ), the deferred queue is never flushed in that request. Counts remain stale until something else triggers a recount. The finally block above exists for this reason.

One caveat about the documentation: the user-contributed note on the wp_defer_term_counting() reference describes the function as designed to speed up inserting multiple posts during an import. The retrieved sources do not document it for bulk deletion specifically. The reasoning that it applies to deletion follows from the code path — deletion removes term relationships, and relationship removal flows through wp_update_term_count() — not from a documented guarantee. Treat it as a mechanism you can verify locally, not a promise from the handbook.

WP-CLI: the flag already exists

If the deletion is a one-off maintenance task, you may not need a custom script. The wp post delete command documents a --defer-term-counting flag, described as recalculating term count in batch for a performance boost. The command also supports --force to skip the trash, which matters because wp_delete_post() moves posts to Trash unless Trash is disabled, the item is already trashed, or $force_delete is true.

wp post delete $(wp post list --post_type=post --post_status=any --format=ids) --force --defer-term-counting

The same placement logic applies: the flag batches the recount, and the command is responsible for flushing. If you are writing your own script instead of using WP-CLI, you own the try/finally.

Verification: do not assume the flush covered everything

After a deferred bulk operation, confirm the counts rather than trusting the flush. The wp term recount command runs wp_update_term_count() on a taxonomy’s terms to bring counts back to the correct value, and its documentation notes it is intended for cases where manual database changes have left counts out of sync.

wp term recount category post_tag

If the recount reports changes, the deferred flush did not cover every taxonomy — usually because a taxonomy was registered after the deferral window, or because a hook modified relationships outside the loop. If it reports no changes, the flush did its job.

For a direct database check, compare the stored count against the relationship table for a sample of terms:

SELECT tt.term_taxonomy_id, tt.count AS stored_count, COUNT(tr.object_id) AS actual_count
FROM wp_term_taxonomy AS tt
LEFT JOIN wp_term_relationships AS tr
    ON tr.term_taxonomy_id = tt.term_taxonomy_id
WHERE tt.taxonomy = 'category'
GROUP BY tt.term_taxonomy_id
HAVING stored_count <> actual_count;

An empty result set is the verification. A non-empty one tells you which terms to recount and, more usefully, that something in your deletion path bypassed the deferral window.

Diagnostic checklist

  1. Confirm deferral is on during the loop. Log wp_defer_term_counting() at the top of the first iteration; it should return true. If it returns false, the flag was set after the loop started or was reset by another callback.
  2. Confirm the flush runs once. The finally block should execute exactly once per script run. If the script is invoked per-post by a queue worker, deferral cannot batch across invocations — the batching boundary is the request.
  3. Confirm no hook re-enters counting outside the window. A deleted_post callback that calls wp_set_object_terms() after the flush will trigger an immediate recount. Check hook priorities and whether the callback runs before or after your finally.
  4. Confirm counts afterward. Run wp term recount for each affected taxonomy and inspect the HAVING query above.

The failure mode is not mysterious once the path is visible: deletion removes relationships, relationship removal calls the term count updater, and the updater recalculates immediately unless deferral is enabled. The fix is not a faster database or a bigger worker pool. It is moving the recount out of the per-post path and into a single flush after the loop — and then verifying that the flush actually ran.