Advanced pagination control for WordPress

So, you’re developing your own WordPress theme, or you are heavily customizing the one you are already using, and you need to have different number of posts per page depending on the page you are in.

How do you do that?

WordPress only allows you to set a predetermined limit of posts per page, and applies that limit to all listing pages by default. It’s simple, elegant, usable, some more random keywords here, but far from ideal in real world themes and real world users (kinda O.C.D. like me), where we might (definitely) want the height of your content to roughly (exactly) match the height of our sidebar in every single page.

In order to intercept the default WordPress options and apply our own rules, we are going to hook onto the pre_get_posts action. This action fires before the query is executed (so there is no performance penalty) and passes as a parameter a reference to the WP_Query object, so every change to the parameter changes the immediately the query itself.

So, open your theme’s functions.php file, and let’s write our hook which will be the placeholder for the rest of our code.

[pastacode lang=”php” manual=”add_action(‘pre_get_posts’%2C%20’ci_paging_request’)%3B%0Afunction%20ci_paging_request(%24wp)%0A%7B%0A%09%2F%2F%20Here%20goes%20the%20rest%20of%20our%20code%0A%7D%0A” message=”” highlight=”” provider=”manual”/]

Notice that we have named the wp_query object reference as $wp. You can name it anything you want.

Let’s make it very clear. We don’t want to mess with the admin side of WordPress. So, the very first thing we need to do inside our new function, is to detect whether it’s an admin page and return immediately.

[pastacode lang=”php” manual=”add_action(‘pre_get_posts’%2C%20’ci_paging_request’)%3B%0Afunction%20ci_paging_request(%24wp)%0A%7B%0A%09%2F%2FWe%20don’t%20want%20to%20mess%20with%20the%20admin%20panel.%0A%09if(is_admin())%20return%3B%0A%7D%0A” message=”” highlight=”” provider=”manual”/]

This did nothing. Now what?

Relax, and get a sip of coffee.

Let’s assume you have thought of your blog’s structure, navigation and SEO techniques, and you have determined that you need your blog to display the following:

  • Home page: 5 posts per page
  • Category and Tag listings: 10 posts per page
  • Other archive pages (taxonomies, custom post types, date-based archives): 25 posts per page

Additionally, you have need to keep the default WordPress setting so that it applies in all other cases, and you want to be able to change it whenever you want from within the WordPress admin panel.

Finally, you have a category Exotic Flowers, with the slug exotic-flowers and you don’t want any pagination at all, i.e. you want to display all posts in a single page.

Now, let’s start thinking. Shall we?

The general idea is to create a variable ($num) that will hold a default number of posts, and replace that number depending on the case. Where we need to be careful is that we need to replace this value going from the more generic cases, to the more specific ones. Let’s start.

Let’s get the user setting from the WordPress panel.

[pastacode lang=”php” manual=”%24num%20%3D%20get_option(‘posts_per_page’%2C%2015)%3B” message=”” highlight=”” provider=”manual”/]

Here, we’ve said that we want $num to be whatever the user has set, and just in case there is no such record in the database, set 15 posts as a default.

Let’s continue by setting a number for our home page.

[pastacode lang=”php” manual=”if(%20is_home()%20)%0A%09%24num%20%3D%205%3B%0A” message=”” highlight=”” provider=”manual”/]

In the same spirit, we continue adding cases, depending on what our needs are. Keep in mind the what we mentioned before about working from the more generic cases to the more specific ones. The following code is what handles the cases for archives, categories, tags, and the Exotic Flowers in the right order.

[pastacode lang=”php” manual=”if(%20is_archive()%20)%0A%09%24num%20%3D%2025%3B%0A%0Aif(%20is_category()%20or%20is_tag()%20)%0A%09%24num%20%3D%2010%3B%0A%0Aif(%20is_category(‘exotic-flowers’)%20)%0A%09%24num%20%3D%20-1%3B%20%2F%2F%20-1%20means%20No%20limit%0A” message=”” highlight=”” provider=”manual”/]

Don’t use the following code

The following snippet is meant to show an example of wrong ordering of the IF statements, and why they are wrong. DO NOT copy this code as it contains logical errors.

