Subtle diagonal pinstripe pattern in light grey and cream, giving an impression of printed editorial paper

Keeping Posts in Order While Paginating With WP_Query

A shop owner in Melbourne notices something unsettling on her WooCommerce archive page. The first page shows a clean, descending price order, but the moment a visitor clicks page two, the products appear to shift around. A blog editor in Brisbane runs a tourism site and sees a similar problem with custom itinerary posts. These are not bugs in WordPress core. They are usually the result of pagination parameters that have been bolted on after a query has been written, and they quietly break the ordering logic that the template relies on.

WP_Query remains the most reliable way to fetch posts in WordPress, but the way it handles ordering and pagination can feel like a moving target. Once a developer mixes custom orderby values with the paged parameter, sticky post handling, or a secondary loop on the same page, the order can drift between pages without throwing a single error. The goal of this guide is to walk through the parameters and patterns that keep results consistent from page one through to the last.

You will see a handful of techniques that Australian developers tend to reach for when working on client sites, from sticking with the default query for simple archives to writing fully custom loops for membership directories and event listings. Along the way, a few quirks of regional hosting, timezone handling, and even local payment integrations will pop up, because they often dictate how the data is sorted in the first place.

Building a Base Query That Stays Predictable

The starting point is always the same: a clean WP_Query object with the right arguments for the content type being shown. Whether you are building a custom page template for a Sydney real estate agency or extending a theme for a Perth-based membership site, the structure does not change much.

A reliable skeleton looks like this:

$args = array( 'post_type' => 'property', 'posts_per_page' => 12, 'paged' => get_query_var( 'paged' ) ? get_query_var( 'paged' ) : 1, 'orderby' => 'date', 'order' => 'DESC', ); $query = new WP_Query( $args );

The two pieces that often get overlooked are posts_per_page and paged. If you set posts_per_page to -1, pagination disappears entirely and ordering stops being a concern. As soon as you switch it to a real number, you must also feed paged the current page number, otherwise WordPress assumes page one and silently drops the rest. For sites hosted on Australian servers, this is also a useful moment to confirm the timezone setting in Settings → General. If the site runs on UTC but the business operates in AEST, ordering by date can place yesterday's post ahead of today's by accident.

Another small but important detail is the use of suppress_filters. By default it is false, which means plugins like search and ordering extensions can still touch the SQL. For a custom loop that needs guaranteed stability, set it to true only when you have tested every other interaction, because doing so can break facets, language switchers, and a few popular SEO plugins used by agencies in Adelaide and Brisbane.

Locking the Order With Reliable Orderby Values

Ordering is where most post-pagination bugs start. WordPress accepts a long list of orderby values, and the wrong combination can produce inconsistent results across pages. The simplest stable choice is a single column, such as date, title, menu_order, or rand. Once you start mixing values, the database has to do more work and the results can become unpredictable on shared hosting.

For a WooCommerce store selling in Australian dollars with GST-inclusive pricing, a common pattern is to order products by price. Because price is stored in post meta, the orderby becomes meta_value_num rather than a built-in column:

'orderby' => 'meta_value_num', 'meta_key' => '_price', 'order' => 'ASC',

This works, but only if the meta_key exists for every post in the result. If even a single product is missing _price, that row will float to the top of the sort on some MySQL versions. A safer alternative for shops that integrate with local gateways like Afterpay or EWay is to add a fallback meta_key, or to exclude variable products from the loop until prices are set.

Multiple orderby values are supported through an array, which is helpful when you want a secondary sort to act as a tiebreaker:

'orderby' => array( 'meta_value_num' => 'ASC', 'title' => 'ASC' ), 'meta_key' => '_price',

This is the pattern many Australian editorial teams use for directory listings, where the primary sort is a paid placement flag and the secondary is alphabetical. The pagination then follows naturally because the database returns the same ordered set, sliced into pages.

Tying Pagination to the Same Query Without Drift

The next step is making sure the pagination links reflect the same query that produced the posts. A frequent mistake is to run the main loop with one set of arguments and then call paginate_links() with a different post count, which causes the link set to expect a number of pages that the database never returned.

The fix is to share the same $query variable between the loop and the pagination call:

