DEV Community

Cover image for Your WordPress "URL Path" column is not reading a slug, it is computing one
Lucas Fenwick
Lucas Fenwick

Posted on

Your WordPress "URL Path" column is not reading a slug, it is computing one

Add a URL Path column to the WordPress posts list by enabling Custom Admin Columns in WP Adminify: go to WP Adminify > Productivity, toggle on Custom Admin Columns, tick Show "URL Path" Column for Post Types, pick which post types get it from the Posts, Pages and custom post type options that appear, and save, and a URL Path column shows each row a path like /hello-world/, which WordPress computes at render time from post_name plus your permalink structure plus, on hierarchical types, the whole ancestor chain, and not from a stored URL field, which is why the column is blank or a ?p=123 on drafts, because core saves an empty post_name for draft, pending and auto-draft posts.

That is the whole answer. The rest of this post is why the distinction between a stored slug and a computed path produces three separate surprises, one of which is a genuine bug that will hand you a wrong URL with a straight face.

The tooling is not the interesting part

Let me concede this up front, because the SERP is full of it.

Admin Slug Column has 5,000+ active installs, is tested to WordPress 6.9.5, and adds a column it literally calls URL Path across every post type including WooCommerce products. Slug Search and Admin Columns adds ID and Slug columns and wires them into the default search box. Show Page Slug in Admin does the same for Pages. There is an open source version on GitHub. All correct, all fine.

Every one of them describes the column as showing the slug.

It is not showing the slug.

Stored versus computed

WordPress stores exactly one slug per post: post_name, in wp_posts. One segment. hello-world. There is no stored URL column anywhere in the schema.

What a URL Path column renders is assembled at request time by get_permalink():

