A Guide to Custom WordPress Admin Columns

November 12, 2015

I recently setup a custom post type (CPT) to manage ‘Pledge Premiums’ – IEG’s primary work is for public television (particularly for New York, New Jersey and Long Island), and the premiums are the thank-you gifts that our members get for making a pledge. I wanted the list of premiums that appears in the WordPress admin to display custom columns other than ‘Title’, ‘Date’, etc, and I spent HOURS reading through search results to work out how to setup those admin columns the way I wanted.

I couldn’t find any comprehensive guide on setting up the admin columns, so here’s mine! Here in one place we’ll walk through how to:

  • define custom columns,
  • get them in the order you want them,
  • populate the columns with the right data,
  • make them sortable – including sorting by taxonomy terms, and
  • make the custom columns editable.

My Use Case

I’ve created my ‘Pledge Premiums’ CPT so that our Member Services staff can manage the list of available gifts. To streamline things for them I’ve eliminated all of the standard post attributes (post_content etc) other than title, and store everything else in meta fields and tags. Specifically for this exercise, besides title, I use post meta fields _pledge_premium_pcode (a custom SKU-like identifier), _pledge_premium_required_amount, and taxonomies for pledge_program (such as ‘NOVA’ – a premium can be featured for multiple programs) and premium_type (CD, DVD, book, etc. Some pledges can be a CD/DVD combo).

We want to display sortable columns for the ‘pledge_programs’ that the premium is assigned to, the title (though we label it as the ‘description’), ‘_pledge_premium_pcode’, and the last modified date. And we want to display the columns in that order.

Add a column for the custom taxonomy

The arguments for ‘register taxonomy‘ include show_admin_column. The default is ‘false’, but setting it to ‘true’ automatically adds a column with clickable links to your admin. That was easy!

Note that WordPress gives this column a slug of taxonomy-{taxonomy_name}, so our ‘pledge_programs’ taxonomy column will have a slug of taxonomy-pledge-programs. We’ll need to know this later.

Set the column list

WordPress has a filter ‘manage $post type posts columns‘. Our CPT uses the slug wnet_pledge_premiums, so that looks like this:

add_filter('manage_wnet_pledge_premiums_posts_columns', 'wnetpp_set_admin_column_list');

This filter is applied to the columns shown when listing posts of the $post_type in question and defines which columns appear. It passes you an array of columns to modify and then pass back. Each element in the array has a slug as the key and the label as the value, eg ‘title’ => ‘Title’. By default, that array will include cb (checkbox for bulk actions), date, and whichever of the following fields the post type was set to ‘support’ when it was registered – title, author, categories, tags, comments. Of that list my CPT only ‘supports’ title, so the array I have by default has cb, title, date, and taxonomy-pledge_programs.

I want to:

  • remove the date column,
  • add columns for ‘P-Code’ and ‘Last Modified’,
  • re-order and re-label ‘title’ to be the second column after ‘Pledge Programs’. Because ‘Pledge Programs’ was the last item in the list before, once I remove date and title it’ll be the first element, and everything will be added after it.

Here’s my code.

