- date
- entry
- 051
- topic
- integration
- rev
- —
Counting a WordPress site's posts with the REST API: one HEAD request gives the total, and paging past the end of a non-empty result returns 400, not an empty array
To find out how many posts a WordPress site has, the obvious approach is to start at page 1 and keep going until a page comes back empty. Testing against the REST API of wordpress.org/news, paging past the last page gave me HTTP 400 and an error object, not an empty array; and the total was available from a single HEAD request all along.
WordPress's REST API lists posts at /wp-json/wp/v2/posts, 10 per page by default, paged with page. To count a site's posts, the intuitive loop is: start at page 1, add one each time, stop on an empty array.
But testing against WordPress.org's own blog (wordpress.org/news), paging past the last page returned HTTP 400 and a JSON object, not []. This note is about counting a site's posts correctly with this API. In order: where the total comes from, the maximum page size, what happens past the end, how to check the crawl, and why the category numbers don't match the post count. All requests were made in the early hours of October 18 (Taiwan time), without logging in or sending credentials, so they see the published posts this query exposes publicly.
The total is in a header, and HEAD gets it
The REST API Handbook's Pagination page says every paginated response carries two headers: X-WP-Total, the total number of records, and X-WP-TotalPages, the number of pages. An actual response:
$ curl -s -D - -o /dev/null "https://wordpress.org/news/wp-json/wp/v2/posts?per_page=100&_fields=id"
HTTP/2 200
x-wp-total: 1110
x-wp-totalpages: 12
link: <https://wordpress.org/news/wp-json/wp/v2/posts?per_page=100&_fields=id&page=2>; rel="next"
If all you want is the total, you don't even need the content. With HEAD and per_page=1, the response still has x-wp-total: 1110 (x-wp-totalpages is 1110 here, one post per page). WordPress's source (class-wp-rest-posts-controller.php on wordpress-develop trunk) queries only post IDs for HEAD requests and prepares no content; when all you need is the total, the server doesn't have to build a page of posts for you to throw away. Without per_page, at 10 per page, the same site is 111 pages.
At most 100 per page, and 101 is a 400
The Handbook says per_page is an integer from 1 to 100. Going over isn't quietly clamped to 100; it's rejected:
$ curl -s ".../posts?per_page=101&_fields=id"
{"code":"rest_invalid_param","message":"Invalid parameter(s): per_page",
"data":{"status":400,"params":{"per_page":"per_page must be between 1 (inclusive) and 100 (inclusive)"}, …}}
So fetching all 1,110 posts takes at least 12 requests. Limiting the fields per page is worth it too: the first page at per_page=100 without _fields is 2,341,785 bytes; with _fields=id,date,link it's 12,267 bytes, nearly 200 times smaller. Counting or building a list doesn't need every post's full HTML.
Past the end is a 400, not an empty array
The Handbook's Pagination page doesn't say what happens when the page number exceeds the page count. Paging from 12 to 14:
page 12: [{"id":10},{"id":9},{"id":1432},{"id":1431}, … [200]
page 13: {"code":"rest_post_invalid_page_number","message":"The page … [400]
page 14: {"code":"rest_post_invalid_page_number","message":"The page … [400]
A loop that only stops on an empty array doesn't stop here. Page 13's error JSON decodes to a non-empty dict, so the condition in if not data: break is false, and the loop asks for pages 14 and 15, each the same 400. If the HTTP library raises on 400, the script ends in an error; if it doesn't, and nothing checks the status code or caps the page count, it keeps paging forever.
Empty arrays do still happen. In the source, the condition for this error is "page number above the page count, and total above 0." On the same site, searching for a string that doesn't exist (search=qzxjvkw, X-WP-Total 0) returns 200 and [] for both page 1 and page 2; filtering by a nonexistent category, categories=1, does the same. So the same loop ends cleanly on a query with 0 results and breaks on one with results; testing it against an empty query hides the problem.
Headers make a steadier stop condition: read X-WP-TotalPages from the first page and stop at that page, or watch the Link header, which on this crawl's page 12 had only rel="prev" and no rel="next". Either way, HTTP errors still need handling of their own.
Checking the crawl
After 12 pages: 100 rows each on the first 11, 10 on page 12, 1,110 rows in all, no duplicate IDs, date values (site time zone) from 2003-04-06 to 2026-10-06. Every page's X-WP-Total was 1110, and a HEAD afterwards also said 1110. The total read on the first page, the total re-read at the end, the row count and the distinct IDs all agreeing is the check I ran on this crawl.
Why compare four numbers? Because this API pages by offset: per_page=5&page=4 equals per_page=5&offset=15 (the Handbook's example), ordered newest first by publish date by default. With hypothetical numbers: if a new post is published while you're on page 3, every post shifts back one place, page 4 starts with what was page 3's last post, and you get a duplicate, 1,111 rows. Conversely, if a post you've already fetched is deleted mid-crawl, later posts shift forward one place and the one at the boundary gets skipped: row count, distinct IDs and the re-read total are all 1,109 and agree with each other; only comparing with the 1,110 read on the first page reveals it. So row count and distinct IDs catch duplicates but not skips; you also need "did the total change between start and end." Even all four together can't catch an add and a delete at once: if a fetched post is deleted while a new one appears at the front, both totals are 1,110, rows and distinct IDs are 1,110, and you end up holding a deleted post and missing the new one. So these are consistency checks, not proof; this crawl's responses showed no duplicate IDs or mismatched numbers, but I can't rule out such an add-and-delete pair during those few dozen seconds.
Posts with identical timestamps can also make the order unstable, and this site has a pair: 14175 and 14180, both with a date of 2023-01-16 12:00:00, which landed as rows 52 and 53 of page 3 this time, on the same page, so no harm done. If such a pair straddled a page boundary, the order might differ between two requests, and paging with orderby=id removes the tie (though not the shifting above); I didn't hit that case and haven't tested it.
Why the category numbers don't add up
Another shortcut that comes to mind is reading /wp/v2/categories and summing each category's count. This site returned 22 categories whose count values sum to 1,759, 649 more than 1,110.
The reason is that a post can have several categories. I fetched the categories field for all 1,110 posts and counted: 585 posts have 1 category, 417 have 2, 94 have 3, 12 have 4, 2 have 5. Summing categories per post gives exactly 1,759, matching the category-side sum. In other words, a category's count counts "posts filed under this category," and the sum counts post–category pairs, not posts. For the number of posts, use X-WP-Total on posts.
Also, posts covers posts only, not pages. This site's /wp/v2/pages has an X-WP-Total of 2; counting "all content on the site" means defining the scope first, then querying pages and any custom post types separately.
If you remember one thing
To count WordPress posts, read X-WP-Total with a HEAD request; when paging, decide where to stop from X-WP-TotalPages or Link, not from an empty array: past the end of a query with results you get a 400. After the crawl, compare row count, distinct IDs, and the totals read before and after; all four agreeing is a necessary check, but it can't catch an add and a delete during the crawl.
Revision history
- First published