post_name
+ permalink structure          (Settings > Permalinks)
+ post type rewrite slug       (register_post_type's rewrite arg)
+ ancestor chain               (hierarchical types, via get_page_uri)
+ %category% and friends       (if your structure uses rewrite tags)
Enter fullscreen mode Exit fullscreen mode

Five inputs, one of which is stored on the row. Every surprise below is a consequence of the other four.

Surprise 1: your drafts come back blank

This is the one you will hit within a day of switching the column on, and it is core doing it on purpose. From wp_insert_post():

if ( empty( $post_name ) ) {
    if ( ! in_array( $post_status, array( 'draft', 'pending', 'auto-draft' ), true ) ) {
        $post_name = sanitize_title( $post_title );
    } else {
        $post_name = '';
    }
}
Enter fullscreen mode Exit fullscreen mode

An unpublished post has an empty string for a slug. Not a placeholder, not a guess from the title. Empty.

And that is only half of it. wp_force_plain_post_permalink(), added in WordPress 5.7, decides whether get_permalink() takes the pretty branch or the plain one:

if (
    is_post_status_viewable( $post_status_obj ) ||
    ( $post_status_obj->private && current_user_can( 'read_post', $post->ID ) ) ||
    ( $post_status_obj->protected && $sample )
) {
    return false;
}

return true;
Enter fullscreen mode Exit fullscreen mode

Draft is not viewable. Pending is not viewable. Scheduled (future) is a protected status, and without $sample it does not get the exemption either. So all three return true, and get_permalink() hands back ?p=123.

Admin Slug Column greys those rows out and says the path "isn't an official URL yet", which is the closest anyone in this SERP gets to explaining it. Now you know the two functions responsible.

If you want the path a draft would have, that is a different function: get_sample_permalink(), which is what the Gutenberg permalink panel uses, and it returns the structure with a %pagename% placeholder rather than a finished path.

Surprise 2: a draft parent silently corrupts its children's paths

This one is not a display quirk. This is a wrong answer.

Here is how get_page_uri() assembles a hierarchical path:

foreach ( $page->ancestors as $parent ) {
    $parent = get_post( $parent );
    if ( $parent && $parent->post_name ) {
        $uri = $parent->post_name . '/' . $uri;
    }
}
Enter fullscreen mode Exit fullscreen mode

Read the if twice. An ancestor whose post_name is empty is skipped, silently, with no notice and no fallback.

From surprise 1, we know exactly which posts have an empty post_name: drafts, pending posts and auto-drafts.

So:

  • You have a page Services, still a draft, being written this week.
  • You publish Services > Pricing under it, because publishing children first is a normal staging workflow.
  • Your URL Path column shows /pricing/.
  • The URL that page will actually have, the moment Services goes live, is /services/pricing/.

The column is not lying about post_name. It is faithfully reporting a computation that dropped a segment. If somebody is building a redirect map, a sitemap submission or an SEO audit off that column, they are building it on a path that does not exist and will never exist.

Go check this on your own site:

SELECT child.ID, child.post_name AS child_slug, parent.post_status AS parent_status
FROM wp_posts child
INNER JOIN wp_posts parent ON parent.ID = child.post_parent
WHERE child.post_status = 'publish'
  AND parent.post_status IN ( 'draft', 'pending', 'auto-draft' );
Enter fullscreen mode Exit fullscreen mode

Every row that comes back is a page whose displayed path is missing a segment.

Surprise 3: you can see the slug and you still cannot search it

This is the one that gets the "wait, really?" reaction, because it is the actual reason most people wanted the column.

WP_Query::parse_search() opens with:

$default_search_columns = array( 'post_title', 'post_excerpt', 'post_content' );
Enter fullscreen mode Exit fullscreen mode

post_name is not on that list. It has never been on that list.

The post_search_columns filter arrived in WordPress 6.2 and looked like the fix, except it validates your return value against those same three columns. There is no filter, no argument and no setting that makes the admin Search Posts box find a post by its slug.

So you can add a column showing /summer-sale-2025/, look straight at it, type summer-sale-2025 into the search box above it, and get nothing.

The proof that this is a real gap and not me splitting hairs: somebody shipped an entire separate plugin called Slug Search and Admin Columns. The column half and the search half are two products because core makes them two problems.

If you need it, the query-level escape hatches are WP_Query's name and post_name__in arguments, or get_page_by_path(). None of them are wired to the admin search box.

Surprise 4: non-Latin slugs render percent-encoded

Smaller, but it will look like a bug the first time you see it. sanitize_title_with_dashes() does this to UTF-8 titles:

if ( wp_is_valid_utf8( $title ) ) {
    if ( function_exists( 'mb_strtolower' ) ) {
        $title = mb_strtolower( $title, 'UTF-8' );
    }
    $title = utf8_uri_encode( $title, 200 );
}
Enter fullscreen mode Exit fullscreen mode

post_name is stored already percent-encoded. A column that prints post_name raw shows %e3%81%93%e3%82%93 where your browser's address bar helpfully renders the actual characters. Admin Slug Column specifically advertises multibyte support, which is a good hint about what the naive implementation does.

If you write your own column, rawurldecode() before display, and be aware you are then showing something that is not byte-identical to the stored value.

Also: wp_unique_post_slug() is why you have a -2

While we are in here. wp_unique_post_slug() appends -2, -3 and so on when a slug collides, and it scopes that check by post type, parent and status. Two pages with the same title under different parents can both be about. Two posts cannot. A URL Path column is the fastest way to spot the accumulated -2s from duplicated posts, which is honestly one of the better arguments for switching it on.

The cost ladder in one settings panel

Adminify's Custom Admin Columns panel has four checkboxes and they cost wildly different amounts:

Column What it costs Sortable?
Post/Page ID Nothing, the ID is already on the row object Yes, it is the primary key
Taxonomy ID Nothing, term_id is a real column on wp_terms Yes, no join needed
URL Path A computed permalink per row, plus an ancestor walk per row on hierarchical types No. There is nothing to ORDER BY
Last Login A wp_usermeta join Yes, but it drops rows for users missing the meta key

That "No" in the URL Path row is worth sitting with. post_name is a real column, so orderby=name works, but the displayed path is a computed string that exists nowhere in the database. You cannot sort a list by a value the database does not have without computing it for every row first.

Changing a path is where the actual risk lives

Editing the slug of an already published post is covered. wp_check_for_changed_slugs() stores the old one in _wp_old_slug, and wp_old_slug_redirect() 301s the old URL when it 404s.

Not covered:

  • changing your permalink structure in Settings > Permalinks
  • changing a post type's rewrite slug in register_post_type()
  • reparenting a page, which changes the path without touching post_name at all

That third one is the trap, and it is the same mechanism as surprise 2. The slug never changed, so no _wp_old_slug was recorded, so there is no redirect. URL redirection is where you clean that up manually, and replacing an image without changing its URL is the same class of problem on the media side.

The code route

Two hooks, and note the contract, because it is the opposite of the taxonomy one:

// 1. Register the column.
add_filter( 'manage_posts_columns', function ( $columns ) {
    $columns['url_path'] = 'URL Path';
    return $columns;
} );

// 2. Print the value. This is an ACTION. Echo, do not return.
add_action( 'manage_posts_custom_column', function ( $column, $post_id ) {
    if ( 'url_path' !== $column ) {
        return;
    }
    $post = get_post( $post_id );

    if ( in_array( $post->post_status, array( 'draft', 'pending', 'auto-draft' ), true ) ) {
        echo '<span style="opacity:.5">not published</span>';
        return;
    }

    echo esc_html( rawurldecode( wp_make_link_relative( get_permalink( $post_id ) ) ) );
}, 10, 2 );
Enter fullscreen mode Exit fullscreen mode

manage_posts_columns registers, manage_posts_custom_column prints, and it is an action with the signature ( string $column_name, int $post_id ) where you echo. The taxonomy equivalent, manage_{$taxonomy}_custom_column, is a filter where you return. Same feature, opposite contracts, and mixing them up is an hour of your life.

wp_make_link_relative() is what turns the absolute permalink into the /hello-world/ form the screenshot shows. Use manage_pages_columns for Pages, manage_{$post_type}_posts_columns for a specific custom post type, and put the whole thing in a must-use plugin because in functions.php it dies on a theme switch.

The checkbox route

I ran this pass with WP Adminify's Productivity module. Productivity tab, toggle on Custom Admin Columns, tick Show "URL Path" Column for Post Types, then a second option appears, "URL Path" Column for Post Types, with checkboxes for Posts, Pages and, per the docs, every registered custom post type added dynamically. Save.

That per-post-type selector is the one genuine advantage over the free plugins, and it is worth naming precisely because everything else in this post has been a caveat: every other checkbox in that panel is all-or-nothing, and every wordpress.org equivalent applies to all post types. If you run eight and want the path on two, this is the only one that asks.

The same panel carries the other list screen columns people otherwise snippet in one at a time, a Post/Page ID column, a Comment ID column with Parent ID, a Taxonomy ID column, a post thumbnail column and the Last Login column for Users. If you want arbitrary columns rather than the fixed set, the standalone Admin Columns Editor is the bigger hammer, adding an ACF field as an admin column is the next step for custom fields, the rundown of which admin columns actually earn their width is a sane map before you switch five of them on, and the wider Productivity toolset sits in the same place.

Two related things from the same list screen that pair with this column: duplicating a page is the single fastest way to accumulate -2 slugs, and custom post type ordering is the other reason people spend time in these list tables.

The conditions I would put on it

It is a display column. It prints a computed string. The marketing line is "Find, Copy & Share URLs Instantly", and what the screenshot actually shows is /hello-world/, a relative path you cannot paste into a browser or send to a client. WordPress already puts a copyable absolute URL in the row actions View link on every published row. The column's real value is scanning a hundred rows at once, which is a genuinely good reason to have it, just not the one on the box.

It also costs horizontal space on a list already carrying Title, Author, Categories, Tags, Comments and Date, and Screen Options is where you claw that back.

And the thing worth taping to the monitor: on any page whose parent is a draft, that column is showing you a path with a segment missing. Run the SQL above before you trust it for anything that matters.

Demo Video

Which columns are worth the width: https://wpadminify.com/necessary-admin-columns-for-wordpress

Top comments (0)