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.

The Doorbell Alert on the ‘There Is Motion at Your Front Door’ Shirt Is a State Machine With a Cat in It

The shirt prints a white rounded notification banner with a bell icon and the sentence There is motion at your front door. Below the banner, inside a circular, green-tinted frame, an orange tabby stares into a doorbell camera from inches away, porch siding and lights behind it. That is the whole design. The joke is not the cat, exactly, and it is not the sentence, exactly. It is the collision between a system’s flat, procedural announcement and the specific, absurd event that triggered it.

Most novelty shirts about pets stop at affection. This one borrows the grammar of an alert. The banner is the kind of message a doorbell camera pushes to a phone: short, declarative, stripped of affect. The circular viewport below is the evidence the camera attached. Read together, the two halves form a tiny state machine — condition detected, notification dispatched, image captured — and the punchline is that the condition was a cat pressing its face against the lens.

What the banner promises and what the viewport delivers

The sentence is a promise about the world: something moved, and it moved at your front door. It is deliberately unhelpful about what moved. That vagueness is the design’s engine. A doorbell alert is a low-resolution claim — motion, location, timestamp — and the user is expected to resolve the ambiguity by looking at the attached frame. The shirt reproduces that contract visually: text on top, image below, the second half answering the first.

The answer is a wide-eyed orange tabby, close enough to the lens that its face fills the circular crop. The green tint reads as night-vision or low-light camera processing, though I am describing the printed color, not certifying what hardware produced it. The porch siding and lights place the scene at a threshold. The cat is not doing anything dramatic. It is simply present, at the door, and that is enough to satisfy the alert’s literal terms while defeating whatever the viewer expected.

This is why the design works as a joke about systems rather than a joke about cats. The alert is technically correct. Motion occurred. The front door was involved. The system did its job. The humor lives in the gap between a correct notification and a useful one.

The circular crop is doing structural work

The viewport is round, and that shape matters. A rectangular photo would read as a picture of a cat on a porch. The circle reads as a feed — a lens, a peephole, a camera aperture. It frames the cat as something being watched rather than something being photographed. The banner above supplies the watcher’s voice; the circle supplies the watcher’s eye. Together they stage a small surveillance scene in which the monitored subject is a pet with no idea it is being monitored.

I would not claim the design documents any particular doorbell product. The visual observation is a generic notification banner and a generic camera-style viewport. The joke depends on the reader recognizing the pattern, not on brand fidelity. That generality is a strength: the shirt works for anyone who has ever opened a phone to find that the “motion” was a leaf, a delivery, or a cat.

Why the wording beats a caption

A caption like “my cat rings the doorbell” would explain the joke. The printed sentence does not explain anything. It reports. That restraint is what makes the shirt wearable in the way a good error message is readable: it states the condition and stops. The reader supplies the reaction.

There is a useful distinction here between a slogan and a usable message. A slogan asks you to agree with a feeling. A notification asks you to act on a fact. The shirt’s text is the second kind, and the image is the fact’s disappointing resolution. That structure — terse claim, anticlimactic evidence — is familiar to anyone who has triaged an alert queue. The shirt is funny for the same reason a monitoring dashboard is funny at 2 a.m.: the system is working exactly as designed, and the design is the problem.

If you want the object itself, it is listed as the There Is Motion At Your Front Door shirt from Quirky Cotton Collective. The design is what the listing shows: banner, bell, sentence, circular cat view. Nothing in the print promises that the shirt will detect motion, improve your security, or make your cat any less likely to loiter at the door.

Reading the joke without overclaiming it

I am not going to tell you where the meme came from or which platform’s algorithm made it travel. I do not have retrieved evidence for that, and the shirt does not need the origin story to function. What the artwork supports is narrower and more interesting: a two-part composition that mimics an alert and its attachment, with the cat as the unresolved variable. The humor is structural, not biographical. You do not need to own a doorbell camera or a cat to read it; you only need to have received a notification that turned out to be less urgent than its wording suggested.

That is the argument I would make for the design as a piece of visual writing. It uses the conventions of system messaging — brevity, declarative mood, evidence attached — and lets the evidence undercut the message without contradicting it. The cat is not a failure of the alert. The cat is the alert, correctly delivered, to a human who expected something else.

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