[pastacode lang=”php” manual=”if(%20is_category()%20or%20is_tag()%20)%0A%09%24num%20%3D%2010%3B%0A%0Aif(%20is_category(‘exotic-flowers’)%20)%0A%09%24num%20%3D%20-1%3B%0A%0Aif(%20is_archive()%20)%0A%09%24num%20%3D%2025%3B%0A” message=”” highlight=”” provider=”manual”/]

Why is this bit of code wrong? Well, let’s say you are browsing through the Exotic Flowers category. According to our specification on the top of the article, there should be no pagination at all, and all posts should be listed in a single page. Now, the first condition, if( is_category() or is_tag() ) is TRUE because we are in a category listing page, and the $num variable gets properly assigned the number 10. No problem here.

The second condition, if( is_category(‘exotic-flowers’) ), is also TRUE because we are browsing through that specific category. So the $num variable gets properly overwritten with the number -1. No problem here either.

The third condition, if( is_archive() ) is TRUE, because the category listing pages ARE archive pages, so the $num variable gets overwritten with the number 25. But this is wrong! Yes, this is a logical bug, because the more generic case comes later than the more specific cases. In fact, the specific example will result in all categories displaying 25 posts per page instead of 10.

Just be careful to avoid this pitfall.

Get on with it

Anyway, forgetting for a moment the logical traps that we can fall in, this is great! We can add/remove IF’s as much as we want, and WordPress conditional tags are here to assist. At this point your code should look like something like this:

[pastacode lang=”php” manual=”add_action(‘pre_get_posts’%2C%20’ci_paging_request’)%3B%0Afunction%20ci_paging_request(%24wp)%0A%7B%0A%09%2F%2FWe%20don’t%20want%20to%20mess%20with%20the%20admin%20panel.%0A%09if(is_admin())%20return%3B%0A%0A%09%24num%20%3D%20get_option(‘posts_per_page’%2C%2015)%3B%0A%0A%09if(%20is_home()%20)%0A%09%09%24num%20%3D%205%3B%0A%0A%09if(%20is_archive()%20)%0A%09%09%24num%20%3D%2025%3B%0A%0A%09if(%20is_category()%20or%20is_tag()%20)%0A%09%09%24num%20%3D%2010%3B%0A%0A%09if(%20is_category(‘exotic-flowers’)%20)%0A%09%09%24num%20%3D%20-1%3B%20%2F%2F%20-1%20means%20No%20limit%0A%7D%0A” message=”” highlight=”” provider=”manual”/]

In order to change the query object to take into account the number of posts we determined we need, we need to modify its posts_per_page query variable. Query variables are in an array named query_vars within the query object. Accessing it and assigning our number is as simple as:

[pastacode lang=”php” manual=”%24wp-%3Equery_vars%5B’posts_per_page’%5D%20%3D%20%24num%3B” message=”” highlight=”” provider=”manual”/]

Don’t forget that $wp is a reference to the query object, so we can access its member variables (and functions) directly. Just by adding the line above to the end of our function, is enough to change the whole WordPress paging behavior. But what about all the side-effects?

Protection from side-effects

Just by adding the $wp->query_vars[‘posts_per_page’] = $num; line to our function, affects each and every one query that is going to execute. This includes the main query (primary loop), any other custom/secondary queries and loops, as well as most widgets that use the WP_Query object somehow (e.g. recent posts, popular posts, etc).

We don’t want that, do we?

First line of defense

In order to restrict the effects of our function, we are going to leave custom queries alone. One way to determine which queries are custom and which are not is to examine the WP_Query object (well, the $wp variable) before we modify it. What we will see is that the $wp->query_vars[‘posts_per_page’] is sometimes set to a number, and sometimes is not set at all.

What does this mean? Well, when it’s set and assigned a number, it means that a custom query, loop, widget, slider, etc. requested a specific number of posts. This is where we should stop messing with the query and stop the execution of our function.

When it is not set at all, a number of posts was not passed as a parameter to query_posts() or to a new WP_Query() object. This allows WordPress to behave as it wants, i.e. use the number of posts the user has set from the admin panel. This is where we want to intervene and assign our own number.

This is how to do just that. Instead of:

