Filtering, sorting and pagination
The list endpoint bugs that nobody notices until page 3.
A list endpoint looks simple: ask for products, get products. Then the list grows past what fits in one response, users want it sorted by price, filtered by category and searched by name, and every one of those features is a small piece of code with its own edge cases. List bugs are sneaky because the response always looks plausible. A page with the wrong ten products is still a page with ten products.
This lesson uses /api/Products, which has 20 products in its seed data, and the demo endpoints under /api/paginationdocs. The same parameters work on every list in this API, and the API reference describes them.
| Parameter | What it does here |
|---|---|
limit | Page size, 1 to 100. Without it, you get everything. |
page | 1-based page number, used with limit. |
offset | Skip this many records, as an alternative to page. |
sort, order | Sort by any field, asc (the default) or desc. |
q | Case-insensitive search in text fields such as title and description. |
any field name | Exact, case-insensitive filter, like ?category=books. |
Pagination
Ask for three products per page and look at the headers as well as the body:
/api/Products?limit=3&page=1Twenty products in pages of three is seven pages, the last one holding two. The last page is the first place to look for bugs, because it is the one case where the page isn't full:
$ curl -i "https://api.testingapis.com/api/Products?limit=3&page=7"
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
x-page: 7
x-per-page: 3
x-total-count: 20
x-total-pages: 7
[{"id":19,"title":"Non-Slip Yoga Mat",...},{"id":20,"title":"Resistance Bands Set",...}]Correct: seven pages, and the last one has products 19 and 20. A classic bug here is computing the page count with integer division (20 / 3 = 6), which hides the last two products from anyone clicking through the pages.
Values outside the range
Next, send values the docs say are invalid, or don't mention at all. This is what the API does with them:
?limit=3&page=8 200, x-page: 8, body []
?limit=3&page=0 200, x-page: 1, same as page 1
?limit=0 200, x-per-page: 1
?limit=-1 200, x-per-page: 1
?limit=101 200, x-per-page: 100
?limit=abc 200, x-per-page: 20 (limit ignored, all 20 returned)The server never refuses. It clamps a bad value to the nearest valid one, or ignores it. That is a legitimate design, and it is friendly to clients, but a test should record it, because the alternative (a 400 that explains the valid range) is equally common. The dangerous cases are the ones where clamping hides a client bug. A client that sends limit=abc because of a template mistake gets all 20 records here. On a table with two million rows, the same behaviour means the server tries to return all of them.
A page past the end returns an empty list with a 200. That is what most APIs do, and it is what clients expect when they loop until they get nothing back. A 404 for an empty page would also be defensible. A 500 would not.
No gaps, no duplicates
The most important pagination test is also the simplest to describe: walk every page, collect the IDs, and check that you got each record exactly once. Compare the number you collected with X-Total-Count.
async function allPages(path, limit) {
const seen = [];
for (let page = 1; ; page++) {
const res = await fetch(`https://api.testingapis.com${path}?limit=${limit}&page=${page}`);
const items = await res.json();
if (items.length === 0) break;
seen.push(...items.map((item) => item.id));
}
return seen;
}
const ids = await allPages("/api/Products", 3);
const total = 20; // from X-Total-Count
console.log(ids.length === total, new Set(ids).size === ids.length);Run it with several page sizes: 1, 3, 7, and one larger than the whole list. Off-by-one errors in offset calculations tend to show up only for particular sizes. If the API supports both page and offset, check that limit=5&page=3 and limit=5&offset=10 return the same records.
This test only passes reliably if nobody changes the data while it runs. On a shared server like this one, somebody might. That leads to the main weakness of page numbers, covered below.
Sorting
Sort by price, descending, and check the order of the whole result rather than the first item:
/api/Products?sort=price&order=desc&limit=5The first five come back as 249.99, 199.99, 89.99, 89.99, 59.99. Products 3 and 8 cost the same. Ties matter for pagination: if the server doesn't break ties in a fixed way, the same product can appear at the bottom of page 1 and the top of page 2, or on neither. Most databases make no promise about the order of equal rows unless the query adds a second sort key, usually the ID.
Sorting by category shows how this API handles it. Within each category, products stay in ID order, both ascending and descending:
?sort=category
10:books 11:books 12:books 13:books 6:clothing 7:clothing 8:clothing 9:clothing
1:electronics 2:electronics 3:electronics 4:electronics 5:electronics 14:home ...
?sort=category&order=desc
18:sports 19:sports 20:sports 14:home 15:home 16:home 17:home 1:electronics ...That is stable, which is what you want. To test for it, sort by a field with lots of duplicates, fetch the list twice (and page through it with a small limit), and check that the order never changes.
More sorting cases to try on any API:
- Strings with mixed case. Does "apple" sort before or after "Banana"?
- Numbers stored as strings, where "10" sorts before "9".
- Records where the sort field is null or missing. Do they go first, last, or disappear?
- Dates in different time zones.
Parameters that are ignored
What happens when you get a parameter wrong is a bigger source of trouble than any of the features themselves:
?sort=colour&limit=5 200, ids 1,2,3,4,5 (unsorted)
?sort=price&order=sideways 200, sorted ascending
?categry=books 200, all 20 products
?rating.rate=4.3 200, all 20 productsEvery one of these returns 200 and a list that looks reasonable. A sort on a field that doesn't exist is dropped. An order of sideways becomes ascending. A misspelled filter, categry, is ignored, so the client asked for books and got all 20 products. Filtering on a nested field with dot notation isn't supported, and that is ignored too.
The misspelled filter is the worst of these in practice. Take a screen that shows "orders for customer 42", built on a filter parameter that somebody renamed. After the rename, every customer sees every order, and the API returns 200 the whole time. When you test filters, always include a case where the filter should remove most records, and check that it did. A test that only checks "every returned product is in the books category" passes on an empty list as well, so check the count too.
Filters and search
Filters on this API are exact and case-insensitive: ?category=Electronics returns the five electronics products, and ?category=electron returns an empty list. Boolean fields work the same way:
/api/Products?inStock=falseSearch with q is a substring match. This is where encoding starts to matter, because search terms contain spaces, ampersands, percent signs and quotes, and all of those have special meaning in a URL:
?q=YOGA 19
?q=yoga+mat 19
?q=yoga%20mat 19
?q=100%25 6 (description contains "100% organic cotton")
?q=%27 11,13 (titles and descriptions with an apostrophe)
?q= all 20
?q=a&q=b []
?category=books&category=home []Spaces work written either as + or %20. A literal % has to be sent as %25, and it correctly finds the T-shirt made of "100% organic cotton". A search for an apostrophe works and returns two books, which is a small sign that the search isn't building SQL by gluing strings together (the security lesson covers why that matters). If you send a space without encoding it, curl refuses to send the request at all.
The last two rows are worth a note. Repeating a parameter is legal in a URL, and APIs treat it in different ways: first value wins, last value wins, both values are combined with OR, or they are joined into one string. Here, repeating q or category returns an empty list, which suggests the values were joined into something like books,homethat matches nothing. If your product's UI ever lets people pick two categories, this is a bug waiting to be found.
Combining everything
Filter, sort and paginate in one request, and check that they apply in the right order: filter first, then sort, then take the page.
/api/Products?category=electronics&sort=price&order=desc&limit=2&page=2Page 2 holds products 3 and 4 (89.99 and 59.99), and X-Total-Countis 5, the number of electronics products, not 20. If the total ignored the filter, a client would show "page 1 of 10" for a list with three pages. That mistake is common, because totals are often counted by a separate query that someone forgot to update.
Why page numbers break
Page numbers and offsets describe a position in the list: "skip 20, give me 10". That is fine for data that doesn't change. When it does change, positions move:
- You fetch page 1 (items 1 to 10) of a list sorted by newest first.
- Someone creates a new item. It goes to the top, and everything shifts down by one.
- You fetch page 2 (positions 11 to 20). Position 11 is now the item that was 10, so you see it twice.
Deleting an item does the opposite: everything shifts up and one item is never shown. For a news feed that is a small annoyance. For a job that exports all invoices by walking pages, it means an invoice is missing from the export and nobody notices.
Cursor pagination avoids this by describing where you stopped instead of how far you are. Each response hands you a token for the next request:
/api/pagination/cursor?limit=3{
"object": "list",
"url": "/api/pagination/cursor",
"data": [
{ "id": 1, "name": "Smart Headset", "category": "electronics", "price": 42.01, "createdAt": "2026-01-01T21:00:00Z" },
{ "id": 2, "name": "Compact Speaker", "category": "outdoors", "price": 79.02, "createdAt": "2026-01-02T10:00:00Z" },
{ "id": 3, "name": "Deluxe Blender", "category": "office", "price": 116.03, "createdAt": "2026-01-02T23:00:00Z" }
],
"has_more": true,
"next_cursor": "eyJsYXN0SWQiOjN9"
}You pass next_cursor back as ?cursor= and keep going until has_more is false. An invalid cursor gets a clear 400: "Invalid cursor. Pass the 'next_cursor' value from a previous response unchanged."
Keyset pagination is the same idea without the wrapping. The client sends the last ID it saw, and the server returns records after it:
/api/pagination/keyset?after_id=10&limit=3{
"data": [
{ "id": 11, "name": "Smart Lamp", "category": "electronics", "price": 12.11, "createdAt": "2026-01-07T07:00:00Z" },
{ "id": 12, "name": "Compact Headset", "category": "outdoors", "price": 49.12, "createdAt": "2026-01-07T20:00:00Z" },
{ "id": 13, "name": "Deluxe Speaker", "category": "office", "price": 86.13, "createdAt": "2026-01-08T09:00:00Z" }
],
"has_more": true,
"next_after_id": 13
}The third style puts the page links in a Linkheader, the way GitHub's API does. The body stays a plain array:
$ curl -i "https://api.testingapis.com/api/pagination/link?page=2&per_page=3"
HTTP/1.1 200 OK
link: <http://api.testingapis.com/api/pagination/link?page=3&per_page=3>; rel="next",
<http://api.testingapis.com/api/pagination/link?page=34&per_page=3>; rel="last",
<http://api.testingapis.com/api/pagination/link?page=1&per_page=3>; rel="first",
<http://api.testingapis.com/api/pagination/link?page=1&per_page=3>; rel="prev"
x-total-count: 100The page count is right (100 items in pages of 3 is 34 pages). But look at the URLs. The request went to https, and every link points to http. This is a real bug, and a common one: the application runs behind a proxy that handles HTTPS, sees plain HTTP itself, and builds links from what it sees. A client that follows these links either gets redirected or, on a stricter setup, sends its next request unencrypted. Checking the scheme and host of every URL an API generates is a cheap test that catches this.
For each pagination style, the core tests are the same: walk to the end, collect IDs, check for gaps and duplicates, and check what happens on the last page. For cursors and keysets, add a record while you are walking and confirm you still see every original record exactly once.
Exercise
Pick /api/Bookssign in to see and write down the results of each step.
- Find out how many books there are from
X-Total-Count, then walk the list withlimit=4. Did you get every ID once? - Sort by
genre. Fetch it twice and withlimit=3across pages. Is the order within each genre stable? - Filter by a genre, sort by
pricedescending and ask for page 2. IsX-Total-Countthe filtered count? - Misspell the filter name. What comes back?
- Walk
/api/pagination/offsetsign in to see and/api/pagination/linksign in to see to the end. Where do they signal the last page, and do the two agree on the total?
For each surprise, decide whether it is a bug, a documentation gap, or a design choice you would still question.