DEV Community

Cover image for WordPress stores two IDs for every category, and the ID column in your admin only shows one of them
Lucas Fenwick
Lucas Fenwick

Posted on

WordPress stores two IDs for every category, and the ID column in your admin only shows one of them

Add an ID column to the WordPress taxonomy list tables by enabling Custom Admin Columns in WP Adminify: go to WP Adminify > Settings > Productivity, scroll to Custom Admin Columns, tick Show "Taxonomy ID" Column for all possible types of taxonomies, and save, and an ID column appears after Count on Categories, Tags and every custom taxonomy screen, and the number it prints is the term_id, the same value that sits in each row edit link as term.php?taxonomy=category&tag_ID=3, which is not the term_taxonomy_id that wp_term_relationships uses to join posts to terms.

That is the whole answer. The rest of this post is where the number was hiding before you added the column, why WordPress has two IDs per term, which one the relationship table uses, and why code that confuses them passes on your laptop and fails on a client site.

The number was never missing

Most admin column tricks are recoveries, and this is one of them. Open Posts > Categories and view source on any term row:

<tr id="tag-3" class="level-0">
  <th scope="row" class="check-column">
    <input type="checkbox" name="delete_tags[]" value="3">
  </th>
  <td class="name column-name">
    <a href="term.php?taxonomy=category&amp;tag_ID=3&amp;post_type=post">Dashboard</a>
Enter fullscreen mode Exit fullscreen mode

The ID is in the row element, in the bulk action checkbox, and in the Edit, Quick Edit and Delete links. Core queried it, loaded it, and printed it into the markup. It just never puts it in a cell you can read.

Which is why the standard workaround exists. TaxoPress, BasicWP and MyThemeShop all teach the same move: hover the category, read tag_ID=3 out of the browser status bar, transcribe it. Reveal IDs with its 40,000+ active installs and Catch IDs automate it. All correct, all fine, and all of them stop at the same sentence: "that number is the category ID".

WordPress has two numbers for that category.

A small aside, because you have probably wondered

The parameter is called tag_ID on every taxonomy. Categories. WooCommerce product_cat. Your custom genre. Tags arrived in WordPress 2.3 and the parameter name arrived with them, and it survived the 4.5 move of term editing from edit-tags.php to term.php. If a category edit screen saying tag_ID has ever looked like a bug to you, it is just a very old name.

WordPress keeps two IDs per term

Here is the taxonomy side of the schema, from the database handbook:

wp_terms                term_id, name, slug, term_group
wp_term_taxonomy        term_taxonomy_id, term_id, taxonomy, description, parent, count
wp_term_relationships   object_id, term_taxonomy_id, term_order
wp_termmeta             meta_id, term_id, meta_key, meta_value
Enter fullscreen mode Exit fullscreen mode

Two auto increment keys, term_id and term_taxonomy_id, for what a human calls one category.

Read the third line again. wp_term_relationships is the table that connects a post to a category, and it does not store term_id at all. It pairs object_id with term_taxonomy_id.

Meanwhile everything in the admin, including the new ID column, speaks term_id.

Core admits the ambiguity in its own API

This is the citation I would put in front of anyone who thinks I am splitting hairs. WP_Tax_Query accepts both, in the same argument:

// These are two different queries.
'tax_query' => [ [ 'taxonomy' => 'category', 'field' => 'term_id',          'terms' => 47 ] ]
'tax_query' => [ [ 'taxonomy' => 'category', 'field' => 'term_taxonomy_id', 'terms' => 47 ] ]
Enter fullscreen mode Exit fullscreen mode

If the two IDs were the same thing, field would not need both values. Core is telling you there are two numbers. The list table is not.

Why this ships as a bug instead of getting caught

On a fresh install the two values line up for the earliest terms. Uncategorized is term_id 1 and term_taxonomy_id 1. Add a handful of categories in order and they keep matching. So SQL that confuses them passes locally, passes code review, and passes staging if staging is a clean install.

They diverge on real sites. WordPress used to allow shared terms, one wp_terms row serving several taxonomies, and it spent two releases undoing that: 4.2 split shared terms when one was updated, and 4.3 split every remaining shared term on upgrade. Splitting means new term_id values, which is exactly how the two columns drift apart. Any site that lived through that, or that has simply run several taxonomies for a few years while the two tables incremented at different rates, has terms where term_id and term_taxonomy_id are different numbers.