function wnetpp_set_admin_column_list($columns) { 
  // Delete the element from the array and the column disappears.
  // Delete title so I can add it back in my preferred order.
  $columns['title'] = 'Pledge Description/Title'; 
  // Adding back the built-in 'title' column, but giving it a new label.
  $columns['pcode'] = 'P-Code';
  $columns['modified'] = 'Last Modified';
  return $columns;

And now when I look at my list of ‘Pledge Premiums’, I see my columns in the order I want, but the ‘P-Code’ and ‘Last Modified’ columns are empty!

The admin with two empty columns

Lets get some data in there.

Populate the custom columns

WordPress has another hook for ‘manage $post type posts custom column‘ (note ‘column’ is singular here). This action is called whenever a value for a custom column should be output for a specific post of a post type.

add_action( 'manage_wnet_pledge_premiums_posts_custom_column' , 'wnetpp_populate_custom_columns', 10, 2 );

The “10, 2” args at the end are the priority the action is run at – 10, the default – and the number of args the script should expect – two. The action will automatically pass in the column identifier (the array key from the column list, ‘title’, ‘programs’, etc) and the post_id. Because you have the post_id and the column slug, populating each column entry is easy.

function wnetpp_populate_custom_columns( $column, $post_id ) {
  // 'P-Code' is just a post meta field
  if ($column == 'pcode') {
    echo get_post_meta( $post_id , '_pledge_premium_pcode' , true ); 

  // For 'modified', I'm adapting some formatting logic from Andrew Norcross
  // http://andrewnorcross.com/tutorials/modified-date-display/
  // to provide pretty formatting and include the name of the last editor.
  if ($column == 'modified') {    
    $m_orig = get_post_field( 'post_modified', $post_id, 'raw' );
    $m_stamp = strtotime( $m_orig );
    $modified	= date('n/j/y @ g:i a', $m_stamp );
    $modr_id	= get_post_meta( $post_id, '_edit_last', true );
    $auth_id	= get_post_field( 'post_author', $post_id, 'raw' );
    $user_id	= !empty( $modr_id ) ? $modr_id : $auth_id;
    $user_info	= get_userdata( $user_id );
    echo '<p class="mod-date">' . $modified. '<br />';
    echo 'by <strong>' . $user_info->display_name . '</strong>';
    echo '</p>';

And now our columns have the expected data.

Admin with all the columns populated

Note that ‘modified’ case above shows how easy it is to format and manipulate the text in the column, combine it with other fields from the post, etc. The sky’s the limit.

Choosing columns to be sortable

In our admin list, if we look at the title column we’ll see that it’s already sortable. A quick inspection and you’ll see that the sort link just takes the current URL and appends ‘orderby=title’. If you are experienced with WP Query you’ll recognize ‘orderby’ – it does what you’d think it does, ‘order by’.

WordPress provides a filter ‘manage_{$this->screen->id}_sortable_columns‘ that lets us manipulate the array of sortable columns. This time, the array has our slug as a key and an ‘orderby’ argument as the value. At this point that array is just ‘title’ => ‘title’, but we’re going to add to the list.

The unique ‘screen->id’ is derived from the URL for this view, edit.php?post_type=wnet_pledge_premiums which WP interprets as scriptname + the post type = ‘edit-wnet_pledge_premiums’.

We’ll be calling the filter as so:

add_filter( 'manage_edit-wnet_pledge_premiums_sortable_columns' , 'wnetpp_admin_sortable_columns' );

function wnetpp_admin_sortable_columns($columns) {
  $columns['modified'] = 'modified';
  $columns['pcode'] = 'pcode';
  $columns['taxonomy-pledge-programs'] = 'taxonomy-pledge-programs'; 
  return $columns;

Now all these column headings have ‘orderby’ links. Hooray! But if I try and click on the links for ‘P-Code’ or ‘Pledge Programs’ the results are…odd. ‘Last Modified’ works, though.

Built-in ‘orderby’ sorting

My ‘Last Modified’ column is set to use ‘modified’ as the ‘orderby’ argument. This works because ‘modified’ is a built-in value for ‘orderby’ in WP Query, and this admin list is simply using WP Query to generate the list of posts. Besides ‘modified’, some other built-in orderby values include ‘date’ (the default), ‘ID’, ‘author’, ‘title’, etc – here’s the whole list.

WP Query doesn’t have a clue what to do with our ‘pcode’ and ‘taxonomy-pledge-programs’ ‘orderby’ argmuments, though. So when we click on those column headers, it orders the results by date. We need to tell WP Query what we really want.

Order by a meta field

My ‘P-Code’ is a meta field. WP Query lets you ‘orderby’ ‘meta_value’, but to do so you have to tell WP Query which ‘meta_key’ to use. The ‘pre get posts‘ hook runs before any list of posts is queried, and it is one of the most straightforward ways to interact with WP Query.

add_filter( 'pre_get_posts', 'wnetpp_admin_sort_columns_by');
function wnetpp_admin_sort_columns_by( $query ) {  
  if( ! is_admin() ) { 
    // we don't want to affect public-facing pages
  $orderby = $query->get( 'orderby');  
  if( 'pcode' == $orderby ) {  
    $query->set('meta_key', '_pledge_premiums_pcode');  

This has added a couple of arguments to WP Query, and now my ‘P-Code’ sort works! This technique will let you use any sort order you could make work in WP Query – date fields, numeric fields, whatever.

Order by a taxonomy

All of our sorting techniques so far have used straighforward WP Query sorting techniques, but there is no built-in way to sort by taxonomy in WP Query. And some people think that ‘sorting by taxonomy’ is somehow philosphically flawed. Those people are WRONG. Taxonomies and tags are a way users expect to navigate content, so OF COURSE WE WANT TO SORT BY THEM.

Pardon the rant, back to the problem at hand.

While there isn’t a nice argument to pass to WP Query to do what we want, there is a particularly clever filter that WordPress provides called ‘posts clauses‘ that lets you add literal SQL to parts of your WP Query request.

In order to make a taxonomy sort work we’ll need some particularly complex SQL. Fortunately, a mainstay of WordPress development, ‘Scribu’ (creator of WP-CLI among other things), has already developed the code!

It’s been around for several years and has been tweaked and discussed by many programmers. I’ve adapted it below – my (minor) contribution was to generalize the technique to work for any taxonomy column.

add_filter( 'posts_clauses', 'wnetpp_orderby_taxonomy', 10, 2 );

function wnetpp_orderby_taxonomy( $clauses, $wp_query ) {
  if ( ! is_admin() ) {
    return $clauses;
  global $wpdb;

  if ( isset( $wp_query->query['orderby'] ) && (strpos($wp_query->query['orderby'], 'taxonomy-') !== FALSE) ) {
    $tax = preg_replace("/^taxonomy-/", "", $wp_query->query['orderby']);
    $clauses['join'] .= "LEFT OUTER JOIN {$wpdb->term_relationships} ON {$wpdb->posts}.ID={$wpdb->term_relationships}.object_id
LEFT OUTER JOIN {$wpdb->term_taxonomy} USING (term_taxonomy_id)
LEFT OUTER JOIN {$wpdb->terms} USING (term_id)";
    $clauses['where'] .= " AND (taxonomy = '" . $tax . "' OR taxonomy IS NULL)";
    $clauses['groupby'] = "object_id";
    $clauses['orderby']  = "GROUP_CONCAT({$wpdb->terms}.name ORDER BY name ASC) ";
    $clauses['orderby'] .= ( 'ASC' == strtoupper( $wp_query->get('order') ) ) ? 'ASC' : 'DESC';
  return $clauses;

If you really want to understand what is happening, we’re adding to the ‘join’ clause a multi-table JOIN between the posts, term_relationships, term_taxonomy, and terms tables, adding to the ‘where’ clause a condition to look for terms in the taxonomy we’re sorting by, and then creating an ‘orderby’ clause by CONCATing the found terms into a string in the case where there are multiple tags on a post, and then doing an alphabetical sort on that CONCAT’ed string.

TLDR, it works.

You may note that I exit this orderby_taxonomy function if called outside of the WP admin. I do this because the SQL resulting from this posts_clauses call is quite complex and could probably slow down your site if called on the front end; on the other hand, if you’d like to be able to build some custom sorts for your front-end display, posts_clauses filters could be just what you want.

Make meta-field data editable in ‘quick-edit’ view

NB: I’ve based much of this section on an an excellent walkthrough I found in Tim Bunch’s post Understanding the WordPress Quick Edit Custom Box, but I’ve taken what I learned there and integrated into the rest of this guide.

This will be a little complicated – we need to

  1. add ‘quick edit custom boxes’ for the meta fields,
  2. make sure our ‘save_post’ routine saves the data, and
  3. add some jQuery to populate those fields with the current data.

Adding the box

Built-in post fields and taxonomy fields are all automatically available in the ‘quick edit’ form, but not meta fields. Fortunately, WP has an action ‘quick_edit_custom_box’ which will let you add a ‘quick edit’ box for a field. This is the easiest part – we just echo the form elements for that box. We’ll want to include a nonce. If you’ve got multiple fields you want to be editable, you’d tweak the code to do some ‘case’ choices.

add_action( 'quick_edit_custom_box' , 'wnetpp_display_custom_quickedit_box', 10, 2 );

function wnetpp_display_custom_quickedit_box($column_name, $post_type) {
  // create a nonce so we'll be able to save the field
  echo '<input type="hidden" name="wnetpp_save_post_nonce" id="wnetpp_save_post_nonce" value="' . wp_create_nonce( plugin_basename( $this->dir ) ) . '" />';  
  <fieldset class="inline-edit-col-left">
  <div class="inline-edit-col column-<?php echo $column_name; ?>">
  if ( $column_name == 'pcode' ) {
    echo '<span class="title">P-Code</span><span class="input-text-wrap"><input name="_pledge_premiums_pcode" /></span>';

Now, if your custom ‘pcode’ field was already in the admin columns, now when you click ‘quick edit’ for a post you’ll see your field! However, it’ll be blank, even if there was data showing in the column before, and clicking ‘save’ if you enter something may not yet actually save any data.

Saving your meta field data

If you already had a metabox setup in your custom post admin for the postmeta field, you’ll have already created a ‘save_post’ routine. Our admin column ‘quick edit’ can use the same routine. And if you hadn’t, here’s how this works.

Whenever we click a ‘save’ or ‘update’ button on a post (or in the quick edit for a post), the ‘save_post’ action is called. It automatically gets the post_id and listens for all $_POST arguments, but it only knows by default how to deal with the built-in post fields and any taxonomy fields, so if you setup a custom meta box for postmeta fields, you need to tell the system what to do with them.

Here’s simple code that only handles if you have a single custom field defined.

add_action( 'save_post', 'wnetpp_meta_box_save' );

function meta_box_save($post_id) {
  // Verify nonce -- your nonce name needs to match the one you did in the form
  if ( ! wp_verify_nonce( $_POST[ 'wnetpp_save_post_nonce'], plugin_basename( $this->dir ) ) ) {  
    return $post_id;  
  // Verify user permissions
  if ( ! current_user_can( 'edit_post', $post_id ) ) { 
    return $post_id;
  // if there is a form submitted with this input name, update the post meta field 
  if (isset( $_POST['_pledge_premiums_pcode'] ) ) { 
    update_post_meta( $post_id , '_pledge_premiums_pcode' , $_POST['_pledge_premiums_pcode'] );

If you have multiple custom fields you want to save, you’ll want to put a loop of some sort around the form submission listener, checking for the existence of each field.

However, if you already had a ‘save_post’ handler setup, you shouldn’t have to add one here – if your custom metabox saves properly when you’re editing a post but doesn’t work here, check to make sure your ‘quick edit’ box is using the same input name and nonce setup.

jQuery to get existing value into the field

A problem now is that if we look at our quick edit view, the field will have a blank value even if there really should be data there. If we click ‘update’ in the quick edit view, it’ll actually blank out our data! We need to retrieve the current value that we’re seeing in the ‘list’ view and put it into our form.

First, we write some jQuery that copies and overwrites WP’s built in jQuery for doing quickedit stuff, and use that to copy the values from the ‘list’ view to the ‘edit’ view. This is the trickiest part of the whole process actually, and the one where I am most indebted to Tim Bunch’s post – the following javascript is almost all his.

jQuery(document).ready(function($) {
  // we create a copy of the WP inline edit post function
  var $wp_inline_edit = inlineEditPost.edit;
  // and then we overwrite the function with our own code
  inlineEditPost.edit = function( id ) {
    // "call" the original WP edit function
    // we don't want to leave WordPress hanging
    $wp_inline_edit.apply( this, arguments );
    // now we take care of our business
    // get the post ID
    var $post_id = 0;
    if ( typeof( id ) == 'object' ) {
      $post_id = parseInt( this.getId( id ) );
    if ( $post_id > 0 ) {
    // define the edit row
      var $edit_row = $( '#edit-' + $post_id );
      var $post_row = $( '#post-' + $post_id );

      /* this section below is the part where
       * you'll add in the custom stuff for your columns. 
       * WP automatically added the 'column-pcode' class to 
       * to our column in the 'list' view, and we manually added that
       * same class to our the field in our quick edit box.
       * This code just copies from the list to the edit.
      var $pcode = $( '.column-pcode', $post_row ).text();
      // populate the data
      $( ':input[name="_pledge_premiums_pcode"]', $edit_row ).val( $pcode );

I’ve saved that file into my plugin folder as ‘column_functons.js’. Now we want to get the file to be pulled into the list view for our custom post type. In my plugin file I’ll call the ‘admin_enqueue_scripts’ action, and put in conditionals to get the current ‘screen’ object to enqueue the script in the footer and only on the one view we want.

add_action( 'admin_enqueue_scripts', 'wnetpp_setup_admin_scripts' );
function wnetpp_setup_admin_scripts() {
  $screen = get_current_screen();
  // the 'list' view url is 'edit.php', so the 'base' of it is 'edit'
  if ($screen->post_type == 'wnet_pledge_premiums' && $screen->base=='edit') {  
    wp_enqueue_script('wnet-pledge-premium-column-functions', plugins_url('column_functions.js', __FILE__ ), array('jquery'), null, true);
    /* that final 'true' is what tells it to enqueue the script in the footer, 
     * which will get it in after the original WP-supplied admin functions are loaded.

And now when we load our ‘quick edit’ screen the current value should appear in the input.

In conclusion…

Some think of WordPress as just a blogging platform, but it has the capacity to provide all sorts of data management interfaces. With a little bit of customization you can get the admin to do all sorts of things. I hope this has given you real-world examples and use cases for seeing how you can customize the admin and take advantage of the system to make a more usable system for your users!

Digital at The WNET Group is not responsible for your or any third party’s use of code from this tutorial. All the information on this website is published in good faith “as is” and for general information purposes only; WNET and IEG make no representation or warranty regarding reliability, accuracy, or completeness of the content on this website, and any use you make of this code is strictly at your own risk.