Skip to content
ThemesIonic — home
WordPress Tutorials

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.

Updated 4 min read intermediate

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_posts branch did not match orderby.
  • 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.

Related guides