The Dashed Circle on the ‘I Had Potential’ Shirt Is the Whole Argument

The shirt reads I HAD POTENTIAL in white capitals. Beneath the words, a white curved ramp descends from an upper-left ledge to a lower-right flat ledge. At the top of the ramp, a dashed empty circle sits where a ball would rest. At the bottom, a solid white ball sits on the flat. The ramp is curved, not a straight diagonal. That curve is the first clue that this is not a generic slogan shirt with a physics sticker slapped on it.

The design is a diagram of a system that has already run. The dashed circle is the initial state: a ball at rest at the top of a potential well, marked as absent because the event has already happened. The solid ball is the final state: the same object, now at the bottom, having converted potential energy into motion and then into rest again. The words above the diagram are not a caption. They are the system’s own log line, written in the past tense because the transition is complete.

That is the useful argument of this shirt: it treats potential as a technical term with a precise meaning, then uses the diagram to show that the meaning has already been spent. The joke is not that the wearer is lazy. The joke is that the wearer is describing a closed system. The ball did what balls do. The ramp did what ramps do. The dashed circle is the only remaining evidence that anything else was ever possible.

What the Drawing Actually Shows

Look at the geometry. The dashed circle is not a ghost or a motion trail. It is an empty outline at the upper ledge, the same size as the solid ball below. In physics-diagram conventions, a dashed outline often marks a position that is no longer occupied or a boundary that is not a physical object. Here it reads as the starting position, vacated. The solid ball at the lower right is the current position, at rest on a flat section. The curved ramp connects them. The curve matters because a straight diagonal would read as a simple slide, a one-step drop. A curve implies acceleration, a changing slope, a path with its own dynamics. The ball did not fall; it rolled.

The words are set in plain capitals, no serif, no decoration. The diagram is white on the garment, matching the text. There is no arrow, no axis, no label other than the sentence itself. That minimalism is doing work: it presents the scene as a finished result rather than a problem to be solved. A diagram with arrows and labels would invite the viewer to calculate. This diagram invites the viewer to recognize. The calculation is over. The result is on the shirt.

The phrase I had potential is grammatically past tense. The diagram is grammatically present tense: the ball is there now. The tension between those two tenses is the joke. The potential is gone, but the ball remains. The shirt does not say the ball was wasted. It says the ball is where the ramp put it. That is a more precise claim than the usual sarcastic slogan, and it is why the design holds up under inspection.

Why the Double Meaning Matters

In everyday speech, potential means promise: the quality of being capable of more. In physics, potential energy is the energy an object has because of its position in a field. The shirt uses both meanings at once, and the diagram keeps them from collapsing into each other. The dashed circle is the everyday meaning: the shape of what could have been. The solid ball is the physics meaning: the object that actually moved. The ramp is the field. The words are the narrator’s summary.

This is not a new observation about the phrase. The phrase I had potential is a common meme construction, and the shirt’s retail title calls it a physics pun. But the drawing is what makes the pun specific. A shirt that only said I had potential would be a generic self-deprecation. A shirt that only showed a ball on a ramp would be a physics diagram. The combination works because the diagram supplies the evidence that the sentence withholds. The sentence says the potential is gone. The diagram shows where it went.

That is also why the shirt is not really about laziness. Laziness would be a ball that never left the top ledge. This ball left. The dashed circle is empty because the ball moved. The joke is not that the wearer failed to act. The joke is that the wearer is describing the outcome of having acted, with the flat tone of a system log. The potential was real. The ramp was real. The result is a ball at the bottom. The shirt is a post-mortem, not an excuse.

The Difference Between a Slogan and a Usable Promise

Technical readers spend a lot of time around promises: API contracts, SLAs, schema guarantees, deprecation notices. A slogan is a sentence that sounds like a promise but has no mechanism behind it. A usable promise names the conditions under which it holds. The I Had Potential shirt is funny because it is a slogan that has been converted into a diagram, and the diagram shows the mechanism. The promise was potential. The mechanism was a ramp. The outcome was a ball at rest. Nothing is missing except the part where the potential was supposed to turn into something else.