[pastacode lang=”php” manual=”%24wp-%3Equery_vars%5B’posts_per_page’%5D%20%3D%20%24num%3B” message=”” highlight=”” provider=”manual”/]

wrap it within an IF statement that executes only when the query variable is not set:

[pastacode lang=”php” manual=”if(%20!%20isset(%20%24wp-%26gt%3Bquery_vars%5B’posts_per_page’%5D%20)%20)%0A%7B%0A%09%24wp-%26gt%3Bquery_vars%5B’posts_per_page’%5D%20%3D%20%24num%3B%0A%7D%0A” message=”” highlight=”” provider=”manual”/]

If posts_per_page was set, execution will skip this step and the query will be left untouched.

Second line of defense

Although at this point our custom pagination hook will work, come rain or come shine, you might want to restrict the effectiveness of our function even further, by applying it only on the main query of a page. This means only the main loop of each page template, that may or may not be changed by the theme using the query_posts() function.

In order to do that, we can use the incredibly useful conditional function that WordPress provides, is_main_query(), which only returns TRUE if called somewhere from within the main loop. All we need to do, is extend our previous IF statement to include this condition as well.

[pastacode lang=”php” manual=”if(%20!%20isset(%20%24wp-%26gt%3Bquery_vars%5B’posts_per_page’%5D%20)%20and%20is_main_query()%20)%0A%7B%0A%09%24wp-%26gt%3Bquery_vars%5B’posts_per_page’%5D%20%3D%20%24num%3B%0A%7D%0A” message=”” highlight=”” provider=”manual”/]


There you have it. Your own custom pagination rules. Add or remove rules as you see fit, and make it your own.

Let us know if you find this article useful, if you implemented it in your own blogs and themes, and how you have improved it.

The complete code for this tutorial is:

[pastacode lang=”php” manual=”add_action(‘pre_get_posts’%2C%20’ci_paging_request’)%3B%0Afunction%20ci_paging_request(%24wp)%0A%7B%0A%09%2F%2FWe%20don’t%20want%20to%20mess%20with%20the%20admin%20panel.%0A%09if(is_admin())%20return%3B%0A%0A%09%24num%20%3D%20get_option(‘posts_per_page’%2C%2015)%3B%0A%0A%09if(%20is_home()%20)%0A%09%09%24num%20%3D%205%3B%0A%0A%09if(%20is_archive()%20)%0A%09%09%24num%20%3D%2025%3B%0A%0A%09if(%20is_category()%20or%20is_tag()%20)%0A%09%09%24num%20%3D%2010%3B%0A%0A%09if(%20is_category(‘exotic-flowers’)%20)%0A%09%09%24num%20%3D%20-1%3B%20%2F%2F%20-1%20means%20No%20limit%0A%0A%09if(%20!%20isset(%20%24wp-%26gt%3Bquery_vars%5B’posts_per_page’%5D%20)%20and%20is_main_query()%20)%0A%09%7B%0A%09%09%24wp-%26gt%3Bquery_vars%5B’posts_per_page’%5D%20%3D%20%24num%3B%0A%09%7D%0A%7D%0A” message=”” highlight=”” provider=”manual”/]

