Adding Columns to the WordPress Post List
The column needs two hooks, not one: a filter that registers the header and an action that prints each cell. A column with a header and empty cells has the first and not the second, which is the most common half-finished state.
A list column is two hooks, and a sortable one is four. manage_posts_columns filters the array of column headers on the posts list; manage_posts_custom_column is the action that prints a cell for each row. Register the header without the cell callback and the column appears empty. Make it sortable without a query callback and the header turns into a link that reorders nothing.
Header and cells
add_filter( 'manage_posts_columns', 'ti_word_count_column', 10, 2 );
function ti_word_count_column( $columns, $post_type ) {
if ( 'post' !== $post_type ) {
return $columns;
}
$new = array();
foreach ( $columns as $key => $label ) {
$new[ $key ] = $label;
if ( 'title' === $key ) {
$new['ti_words'] = __( 'Words', 'ti' );
}
}
return $new;
}
add_action( 'manage_posts_custom_column', 'ti_word_count_cell', 10, 2 );
function ti_word_count_cell( $column, $post_id ) {
if ( 'ti_words' !== $column ) {
return;
}
$count = (int) get_post_meta( $post_id, '_ti_word_count', true );
echo $count ? esc_html( number_format_i18n( $count ) ) : '—';
}
Rebuilding the array in a loop places the column after a specific neighbour. Appending with $columns['ti_words'] = … puts it last, after Date, which is rarely where a column earns its space. Removing a column is unset( $columns['comments'] ) in the same filter, and that is the only way to remove one for every user — Screen Options hides columns per user and leaves the filter output intact.
The cell callback is called once per row per custom column, and the column name check is not optional: with two custom columns registered, each callback runs for both and must print only for its own.
Per-post-type hooks
| Content | Header filter | Cell action |
|---|---|---|
| Posts and non-hierarchical types | manage_posts_columns |
manage_posts_custom_column |
| Pages and hierarchical types | manage_pages_columns |
manage_pages_custom_column |
| One specific type | manage_{$post_type}_posts_columns |
manage_{$post_type}_posts_custom_column |
| Media | manage_media_columns |
manage_media_custom_column |
| Users | manage_users_columns |
manage_users_custom_column (a filter — return the value) |
| Taxonomy terms | manage_edit-{$taxonomy}_columns |
manage_{$taxonomy}_custom_column (a filter) |
The typed hooks are the better default for a custom post type: no type check in the callback and no accidental column on another type. Note that the generic manage_posts_columns does not fire for pages, which is why a column added there is missing from the page list — pages are hierarchical and use the pages hook.
The users and terms cell hooks are filters, not actions. Return the string; echoing it prints the content above the table.
Making the column sortable
Two more hooks. The first marks the column:
add_filter( 'manage_edit-post_sortable_columns', 'ti_sortable_words' );
function ti_sortable_words( $columns ) {
$columns['ti_words'] = 'ti_words';
return $columns;
}
The value is what orderby will be set to in the request when the header is clicked. The second hook turns that into a query:
add_action( 'pre_get_posts', 'ti_sort_by_words' );
function ti_sort_by_words( WP_Query $query ) {
if ( ! is_admin() || ! $query->is_main_query() || 'ti_words' !== $query->get( 'orderby' ) ) {
return;
}
$query->set( 'meta_key', '_ti_word_count' );
$query->set( 'orderby', 'meta_value_num' );
}
Sorting by a meta key excludes posts that have no row for that key: a post with no _ti_word_count disappears from the sorted list entirely. Either write the meta for every post — on save_post, for instance — or sort with a meta_query that includes a NOT EXISTS clause so the empty ones sink to the bottom rather than vanishing. The same pre_get_posts mechanics are detailed in pre_get_posts.
Performance
The cell callback runs once per row and the list shows twenty rows by default and up to 999 by setting. A get_post_meta() call per row is fine; the meta cache is primed for the listed posts. A fresh WP_Query per row, an HTTP request, or a count of related posts is not — multiply it by the page size and the list becomes the admin screen people complain about. Compute expensive values once on save and store them in meta, then read the meta in the column.
Verify
- The header appears after Title on the post list and not on pages or other types.
- Each row shows the value or a dash; an empty cell means the column name in the action does not match the key in the filter.
- Clicking the header sorts; a header that is a link but does not reorder means the
pre_get_postsbranch did not matchorderby. - Screen Options lists the column so users can hide it.
- Quick Edit still works: a column with unbalanced HTML breaks the inline editor's row parsing.
Frequently asked
- The header comes from the columns filter and the cells from a separate action, manage_posts_custom_column, which receives the column name and post ID. Without a callback on it, or with a callback that checks the wrong column name, the cells print nothing.
- Use the typed hooks: manage_{post_type}_posts_columns for the header and manage_{post_type}_posts_custom_column for the cells, with the post type slug in the name. The generic manage_posts_columns fires for every non-hierarchical type and passes the type as its second argument.
- Registering it as sortable only makes the header clickable and sets orderby in the request. Sorting by meta needs a pre_get_posts callback that reads the orderby value and sets meta_key and orderby on the query.