That is the work-culture reading, and it is worth stating plainly: the shirt is a boundary object. It can be worn as self-deprecation, as a comment on a project that shipped and then flattened, or as a quiet refusal to dress up a closed system as an open one. The diagram does not accuse anyone. It just shows the states. The dashed circle is the state that is no longer occupied. The solid ball is the state that is. The words are the transition log.

If you want the object itself, the shirt is the I Had Potential Funny Physics Pun Sarcastic Minimalist Quote from Quirky Cotton Collective. The design is the argument. The rest is garment.

Reading the Shirt as a State Machine

A state machine has states, transitions, and a current state. This shirt has two visible states: the dashed circle at the upper ledge and the solid ball at the lower flat. The transition is the curved ramp. The current state is the solid ball. The words I HAD POTENTIAL are the transition log, written after the fact. There is no arrow back up the ramp. The diagram is a one-way system, and the shirt is honest about it.

That honesty is what separates this design from a generic sarcastic tee. The shirt does not claim the ball could roll back up. It does not claim the ramp was unfair. It does not claim the dashed circle was a better ball. It shows a completed transition and lets the viewer supply the tone. The tone can be dry, resigned, or amused. The diagram does not care. It is a diagram.

For readers who spend their days tracing symptoms to root causes, that is a familiar posture. You look at the current state, you find the transition that produced it, and you stop arguing with the physics. The shirt is a small, wearable version of that posture. It is not a lesson. It is a diagram with a sentence on top, and the sentence is in the past tense because the transition is done.

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

Why Your Updated YouTube URL Still Renders the Old Embed: Diagnosing the _oembed_* Hash Key in wp_postmeta and the Invalidation WordPress Never Runs

You changed the YouTube URL in the post. You saved. The editor showed the new URL. The front end still renders the old video’s iframe. The post content is correct; the rendered HTML is not. This is not a caching plugin problem, not a theme problem, and not a YouTube problem. It is a key-mismatch problem in wp_postmeta, and the invalidation routine that would fix it is documented as unused by core.

Symptom

The editor updates the URL. The post saves cleanly. The front end serves the previous embed’s HTML. If you inspect the rendered page, the iframe’s video ID is the old one. If you inspect the post content in the database, the URL is the new one. Both facts are true simultaneously because the rendered embed does not come from the post content — it comes from a post meta row keyed by a hash of the URL.

Before touching post meta, rule out page-level caching and CDN HTML caching. A stale rendered page and a stale _oembed_ row produce the same visual symptom and have completely different fixes. The retrieved sources for this article do not cover host or CDN cache layers, so treat that check as a prerequisite, not a conclusion. If a hard refresh with cache bypass still shows the old iframe, continue.

Trace

The relevant code path is WP_Embed::shortcode(), which is registered as the shortcode callback and also runs via autoembed() on the_content. The method derives a cache key from the URL and the shortcode attributes:

$key_suffix = md5( $url . serialize( $attr ) );
$cachekey = '_oembed_' . $key_suffix;
$cachekey_time = '_oembed_time_' . $key_suffix;

That is from the WP_Embed::shortcode() reference. When a post ID is available, the method reads the cached HTML and cache time from that post’s meta using those keys:

