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_postsis filtered tofalsesomewhere in a theme or plugin. Check whether the trashed post was created or trashed through a path that bypassedwp_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 apostand apagecan share a slug. But a custom post type with the same slug as apostwill not collide in the flat branch. If you see a collision across types, confirm which branch your post type takes. Hierarchical types checkpost_type IN ( %s, 'attachment' )andpost_parent; flat types check a singlepost_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. Nopost_typefilter. An attachment namedlaunch-noteswill force-2on a post. - A row exists that you did not expect. Imports, staging-to-production migrations, and REST API clients that set
slugdirectly 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:
- Query
wp_postsfor the base slug across all statuses. The only row that should match is the one you intend to hold it. - Create or update the post that needs the slug. Check the returned
post_name— through the editor, throughwp post get <id> --field=post_name, or through the REST API response. - 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.
- 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.