How to order a WP_Query by terms or categories
We often get the question of how to order a query by taxonomy terms or categories. With the bonus question of how to add a term heading above each group of posts sharing a term/category.
The orderby parameter in WP_Query can be used to order by many things, including custom fields. But unfortunately, ordering by taxonomy terms or categories is not directly possible.
This tutorial explores two different workarounds to accomplish this. Both are quite complicated to set up, so if possible, we recommend choosing a built-in query WP_Query order instead. But if you absolutely need this type of ordering, read on.
Two approaches
Below, we describe two different approaches to ordering a query by terms or categories in a custom WP_Query template, then (optionally) output a heading above each group of posts in a term/category, and wrap each group in a container element (for styling).
Which approach to choose depends on your situation:
- Approach A can only be used if you don’t need pagination. It will not work with a Pager facet or other pagination. The
posts_per_pageparameter must be set to-1to retrieve all posts at once. The upside is that this setup does work with a Sort facet, with which you can change the order of posts within the term groups (but not the term groups themselves). - Use Approach B if you need pagination. In this approach, you can set the
posts_per_pageparameter however you like, and use a Pager facet. The downside is that this setup does not work with a Sort facet.
Both approaches also work if posts have multiple terms. In that case, the posts are ordered by their first term, where the order of post terms to determine this is the same as the order set for the main term order for the query. Make sure to read and apply the setup steps for the chosen approach, as there is a part in the code that depends on whether posts have multiple terms or not (see step 5 below in each described setup).
Approach A – no pagination, sorting within term groups
A – Overview
This setup can only be used if you don’t need pagination. It will not work with a Pager facet or other pagination. The posts_per_page parameter must be set to -1 to retrieve all posts at once. The upside is that this setup does work with a Sort facet, with which you can change the order of posts within the term groups (but not the term groups themselves).
In this approach, we use the normal WordPress loop to go through all posts and store their (first) term and post data in an array $posts_by_term.
Then we reset the loop, and query all terms of the taxonomy in the desired order with get_terms(). In a new loop, we then go through these ordered terms, and for each we (optionally) output a term heading, and loop through and display all posts that exist for that term. Each term group is (optionally) wrapped in a container <div> with a term class for easy styling.
A – Setup
To get this working properly, follow these steps:
- Set the taxonomy to order by in line 9
- Set the desired term order in lines 12-13.
If you want to useterm_orderfor a custom manual order, you need a term ordering plugin. FacetWP is compatible with these four, of which we recommend the first two because they have the least known issues (as described on their pages):- Intuitive Custom Post Order
- Advanced Taxonomy Terms Order
- Category Order and Taxonomy Terms Order
- Custom Taxonomy Order
If you are not using
term_orderin line 12, make sure these plugins are deactivated or not running their custom order automatically on queries on your page, otherwise this setup will fail to order the posts correctly. - Set the desired order of posts within each term group in lines 17-18.
- Set the desired post type(s) to retrieve in line 22. Don’t change
posts_per_pagein line 23 as this approach does not work with pagination (use approach B if you need that). - In lines 46-72, make a choice between using
get_the_terms()(option 1) andwp_get_post_terms()(option 2). Using get_the_terms() is better for performance because it is cached, whilewp_get_post_terms()is not. But you can only useget_the_terms()if your desired term order is set tonameandASCin lines 12-13. Or if all of your posts only have one term selected each. If any posts have multiple terms, or if you need another term order thannameandASC, liketerm_order, you need to use wp_get_post_terms() (option 2). Make sure to comment out or remove the option you are not using. - If you don’t want a heading above each term group of posts, remove line 114.
- Customize the post output after the post heading in line 127.
- For debugging purposes, in lines 129-152 the code outputs the terms for each post in the correct order, and the value for
term_order(if that is in use). Remove that part when done or not needed.
How to use custom PHP code?
PHP code can be added to your (child) theme's functions.php file. Alternatively, you can use the Custom Hooks add-on, or a code snippets plugin. More info
// Approach A - no pagination, sorting with a Sort facet within term groups // Does NOT work with pagination / Pager facet. Posts per page *must* be -1. // Works with a Sort facet to change sort *within* the term groups. You cannot use a Sort facet to sort the term groups // First, set the taxonomy and all order parameters // Set the taxonomy to order the query by $orderby_taxonomy = 'category'; // Set the term order to order the query by $terms_orderby = 'name'; // Options: 'name', 'slug', 'term_group', 'id', 'description', 'count', 'term_order' (requires a plugin for custom term_order) $terms_order = 'ASC'; // Options: 'ASC' or 'DESC' // Set the order of the posts *within* each term group // This order can be changed with a Sort facet on the page $posts_orderby = 'post_title'; $posts_order = 'ASC'; // Use 'ASC' or 'DESC' // Set WP_Query arguments $args = [ 'post_type' => [ 'post' ], // Set the post types to retrieve 'posts_per_page' => - 1, // Don't change! The query needs all posts here. This solution does NOT work with pagination 'orderby' => $posts_orderby, 'order' => $posts_order, 'facetwp' => true, // Enable FacetWP for this query ]; // Loop through all the posts and store their (first) term and post data $the_query = new WP_Query( $args ); // Initialize an array of terms to store the terms and post data $posts_by_term = array(); echo '<div class="facetwp-template">'; if ( $the_query->have_posts() ) : while ( $the_query->have_posts() ) : $the_query->the_post(); // Get the taxonomy terms for the current post. // We use the first term in the retrieved array of terms. // The retrieved term order must be the same as the main term order set for the query, for when posts have more than one term. // Choose between Option 1: get_the_terms() and Option 2: wp_get_post_terms(). For performance reasons, preferably use get_the_terms() if you can, because that is cached and wp_get_post_terms() is not. The choice depends on the desired order, and if there are multiple terms per post. // Option 1 - get_the_terms() - Better performance // ONLY use this if: // - you have set $terms_orderby above to 'name' and $terms_order to 'ASC' // - or, if all posts in the query only have *one* term // get_the_terms() is cached and thus better for performance than wp_get_post_terms() (see below), but does not have a good way of ordering reliably $terms = get_the_terms( get_the_ID(), $orderby_taxonomy ); if ( ! empty( $terms ) && ! is_wp_error( $terms ) ) { // Use usort() to order the terms by 'name', 'ASC'. Only needed if any post in the query can have more than one term usort( $terms, function( $a, $b ) { return strcmp( $a->name, $b->name ); } ); } // Option 2 - wp_get_post_terms() - More flexible: works for orders *other* than by 'name', 'ASC'. // Use this if the desired term order is e.g. term_order, or if your posts have multiple terms and you're not ordering by 'name' + 'ASC'. // Drawback: wp_get_post_terms() is uncached as opposed to get_the_terms() (see above). $terms = wp_get_post_terms( get_the_ID(), $orderby_taxonomy, [ 'orderby' => $terms_orderby, 'order' => $terms_order ] ); // Get the first term for the post in the retrieved terms. if ( ! empty( $terms ) && ! is_wp_error( $terms ) ) { $term_slug = $terms[0]->slug; $term_name = $terms[0]->name; // Add the current post data to our custom term array. // We use the term slug as the key for grouping. $posts_by_term[ $term_slug ]['term_name'] = $term_name; $posts_by_term[ $term_slug ]['posts'][] = get_post(); } endwhile; wp_reset_postdata(); // Query *all* terms in the desired display order. $term_args = array( 'taxonomy' => $orderby_taxonomy, 'hide_empty' => true, // Only get terms that have posts in the query. 'orderby' => $terms_orderby, 'order' => $terms_order, ); $ordered_terms = get_terms( $term_args ); if ( ! empty( $ordered_terms ) && ! is_wp_error( $ordered_terms ) ) : // Loop through the *ordered* terms, and if posts exist for that term, display them. // Add a heading before each term group is output. foreach ( $ordered_terms as $term ) : $term_slug = $term->slug; // Check if the current term has posts in our $posts_by_term array. if ( isset( $posts_by_term[ $term_slug ] ) ) : $term_data = $posts_by_term[ $term_slug ]; // Optional: for each group of posts in a term, display a heading. echo '<h2 class="term-heading ' . $term_slug . '">' . esc_html( $term_data['term_name'] ) . '</h2>'; // Open a container div for the posts in this term group. echo '<div class="posts-in-term ' . $term_slug . '">'; // Loop through the posts for the current term (already ordered by title DESC). foreach ( $term_data['posts'] as $post ) : setup_postdata( $post ); // Set up post data for the current post. // Display the post content ?> <article id="post-<?php the_ID(); ?>" <?php post_class(); ?>> <h3><a href="<?php the_permalink(); ?>"><?php the_title(); ?></a></h3> <?php // Optional for debugging the order, remove when done: // Display post terms for each post $terms = wp_get_post_terms( get_the_ID(), $orderby_taxonomy, [ 'orderby' => $terms_orderby, 'order' => $terms_order, ] ); echo '<div class="post-terms">'; foreach ( $terms as $term ) { echo $term->name . ', '; } echo '</div>'; // Display term_order - use only when using a taxonomy ordering plugin, for debugging the term_order value $first_term = $terms[0]; echo '<br>term_order:' . $first_term->term_order; // end debugging ?> </article> <?php endforeach; echo '</div>'; // .posts-in-term wp_reset_postdata(); endif; // End check if term has posts endforeach; // End loop through ordered terms endif; // End check if there are terms else : echo '<p>No posts found.</p>'; // Nothing found message endif; wp_reset_postdata(); echo '</div>'; // .facetwp-template
Approach B – pagination, but no sorting
B – Overview
Choose this setup if you need pagination and you want to use a Pager facet. The posts_per_page parameter can be set however you like. The downside is that this setup does not work with a Sort facet.
In this approach, we use a normal WordPress loop to go through all posts. To output the posts in the desired term order, we use a post_clauses filter.
For each post in the loop, we get its (first) term(s). We compare this with the current term (that we track using a tracking variable), to output an optional term heading and an (optional) wrapper <div> around each term group, with a term class for easy styling.
B – Setup
To get this working properly, follow these steps:
- Set the taxonomy to order by in line 8
- Set the desired term order in lines 11-12.
If you want to useterm_orderfor a custom manual order, you need a term ordering plugin. FacetWP is compatible with these four, of which we recommend the first two because they have the least known issues (as described on their pages):- Intuitive Custom Post Order
- Advanced Taxonomy Terms Order
- Category Order and Taxonomy Terms Order
- Custom Taxonomy Order
If you are not using
term_orderin line 11, make sure these plugins are deactivated or not running their custom order automatically on queries on your page, otherwise this setup will fail to order the posts correctly. - Set the desired order of posts within each term group in lines 16-17.
- Set the desired post type(s) to retrieve in line 21. Set
posts_per_pagein line 22 as desired. - In lines 101-127, make a choice between using
get_the_terms()(option 1) andwp_get_post_terms()(option 2). Using get_the_terms() is better for performance because it is cached, whilewp_get_post_terms()is not. But you can only useget_the_terms()if your desired term order is set tonameandASCin lines 11-12. Or if all of your posts only have one term selected each. If any posts have multiple terms, or if you need another term order thannameandASC, liketerm_order, you need to use wp_get_post_terms() (option 2). Make sure to comment out or remove the option you are not using. - If you don’t want a heading above each term group of posts, remove line 145.
- Customize the post output after the post heading in line 158.
- For debugging purposes, in lines 160-183 the code outputs the terms for each post in the correct order, and the value for
term_order(if that is in use). Remove that part when done or not needed.
How to use custom PHP code?
PHP code can be added to your (child) theme's functions.php file. Alternatively, you can use the Custom Hooks add-on, or a code snippets plugin. More info
// Approach B – pagination, but no sorting with a Sort facet // Does work with pagination / Pager facet. The posts_per_page can be set as desired. // Does NOT work with a Sort facet. You cannot use a Sort facet to sort the term groups or the posts within them. // First, set the taxonomy and all order parameters $orderby_taxonomy = 'category'; // Set the taxonomy to order the query by // Set the term order to order the query by $terms_orderby = 'name'; // Options: 'name', 'slug', 'term_group', 'id', 'description', 'count', 'term_order' (requires a plugin for custom term_order) $terms_order = 'ASC'; // Options: 'ASC' or 'DESC' // Set the order of the posts *within* each term group // This order can be changed with a Sort facet on the page $posts_orderby = 'post_title'; $posts_order = 'ASC'; // Use 'ASC' or 'DESC' // Set WP_Query arguments $args = [ 'post_type' => [ 'post' ], 'posts_per_page' => 12, // Set as desired 'facetwp' => true, // Enable FacetWP for this query 'tax_query' => [ [ 'taxonomy' => $orderby_taxonomy, 'operator' => 'EXISTS', // Ensure we only get posts that actually have this term ], ], // Pass taxonomy name, term- and post order to the query arguments so they are accessible in the 'posts_clauses' hook 'my_sort_taxonomy' => $orderby_taxonomy, 'terms_orderby' => $terms_orderby, 'terms_order' => $terms_order, 'posts_orderby' => $posts_orderby, 'posts_order' => $posts_order, ]; // Add a 'posts_clauses' filter order the queried posts by term add_filter( 'posts_clauses', 'my_order_by_taxonomy_clauses', 10, 2 ); function my_order_by_taxonomy_clauses( $clauses, $query ) { global $wpdb; // Only run this logic if our custom argument is present if ( $query->get( 'my_sort_taxonomy' ) ) { $taxonomy = $query->get( 'my_sort_taxonomy' ); $terms_orderby = $query->get( 'terms_orderby' ); $terms_order = $query->get( 'terms_order' ); $posts_orderby = $query->get( 'posts_orderby' ); $posts_order = $query->get( 'posts_order' ); // JOIN: Link posts -> term_relationships -> term_taxonomy -> terms $clauses['join'] .= " LEFT JOIN {$wpdb->term_relationships} ON ({$wpdb->posts}.ID = {$wpdb->term_relationships}.object_id) LEFT JOIN {$wpdb->term_taxonomy} ON ({$wpdb->term_relationships}.term_taxonomy_id = {$wpdb->term_taxonomy}.term_taxonomy_id) LEFT JOIN {$wpdb->terms} ON ({$wpdb->term_taxonomy}.term_id = {$wpdb->terms}.term_id) "; // WHERE: specify the taxonomy to avoid grouping by tags or other taxonomies $clauses['where'] .= $wpdb->prepare( " AND {$wpdb->term_taxonomy}.taxonomy = %s", $taxonomy ); // Group by Post ID so we don't get duplicates $clauses['groupby'] = "{$wpdb->posts}.ID"; // Order the posts by the set term order // Then within the term groups, order by the set posts order // If $terms_order is 'ASC', we use MIN() to order by the *first* term if there are multiple // If $terms_order is 'DESC', we use MAX() to order by the *last* term if there are multiple $select = 'MIN'; if ( $terms_order === 'DESC' ) { $select = 'MAX'; } $clauses['orderby'] = "{$select}({$wpdb->terms}.{$terms_orderby}) {$terms_order}, {$wpdb->posts}.{$posts_orderby} {$posts_order}"; } return $clauses; } $the_query = new WP_Query( $args ); // Remove the filter so we don't affect other queries on the page remove_filter( 'posts_clauses', 'my_order_by_taxonomy_clauses' ); echo '<div class="facetwp-template">'; // Loop through the ordered posts and add a heading before each term group is output if ( $the_query->have_posts() ) : // Initialize a variable to track the "previous" term $current_term_header = null; while ( $the_query->have_posts() ) : $the_query->the_post(); // Get the taxonomy terms for the current post. // We use the first term in the retrieved array of terms. // The retrieved order must be same as SQL order in my_order_by_taxonomy_clauses() for when posts have more than one term. // Choose between Option 1: get_the_terms() and Option 2: wp_get_post_terms(). For performance reasons, preferably use get_the_terms() if you can, because that is cached and wp_get_post_terms() is not. The choice depends on the desired order, and if there are multiple terms per post. // Option 1 - get_the_terms() - Better performance // Only use this if: // - you have set $terms_orderby above to 'name' and $terms_order to 'ASC' // - or, if all posts in the query only have *one* term // get_the_terms() is cached and thus better for performance than wp_get_post_terms() (see below), but does not have a good way of ordering reliably $terms = get_the_terms( get_the_ID(), $orderby_taxonomy ); if ( ! empty( $terms ) && ! is_wp_error( $terms ) ) { // Use usort() to order the terms by 'name', 'ASC'. Only needed if any post in the query can have more than one term. usort( $terms, function( $a, $b ) { return strcmp( $a->name, $b->name ); } ); } // Option 2 - wp_get_post_terms() - More flexible: works for orders *other* than by 'name', 'ASC' // Use this if the desired term order is e.g. term_order, or if your posts have multiple terms and you are not ordering by 'name' + 'ASC'. // Drawback: wp_get_post_terms() is uncached as opposed to get_the_terms() (see above). $terms = wp_get_post_terms( get_the_ID(), $orderby_taxonomy, [ 'orderby' => $terms_orderby, 'order' => $terms_order, ] ); $this_post_term_name = 'Uncategorized'; // Default // Get the first term for the post in the retrieved terms if ( ! empty( $terms ) && ! is_wp_error( $terms ) ) { $this_post_term_name = $terms[0]->name; $this_post_term_slug = $terms[0]->slug; } // Optional: // - Display a heading above each term group. // - Add a container div for the posts in this term group. if ( $this_post_term_name !== $current_term_header ) : if ( null !== $current_term_header ) { echo '</div>'; // Close .posts-in-term } // Optional: for each group of posts in a term, display a heading. echo '<h2 class="term-heading ' . $this_post_term_slug . '">' . esc_html( $this_post_term_name ) . '</h2>'; // Open a container for the posts in this term echo '<div class="posts-in-term ' . $this_post_term_slug . '">'; // Update the term tracker $current_term_header = $this_post_term_name; endif; // End optional heading and container div. // Display the post content ?> <article id="post-<?php the_ID(); ?>" <?php post_class(); ?>> <h3><a href="<?php the_permalink(); ?>"><?php the_title(); ?></a></h3> <?php // Optional for debugging the order, remove when done: // Display post terms for each post $terms = wp_get_post_terms( get_the_ID(), $orderby_taxonomy, [ 'orderby' => $terms_orderby, 'order' => $terms_order, ] ); echo '<div class="post-terms">'; foreach ( $terms as $term ) { echo $term->name . ', '; } echo '</div>'; // Display term_order - use only when using a taxonomy ordering plugin, for debugging the term_order value $first_term = $terms[0]; echo '<br>term_order:' . $first_term->term_order; // end debugging ?> </article> <?php endwhile; ?> <?php // Close the final container after the loop ran. if ( null !== $current_term_header ) { echo '</div>'; // Close last .posts-in-term } ?> <?php else : echo '<p>No posts found.</p>'; // Nothing found message endif; ?> <?php wp_reset_postdata(); echo '</div>'; // .facetwp-template