That split is also why get_term() could make its $taxonomy parameter optional in 4.4: once terms stopped being shared, a term_id identified a term on its own. Useful history, and the same history that put two IDs on your screen with no label.

Run this on your oldest client site:

SELECT term_id, term_taxonomy_id, taxonomy
FROM wp_term_taxonomy
WHERE term_id != term_taxonomy_id
LIMIT 10;
Enter fullscreen mode Exit fullscreen mode

If that returns rows, every hand written join on that site is a coin flip.

Where each number is safe

Safe with the term_id your column shows:

Check before you trust it:

  • any hand written SQL touching wp_term_relationships
  • import and migration mappings, where both numbers appear in the same export
  • wp_get_object_terms() results being fed back into raw SQL
  • anything copying an ID from one site's admin into another site's config

Get the right number for the join like this:

$term = get_term( 3, 'category' );
$ttid = $term->term_taxonomy_id;   // the value wp_term_relationships wants
Enter fullscreen mode Exit fullscreen mode

Or in SQL:

SELECT p.ID, p.post_title
FROM wp_posts p
INNER JOIN wp_term_relationships tr ON tr.object_id = p.ID
INNER JOIN wp_term_taxonomy tt ON tt.term_taxonomy_id = tr.term_taxonomy_id
WHERE tt.term_id = 3 AND tt.taxonomy = 'category';
Enter fullscreen mode Exit fullscreen mode

Note the join goes through wp_term_taxonomy. That is not boilerplate, it is the translation layer between the two IDs.

One thing this column gets right

It is safe to sort. term_id is a real column on wp_terms, so get_terms() can order by it with no join and no dropped rows.

Compare the Last Login column from the same settings panel. That one sorts on user meta, which joins wp_usermeta on the meta key and removes every user who does not have it, so sorting to find dormant accounts can delete the dormant accounts from the list. Same panel, opposite risk profile, and worth knowing which column you are clicking.

The code route

Two hooks, and the second one has a contract people get wrong:

// 1. Register the column.
add_filter( 'manage_edit-category_columns', function ( $columns ) {
    $columns['term_id'] = 'ID';
    return $columns;
} );

// 2. Print the value. This is a FILTER. Return, do not echo.
add_filter( 'manage_category_custom_column', function ( $out, $column, $term_id ) {
    return ( 'term_id' === $column ) ? (int) $term_id : $out;
}, 10, 3 );

// 3. Optional, make it sortable.
add_filter( 'manage_edit-category_sortable_columns', function ( $columns ) {
    $columns['term_id'] = 'term_id';
    return $columns;
} );
Enter fullscreen mode Exit fullscreen mode

manage_{$taxonomy}_custom_column has been a filter since WordPress 2.8, with the signature ( string $string, string $column_name, int $term_id ) and an empty string default. The posts equivalent, manage_{$post_type}_posts_custom_column, is an action where you echo. Same feature, two different contracts, and the taxonomy one is where people lose an hour.

Fine print: swap category for post_tag or your own taxonomy, register one pair per taxonomy or hook manage_edit-{$taxonomy}_columns dynamically for all of them, use manage_{$screen->id}_sortable_columns if you prefer the screen-based hook name, 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. Settings, Productivity tab, scroll to Custom Admin Columns, tick Show "Taxonomy ID" Column for all possible types of taxonomies, save. The ID column appends after Count.

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 post thumbnail column and a URL Path column, all documented on that page. If you want arbitrary columns rather than the fixed set, the standalone Admin Columns Editor is the bigger hammer, customising columns for any post type is the docs entry for that, and WooCommerce product columns is the same idea on product_cat and friends. The rundown of which admin columns actually earn their width is a sane map before you switch five of them on, adding an ACF field as an admin column is the next step for custom fields, and the wider Productivity toolset sits in the same place.

The conditions I would put on it

It is a display column. It prints a number, it does not tell you which of the two IDs your other code needs, and it does not remap, merge or migrate anything. It costs horizontal space on a Categories screen already carrying Name, Description, Slug and Count, and Screen Options is where you claw that back. If you enable it on a site with many taxonomies, check that it appears on the ones you care about rather than assuming, because "all possible types of taxonomies" is a claim worth testing on a custom taxonomy.

And the thing worth taping to the monitor: the ID on that screen is the term_id. If your next step is a join, translate it first.

Demo Shorts

Docs for the checkbox route: https://wpadminify.com/docs/adminify/productivity/custom-admin-columns

Top comments (0)