Related Articles


  1. Rilwis says:

    I don’t think the code is correct. What happens if $wp->query_vars[‘posts_per_page’] is set and has a different value? The code above won’t do anything, which is not expected.

  2. Anastis Sourgoutsidis says:

    The code is correct for what it is expect to perform.
    If you read more carefully the section “First line of defence”, you’ll see that if ‘posts_per_page’ is set, it means that it has explicitly been set to a number and we want to leave it untouched. This is correct for the scope and purpose of this tutorial.
    If you want to override all queries (even the ones that have set the parameter explicitly) then feel free to replace:
    if( ! isset( $wp->query_vars[‘posts_per_page’] ) and is_main_query() )
    $wp->query_vars[‘posts_per_page’] = $num;
    with just a:
    $wp->query_vars[‘posts_per_page’] = $num;

  3. Patrick Taylor says:

    Anastis, thanks. I’d spent some time looking for a fix for my pagination stopping after a certain number of pages. This works flawlessly so far and is the only solution I found that works in my particular situation. Very helpful.

    PS: I think you omitted the ‘home’ part in your conclusion:

    if( is_home() )
    $num = 5;


    1. Anastis Sourgoutsidis says:

      Thanks for your feedback Patrick.
      I’ve now added the missing code.
      Cheers :)

  4. Sal says:

    What if I want to change the pagination on the wp-admin side? I have installed a theme (Artic by Umbrella Software) and the Pages of the Portfolio section list only 20 at the time. Where can I set a different number instead? It’s extremely annoying that WP doesn’t offer what would look to be a necessary option to customize the items listed within the admin UI. Thanks in advance.

    1. Anastis Sourgoutsidis says:

      This is not theme-related, and also not related to the post.
      However, WordPress allows you to customize the number of items each page shows. While on the Porfolio list of of posts, expand “Screen Options” (on the top right corner) and you’ll see the related option.

      1. Sal says:

        Got it, thanks Anastis… self-Duh! ;P

  5. Abel says:

    Thanks for this tutorial Anastis!
    I was looking for this for a long time and found a nearly similar solution but with this tutorial everything has been very clear to me, is a great explanation.
    Thank you again!

    1. Anastis Sourgoutsidis says:

      Glad you found it helpful :)

  6. Aron says:

    I have to take my hat off for the excellent explanation and example. Works perfectly.
    I have been wondering if it is possible to set the homepage post to have no posts. I tried setting it to 0 with your code, but that obviously didn’t work.

    1. Anastis Sourgoutsidis says:

      Indeed this won’t work. You’ll need to trick the query into returning something that doesn’t exist.
      if( is_home() )
      $wp->query_vars[‘p’] = 0;
      This will try to fetch a single post, with ID = 0 (which it will not find, as IDs start from 1)

      Alternatively, you can also try:
      if( is_home() )
      $wp->query_vars[‘post_type’] = ‘adummyposttype’;
      Obviously, ‘adummyposttype’ should be a post type that doesn’t actually exist, therefore the query will return no posts.

  7. Aron says:

    Thanks for the reply.

    I tried both of your suggestions, and unfortunately they didnt work as intended. The query value of 0 was intepreted as unlimited, so all posts where listed on the homepage, and dummyposttest did remove the posts, but also had the negative effect of removing the menu bar.
    If you have any quick ideas on a fix, ill highly appreciate it, and ofcourse will be in your debt.

    1. Anastis Sourgoutsidis says:

      The dummy post type solution, cannot in any way affect your menu bar.
      It’s most probably the theme’s (or some plugin’s perhaps) issue, not displaying or handling everything as it should, when no posts are found.

      A look at the appropriate theme’s file will tell you a lot and perhaps you’ll be able to fix the issue, then again, if you do edit the file, you may just as well delete the loop that you want to disable :D

  8. Aron says:

    Found it, Got it. Thanks a bunch!

  9. Astrit says:

    And what if I want to show X number of posts (by using a custom page template and setting a static page as front page) on the front page and Y number of posts on paged pages, how would the code be?

    I’ve tried several times, but the pagination keeps breaking… any idea?

    1. Anastis Sourgoutsidis says:

      You can target the front page’s queries with is_front_page() however this might not work if your custom page template uses a fixed number.

      1. Astrit says:

        I see, well I did target the front page’s query, but it breaks the pagination, hope you don’t mind sharing a piece of code (linked to pastebin: , but if you do mind, please feel free to moderate the comment and remove the link).

        The code I’ve got there, it does what it should (shows 8 posts on front page and the default number of posts set in Settings>Reading), while on front page it shows 3 paged pages, but once I navigate to second page then the third one disappears… that is what is puzzling me.

        Any hint?

        1. Anastis Sourgoutsidis says:

          For one, try changing the numbers for your pagination to something that can aid your debugging. For example, in code set 2 posts per page, and in Settings -> Reading set 4 posts per page. This way you can easily see which number gets picked up. One will have twice as much pages than the other.

          Now, I took the liberty of changing your query to a new WP_Query object instead of overwriting the global $wp_query. This shouldn’t fix things though:

          The problem seems to be the logic in your custom_posts_per_page()
          function custom_posts_per_page($query) {
          if (is_home() && !is_paged()) {
          $query->query_vars[‘posts_per_page’] = 8;
          return $query;
          } else {
          $query->query_vars[‘offset’] = 8;
          return $query;
          is_home() is applied on the blog listing, not on the front page, and && !is_paged() makes the posts_per_page apply only on the first page of the blog listing.
          For all other cases, you skip (offset) 8 posts. I don’t really understand your reasoning behind this, but I trust you. However, according to the documentation, offset (int) – number of post to displace or pass over. Warning: Setting the offset parameter overrides/ignores the paged parameter and breaks pagination.

          What you probably want is:
          function custom_posts_per_page($query) {
          if (is_front_page()) {
          $query->query_vars[‘posts_per_page’] = 8;
          return $query;

          1. Astrit says:

            Hi Anastis,
            That does seem to get close to it. However I do get 2 errors

            1). is_front() throws me error: “Fatal error: Call to undefined function is_front()” – and this is really weird!
            2). If i change is_front to !is_paged, the pagination and everything works as it should (shows 2 on homepage, and 4 on the inner pages) with 1 gap, it does skip 2 posts (the ones that come right after the posts shown in front page).

            Sorry for bugging you really, it’s just that i’ve been banging my head for an entire day over this :S

        2. Anastis Sourgoutsidis says:

          Oooops! It’s actually is_front_page()
          My bad! I’ve fixed all references in my previous comments. Give it a spin and let me know.

          1. Astrit says:

            Hi Anastis,
            Thank you. That brought back the 2 missing posts, but now it won’t change the number of posts on paged pages , it keeps them all on 2 posts per page as opposed to 2 on homepage and 4 on paged pages.

            Also with wp_debug true, I am getting the next: Notice: Trying to get property of non-object in /../\wp-includes\query.php on line 4337

            But at least, the pagination is working as it should ;)

        3. Anastis Sourgoutsidis says:

          You didn’t mention that you wanted a different number of posts in subsequent pages. That’s a whole different issue. This StackExchange thread will help you with this issue.

          As far as the notice is concerned, I can’t help you without seeing the backtrace. Any query-related code could be to blame, even from a plugin.

          1. Astrit says:

            Hi Anastis,
            I appreciate it! Will check that stack and see if I can really understand what is kickin’ in (tho at first glance, i am not confident as i think i had already seen and tested that example).

            Thank you a bunch!

          2. Astrit says:

            This: does get me close, but still, it seems that once again the third paged page disappears when I am on second page … arghh!!

    2. Anastis Sourgoutsidis says:

      If, as I mentioned earlier, your custom page template has a fixed posts per page number, then your issue is just a matter of logic, if your are using the following bit of code (or similar):
      if( ! isset( $wp->query_vars[‘posts_per_page’] ) and is_main_query() )
      $wp->query_vars[‘posts_per_page’] = $num;

      This makes sure that you don’t override any explicitly set posts_per_page parameters.

      You may however want to override it just on the front page, so, you need to make sure that the above code won’t get executed if you’re on the front page.

      Therefore, your code may look like this:
      if( is_front_page() )
      $wp->query_vars[‘posts_per_page’] = 5; // Front page number of posts
      elseif( ! isset( $wp->query_vars[‘posts_per_page’] ) and is_main_query() )
      $wp->query_vars[‘posts_per_page’] = $num;

      There you have it. The last statement won’t get executed if and only if you are viewing the front page.

  10. Astrit says:

    Nope neither that does help actually. The code as is, it throws error
    Notice: is_main_query was called incorrectly. In pre_get_posts, use the WP_Query::is_main_query() method, not the is_main_query() function.

    however, even after modifying it the pagination still remains broken as in example I earlier mentioned.

    but thank you very much, I really appreciate you taking your time to respond to my comments.

  11. Bala says:

    how do you remove comments are closed’ in business3 theme

    1. Anastis Sourgoutsidis says:

      Please forward your question in our support forum.

  12. Esar says:

    Very nice advanced pagination….Appreciation …(Y)

  13. Stefan says:

    Thank you so much on this one. You save my nerves :)

  14. Please write more about WordPress, I love advanced programming articles

Leave a Reply

Your email address will not be published. Required fields are marked *