$total = $query->found_posts; $pages = $query->max_num_pages;

$big = 999999999; $pagination = paginate_links( array( 'base' => str_replace( $big, '%#%', esc_url( get_pagenum_link( $big ) ) ), 'format' => '?paged=%#%', 'current' => max( 1, get_query_var( 'paged' ) ), 'total' => $pages, 'prev_text' => 'Previous', 'next_text' => 'Next', ) );

A community resource that covers variations of this pattern, including AJAX pagination and infinite scroll, is available on wp-qa, where developers from different regions share tested snippets. Using the same query object for both the loop and the link builder is the single most important habit to keep the order intact, because the database only sees one consistent set of ORDER BY clauses.

For sites that use a static front page, get_query_var( 'paged' ) can return zero. In that case, fall back to get_query_var( 'page' ), which WordPress uses for the page query variable on singular templates. This small switch resolves a class of bugs that show up on local business sites where the home page is set to a static landing screen rather than the blog index.

Sorting by Custom Fields, Taxonomies, and User Input

Real projects rarely stick to date or title. A directory of accountants in Adelaide might need to order by suburb, then by rating, then alphabetically. A national events platform might order upcoming meetups by event date stored in a custom field. In all of these cases, the ordering logic lives in the database, not in PHP, so the same principles apply: keep the orderby values explicit, ensure every row has the required meta, and avoid expensive computations inside the loop.

For taxonomy-based ordering, a useful trick is to use a term_order or term_id field through a tax_query combined with a JOIN. This is more involved than a simple meta query, and it is worth reading the performance implications on a staging site before going live. A hosting plan in a regional data centre, such as those offered by providers in Melbourne or Canberra, can mask slow queries during local testing, only to surface when the site is viewed from further away.

When users control the sort themselves, such as a "Sort by" dropdown on a products archive, the orderby and order parameters need to be sanitised and whitelisted. A safe pattern is to map a short code like price-desc to a known set of arguments:

$allowed = array( 'price-desc' => array( 'meta_value_num', 'DESC', '_price' ), 'price-asc' => array( 'meta_value_num', 'ASC', '_price' ), 'newest' => array( 'date', 'DESC', '' ), ); $key = isset( $_GET['sort'] ) ? sanitize_key( $_GET['sort'] ) : 'newest'; list( $orderby, $order, $meta_key ) = $allowed[ $key ];

Allowing free-form user input to flow into orderby is a common source of SQL injection attempts flagged by the Australian Cyber Security Centre in its WordPress hardening guidance, so a whitelist is not just good practice, it is also a security measure.

Handling Sticky Posts, Offsets, and Edge Cases

A few edge cases deserve their own attention because they tend to break ordering in subtle ways. Sticky posts are the most common. When ignore_sticky_posts is left as false, WordPress pulls sticky posts to the top of the first page, which means page two starts from the next non-sticky post. The result is a duplicated or skipped post at the boundary between pages. For a news site that relies on sticky featured stories, this is usually desired. For a directory or shop, it is not, and setting ignore_sticky_posts to true is often the right call.

The offset parameter is another trap. SQL OFFSET is precise, but it does not work alongside pagination, because pagination is calculated from the page number, not the offset. The common workaround is to add a filter on the posts_where clause that subtracts page one from the SQL, or to use a custom query with a calculated offset. Neither is elegant, and for most use cases it is cleaner to use a meta query or a taxonomy filter to exclude the first N items rather than trying to skip them with OFFSET.

Finally, caching layers such as Redis or object cache can sometimes serve a stale result set if a query is regenerated on every page load with different parameters. A site that runs a flash sale around Australia Day, for example, might see ordering shift when the cache is invalidated mid-promotion. The remedy is to include a cache key that reflects the orderby and order, so the cache version matches the data version, and to flush it through a controlled action when the sale starts or ends.

Once these patterns are in place, pagination stops feeling like a separate concern from ordering. The two share the same query, the same database result, and the same assumptions about which posts belong on which page. For developers across Australia working on everything from small business sites to high-traffic WooCommerce stores, that consistency is what turns a brittle template into one that visitors can trust to load the same way every time.