if ( $post_id ) {
    $cache = get_post_meta( $post_id, $cachekey, true );
    $cache_time = get_post_meta( $post_id, $cachekey_time, true );

The TTL defaults to one day and is filterable:

$ttl = apply_filters( 'oembed_ttl', DAY_IN_SECONDS, $url, $attr, $post_id );

The cached branch is entered when either $this->usecache is true or the cache is recent. If the cached value is the sentinel {{unknown}}, the method returns the URL as a link rather than an embed. If the cached value is non-empty HTML, it is passed through the embed_oembed_html filter and returned. The filter reference describes its first parameter as “the cached HTML result, stored in post meta.”

So the rendered iframe on the front end is not read from post_content. It is read from wp_postmeta under a key derived from the URL. Change the URL, and the derived key changes. The old key’s row is not overwritten; it is orphaned. The new key has no row yet, so the next render on the front end will fetch fresh HTML from the provider and write a new row — unless something prevents that fetch path from running, or unless the front end is serving a page cached before the save.

Root cause

The mental model “the cache is stale” is slightly wrong and leads to the wrong fix. The cache is not stale. It is keyed to a URL that no longer appears in the post. The old row is dead weight; the new URL has no row. The correct model is a key mismatch, not a stale value.

WordPress ships a method that would clean this up. WP_Embed::delete_oembed_caches() iterates the post’s meta keys and deletes every one beginning with _oembed_:

foreach ( $post_metas as $post_meta_key ) {
    if ( str_starts_with( $post_meta_key, '_oembed_' ) ) {
        delete_post_meta( $post_id, $post_meta_key );
    }
}

The reference page for that method states plainly: “Deletes all oEmbed caches. Unused by core as of 4.0.0.” The WP_Embed class listing repeats the same note. The method exists; core does not call it.

A 2012 wp-trac mailing-list message records a proposal to move oEmbed cache deletion to pre_post_update so cached metadata would be more likely to be used. That is a proposal recorded on a mailing list, not evidence that shipped core ever hooked delete_oembed_caches() to save_post. The documented fact is the one on the reference page: as of 4.0.0, core does not run it.

The practical consequence for a self-maintained install is that oEmbed meta is data the site owns and must occasionally groom, the same way it treats transients or orphaned post meta. WordPress will not reconcile it on save.

The TTL nuance that explains “sometimes it fixes itself”

Publishers often report that the embed eventually corrects itself. That is consistent with the code. The cached branch is gated on $this->usecache || $cached_recently. On a normal front-end render, usecache is true, so the cached HTML is served regardless of age. The TTL matters on paths that set usecache = false — notably WP_Embed::cache_oembed(), which the reference shows setting usecache to false while re-running the shortcode and autoembed over the post content, then restoring it to true.

That is why behavior differs between the front end and the admin re-cache path. The front end will happily serve a cached row indefinitely as long as the key matches. The admin path can force a fresh fetch. If the publisher’s symptom resolves after an admin save but not after a front-end reload, the difference is the usecache flag, not the TTL.

The {{unknown}} sentinel

Failures are cached too. When wp_oembed_get() returns false and there is no existing cache, WP_Embed::shortcode() writes the literal string {{unknown}} under the cache key. On subsequent renders, the cached branch sees that sentinel and returns the URL as a link via maybe_make_link(). A publisher who sees a bare link where an embed used to be may be looking at a cached failure from a transient provider outage, not a provider outage happening right now. The troubleshooting will be mis-timed unless the stored value is inspected.

Fix

The fix is a deliberate, narrowly scoped purge of the affected post’s _oembed_ meta, followed by a render that repopulates the new key. Do not run a blanket DELETE FROM wp_postmeta WHERE meta_key LIKE '_oembed_%' on a live publishing site. That forces every embedded URL across the site to be re-fetched on next render, which is a self-inflicted provider-rate and page-latency event. Scope the purge to the affected post and, where practical, the affected key.

Step 1: Identify the post and list its oEmbed keys

Using WP-CLI:

wp post meta list 1234 --keys='_oembed_*' --format=table

Or in PHP, using get_post_custom_keys() as the core method does:

$keys = get_post_custom_keys( 1234 );
foreach ( $keys as $key ) {
    if ( str_starts_with( $key, '_oembed_' ) ) {
        echo $key . "\n";
    }
}

You will see pairs: _oembed_<md5> and _oembed_time_<md5>. If the post has been edited several times, there may be several pairs, one per historical URL-and-attributes combination.

Step 2: Confirm which key corresponds to the current URL

Compute the hash the same way core does. The URL must match exactly what WP_Embed::shortcode() receives after the &amp; to & replacement, and the attributes must match the serialized form. For a bare URL with default attributes, a quick check in a WP-CLI eval is:

wp eval '
$url = "https://www.youtube.com/watch?v=NEWVIDEOID";
$attr = wp_parse_args( array(), wp_embed_defaults( $url ) );
echo "_oembed_" . md5( $url . serialize( $attr ) ) . "\n";
'

If the computed key is not present in the post’s meta, the new URL has no cache row yet and the next render will fetch fresh. If the computed key is present and its value is the old iframe HTML, the row is wrong for the current URL and should be removed. If the computed key is present and its value is {{unknown}}, the row is a cached failure and should be removed.

Step 3: Delete only the affected keys

wp post meta delete 1234 _oembed_<md5>
wp post meta delete 1234 _oembed_time_<md5>

Or, to remove every oEmbed row for that one post — the equivalent of running delete_oembed_caches() manually — use a scoped WP-CLI eval:

wp eval '
$post_id = 1234;
foreach ( get_post_custom_keys( $post_id ) as $key ) {
    if ( str_starts_with( $key, "_oembed_" ) ) {
        delete_post_meta( $post_id, $key );
    }
}
'

That is the same loop the core method uses. It is safe because it is scoped to one post ID. It is not a substitute for a site-wide purge, and it should not be.

Step 4: Let the next render repopulate

Load the post on the front end with any page cache bypassed. WP_Embed::shortcode() will find no cached row, call wp_oembed_get(), and write the new HTML and timestamp under the new key. If the provider returns false, the sentinel {{unknown}} will be written instead, and the front end will show a link. That is a provider-side failure, not a meta problem.

Why a save_post hook is the wrong reflex

The obvious-looking fix is to hook save_post and call delete_oembed_caches() on every save. That fires on unrelated edits — title changes, category changes, status transitions — and purges oEmbed rows that did not need purging. The save_post reference also warns that calling a function such as wp_update_post() inside a save_post callback creates an infinite loop unless the callback is unhooked first. A purge callback that itself triggers a save is a plausible way to compound the problem.

For a small publishing team, a targeted admin action or a WP-CLI command is a better fit than an automatic hook. The purge is rare, the scope is one post, and the operator can see what was deleted.

Verification

After the purge and a fresh render, confirm three things:

  1. The rendered iframe’s video ID matches the URL in the post content. Inspect the iframe src in DevTools. If it does not match, the front end is serving a page cached before the save, or a different code path is producing the embed.

  2. The stored meta value matches the rendered HTML. Query the row:

    SELECT meta_key, LEFT(meta_value, 120) AS value_preview
    FROM wp_postmeta
    WHERE post_id = 1234 AND meta_key LIKE '_oembed_%'
    ORDER BY meta_key;

    You should see one _oembed_<md5> row containing the new iframe HTML and one _oembed_time_<md5> row containing a recent Unix timestamp. If the value is {{unknown}}, the provider fetch failed and the front end will show a link.

  3. The timestamp is recent. A timestamp older than the edit means the row was not repopulated — either the render did not reach WP_Embed::shortcode(), or the page was served from cache.

If all three check out and the front end still shows the old video, the problem is above the oEmbed layer: page cache, object cache, CDN, or a theme/plugin filter on embed_oembed_html that is substituting HTML. The filter reference shows the filter receives the cached HTML, the URL, the attributes, and the post ID, so a filter that ignores the URL and returns a fixed string will produce exactly this symptom.

Diagnostic checklist

  • Confirm the rendered iframe’s video ID against the URL in post_content.
  • Confirm the stored _oembed_ value for the current URL’s hash.
  • Confirm the stored _oembed_time_ value is recent.
  • Confirm whether a page cache or CDN is serving HTML rendered before the save.
  • Confirm whether an embed_oembed_html filter is substituting HTML.
  • Confirm whether the stored value is {{unknown}}, which indicates a cached provider failure rather than a stale success.

FAQ

Why does the old embed come back after I delete the meta row?

If the front end is served from a page cache that was populated before the deletion, the cached HTML still contains the old iframe. The meta row is gone, but the rendered page is not. Bypass the page cache and re-render.

Why does the embed show as a plain link instead of a video?

The stored value is likely {{unknown}}, the sentinel WP_Embed::shortcode() writes when wp_oembed_get() returns false and there is no existing cache. Delete the key and let the next render retry the provider. If the provider is still failing, the sentinel will be written again.

Does the TTL eventually fix this on its own?

Not on a normal front-end render. The cached branch is entered when $this->usecache is true, which it is by default, regardless of the TTL. The TTL matters on paths that set usecache = false, such as WP_Embed::cache_oembed(). A front-end reload will keep serving the cached row as long as the key matches.

Can I just run DELETE FROM wp_postmeta WHERE meta_key LIKE '_oembed_%'?

You can, but on a live publishing site it forces every embedded URL across the site to be re-fetched on next render. That is a provider-rate and page-latency event you are inflicting on yourself. Scope the purge to the affected post.

Why doesn’t WordPress clean this up on save?

WP_Embed::delete_oembed_caches() exists and does the cleanup, but the reference page states it is “Unused by core as of 4.0.0.” Core does not call it on save. The oEmbed meta is data the site owns and must groom.

Is this specific to YouTube?

No. The mechanism is provider-agnostic. Any URL that routes through WP_Embed::shortcode() and caches under _oembed_<md5> is subject to the same key-mismatch behavior when the URL changes.

The Root Vegetable Ring on the ‘Let’s Root For Each Other’ Shirt Is a Diagram of a Promise

The artwork on the Let’s Root For Each Other And Watch Each Other Grow Garden shirt is a closed loop. The words LET’S ROOT FOR EACH OTHER AND WATCH EACH OTHER GROW wrap around four smiling root vegetables — carrot, radish, beet, and onion — each with its roots drawn down into visible soil. The sentence is a ring; the vegetables sit inside it. That layout is the argument, and it is worth reading closely because the sentence contains two different meanings of root and the drawing quietly enforces one of them.

Take the first clause. Root for is the idiom of the spectator: you cheer, you hope, you want someone else to succeed. It costs the rooter nothing except attention. Take the second clause. Root as a noun is the underground structure that anchors a plant and draws water and nutrients from the soil. The shirt uses both senses in one breath, and the pun works because the vegetables are literally rooted while the sentence asks us to figuratively root. The design does not resolve the pun; it stages it.

Now look at what the vegetables are doing. They are not cheering. They are not holding signs. They are smiling with their roots exposed in soil, which is to say they are shown in the condition the second clause describes: growing, anchored, drawing from the ground. The carrot, radish, beet, and onion are the objects of the rooting, not the rooters. The sentence surrounds them the way a fence surrounds a bed. The graphic makes the mutual-support claim visual by putting the supported things at the center and the supporting words at the perimeter.

That is a more careful design than a slogan shirt usually manages. Most mutual-support graphics put two figures side by side, or two hands, or an arrow between two boxes. This one puts four different root vegetables in a single frame and lets the shared soil do the work. The carrot is long and tapered; the radish is round and small; the beet is bulbous; the onion is layered. They are not the same plant. The design does not ask them to be. It asks them to be in the same dirt, under the same sentence, growing at whatever rate each one grows.

Here is where the shirt becomes useful as an object to think with, and where I want to be precise about what it does and does not claim. The phrase watch each other grow is not the same as make each other grow. Watching is a passive, attentive act. It implies presence and patience, not intervention. The shirt does not promise that rooting for someone will cause them to grow; it pairs the cheering with the observing. That distinction matters in any team setting where support gets confused with management. A colleague who roots for you is not the same as a colleague who prunes you. The artwork keeps the roots in the soil and the words outside the bed, which is a fairly exact picture of the difference.

The second thing the design gets right is that the growth is plural and uneven. Four vegetables, four shapes, one soil line. Nothing in the drawing suggests they will emerge at the same time or reach the same size. The sentence says each other, not together. Mutual support, in this reading, is not synchronized support. It is the agreement to keep watching while the carrot does carrot things and the onion does onion things. That is a more defensible promise than the usual team slogan, which tends to imply that everyone is on the same growth curve and that the curve is upward.

I would not push the metaphor further than the drawing supports. The shirt is not a diagram of a software system, a security model, or an engineering process, and I am not going to pretend the root vegetables are a load-balancing diagram. What it is is a compact visual statement about a specific kind of relationship: the one where you are close enough to notice someone’s progress and far enough away not to be directing it. The ring of text and the ring of soil are two boundaries around the same idea. The words are the outer boundary — the social claim. The soil is the inner boundary — the physical condition. The vegetables sit between them, which is where growth actually happens.

If you are the kind of reader who keeps a mental file of terse messages and boundary wording, this shirt belongs in it. The phrase let’s root for each other is an invitation, not an instruction. The phrase and watch each other grow is a commitment to attention, not a promise of outcome. The drawing adds the missing clause: in the same soil, at our own rates. That is the whole design, and it is enough. The lets root for each other shirt is a wearable version of a sentence that is easy to say and harder to mean, and the artwork is what makes the difference visible.

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