API pagination design
solovyov.net
solovyov.net
Cursors elegantly sidestep these issues.
But this has the drawback of only working as long as the sorting field doesn't have many duplicates. You have to beware if timestamps are high resolution enough for your application or if you have 100+ entries with same family name.
Usually users are smart(lazy) enough to realize they got more than 100 results and will narrow down the search instead. When was the last time you looked beyond even the first 5 results on google yourself, i'd rather adjust the search term than go to page 2.
That's the whole point of a cursor, isn't it?
And pagination just means querying the records to return a limited subset.
Now it might not be as accurate as having a cursor but it is a lot easier to implement, and still beats simply ?page=2 without cursor, which is the level you see in most apis today.
The cursor is never invalidated. You can always get a valid response from any cursor, whether the resource exists or was already deleted. That's the whole point of using a cursor.
Perhaps your confusion lies in assuming that a cursor's life cycle is tied to a specific object. It isn't. A cursor means "get me whatever resources would immediately follow this resource". It matters nothing if the cursor exists or not. When you run the query, you will get exactly the resource which would follow the cursor. That's by design.
If I'm sorting by last name, and I sort by last name, then id, I can save the cursor as last name + id, and be able to continue right where I left off.
Relevant song: https://youtube.com/watch?v=Z0JhC3LO0-8
I mean, these problems happen if you use offsets too, and my point is that they don't matter.
The most hardcore solution to those would be pre-computing pages but I doubt it makes sense to bring even more state to the search results.
Really? The underlying storage engine of most databases stores all data necessary for an "as of" query. Simply ignore all data pages newer than the timestamp cutoff, and prevent the garbage collection of any page that could be part of such a query.
In fact thats the way [eg. postgres] can do a long-running query on a table that someone else is modifying. You in effect are looking at a snapshot of the table at the moment you started the query, even if it takes an hour to produce all the results.
That makes absolutely no difference. You're still querying the database for elements that come after a value from a data type for which there is an order. You don't need that element to exist to run a comparison. For example, consider a timestamp-based query: you don't need an element with that specific date to exist to search for any element whose timestamp was taken after an arbitrary moment.
2 items per page
first page is A&B. Next query is "WHERE ID>'B'".
What's the problem?
Because LIMIT...OFFSET will also arguably give you the wrong result. E.g. Aa is inserted. The user will see "B" both as the last item on the first page, and the first item on the second page.
Or with your example:
Aa Ab B C
Now "A" is inserted between page loads. The user will never see "A". What's right for your application? Maybe a notification saying "previous pages have gotten new items". Maybe not. It all depends.
Reddit can be annoying if you go page after page. As stories are bumped down you see them again. That, in my opinion, is a bug. But solving it requires something completely different from merely defining page boundaries.
Or just include everything in a single response, HTTPS was designed to handle arbitrary length payloads if I recall correctly. You might want to use a more streamable format than JSON though.
You don't need that. You just need to get the collection resource to be cacheable and subsequently track your resources' state with conditional requests.
https://developer.mozilla.org/en-US/docs/Web/HTTP/Conditiona...
Did you mean we should stream data over WebSocket or use HTTP/2 or do we need to do something different altogether?
This is annoying to do with JSON though because you need to remember to close all your brackets, something like CSV is a lot easier because you can just write the header once and then just stream the data.
Edit: Looks like Python supports this using chunked transfer by simply providing the HTTP request data as an iterator.
page 1 elements: A B C D E
page 2 elements: F G H I J
so 5 elements in each page.
Suppose I'm on page 2. If I insert a new element Q and it gets pushed as first then page 1 will have Q A B C D. Now if I go back to page 1, I'll get A B C D E and also a token/pointer to go back one more time only to retrieve Q.
So while cursor solved the issues you mentioned, it still will have this case in which pagination gets broken. I'm interested how we can tackle this.
This means any stateless pagination api is fundamentally broken. Please don't make promises you cannot keep.
For elastic: https://www.elastic.co/guide/en/elasticsearch/reference/curr...
My favorite SQL implementation of cursor-based keyset pagination can be found on this Hasura issue: https://github.com/hasura/graphql-engine/issues/141, specifically this comment: https://github.com/hasura/graphql-engine/issues/141#issuecom.... Something so elegant about this SQL. I enjoy it a lot.
Also really enjoy this answer about how to implement keyset pagination on multiple columns:
https://stackoverflow.com/questions/38017054/mysql-cursor-ba...
I implemented a keyset paging library for sqlalchemy which supports this, and found the nicest way to do it is to swap the comparisons for the descending columns. For instance if this is an all-ascending paginated query:
select ... where row(c1, c2, c3) > row(11, 12, 13) order by c1, c2, c3
Then with c2 descending it would look like this: select ... where row(c1, 12, c3) > row(11, c2, 13) order by c1, c2 desc, c3> For row comparisons, (a, b) > (x, y) is equivalent to:
> (a > x) OR ((a = x) AND (b > y))
[1] https://dev.mysql.com/doc/refman/8.0/en/comparison-operators...
SELECT * EXCEPT FOO_ID FROM FOO;
I have always wanted this but have never vome across it...
Otherwise, dynamic column names (that's your search engine fodder) require introspection…
-- "Chinook" sample database
select column_name
from information_schema.columns
where table_name = 'Track'
except
select 'UnitPrice'
… coupled with the moral equivalent of PL/pgSQL `execute` (i.e. run-time eval) statement.I mean, the information from the information_schema must be updated anyway when one deletes a column or table, so I thought maybe a function like that which looks it up could exist.
I will try with PL/pgSQL, have long wanted to familiarise myself with it anyway.
[1] https://blog.jooq.org/2018/05/14/selecting-all-columns-excep...
If there's subset of rows you frequently want, you may just be able to define a view and use that. (At one point, I defined a text macro in my terminal to list the fields I usually wanted on our "orders" table.)
WHERE NOT EXIST (SELECT ...)
can help?
I don't know any way to do that personally.
There's many UX benefits for paginated queries. It gives customer a clear overview of how many products there are and customer can also navigate faster to get a better understanding of what general prices are. If the table/list doesn't have good filters, customer can easily act out binary search to find what they are looking for in an ordered list of items.
Same with highscores, forums threads and other similar things. Say you are browsing highscores and you want to see what scores are around 10000th position etc.
As a user I find having only prev/next buttons a bit claustrophobic in this case. I think UX should trump whatever performance gains there are from it.
That said, I think the percent of average ability internet users (ie. people visiting an ecommerce shop) will virtually never attempt to navigate by changing values in a query string param. High scores on a gaming forum, maybe more likely. Either way, if users need the ability to jump through pages, I would hope that would be exposed in a UI rather than expecting people to edit the URL directly.
If you look at any e-commerce systems, ranging from WooCommerce to Shopify, Amazon and Walmart, I think they all use pagination. Some Amazon pages do have infinite scroll however
I have also been frustrated without having the ability to quickly go through commits... Or I think releases for that matter?
For instance, browsing through tags/release versions, which should be a very valid use-case in my opinion also uses cursor based navigation.
git log has a reverse option to reverse the order of commits displayed. You can also provide a commit reference like HEAD~10 to get the 10th ancestor from the head commit.
Does Github have a way to specify URL parameters to achieve something similar?
1. Nothing stops you from having your cursors be implemented via a SQL offset under the hood.
2. You can have your cursors be base64 data, and embed a cursor version in the ID. You can use this to change how your pagination works without breaking clients during the transition period. (You'll need to document+enforce a maximum validity for pagination tokens to do this successfully.)
3. You can encrypt your pagination tokens, so clients don't get used to making assumptions about how your pagination works under the hood. (This isn't security by obscurity, it's just defending against Hyrum's Law [1].)
It doesn't cost you much to implement cursors early on, and unlike offsets they will grow with you across the evolution of your API.
Sometimes you want position-based queries, because you explicitly do want everything to be positional.
Sometimes you want to combine the two techniques, e.g. if you jump to the middle of a large, ever-changing list, and then want to retrieve the next batch of results after wherever you happen to be.
I like the design that JMAP ended up with <https://tools.ietf.org/html/rfc8620#page-45>: you can specify a position integer, or an anchor ID and optionally an anchorOffset integer. An anchor is a restricted case of a cursor, being the ID of an entity in the result set rather than an opaque type that could embed other information (such as coroutine addresses, thinking back to the old days of HN), but has the notable advantage of being client-controllable.
(Because JMAP is very much an object synchronisation protocol and not just an API for objects that don’t record their history in any way, like your common-or-garden REST API, this is also paired with change tracking so that you can be notified when the set of entities matching the query changes; this is how Fastmail’s webmail (probably the most-used JMAP client for now) updates its message lists for mailboxes (roughly `Email/query { filter: { inMailbox: inbox } }`) and search results (roughly `Email/query { filter: { text: "foo" } }`). Such a principled approach to changing state is extremely valuable for supporting live updating of a UI, and pretty much essential for offline support.)
This is not an "exception to 'you should offer cursors'" this is offering cursors.
But yes, I'm surprised it would show up on hackerNEWS.
While the post claims that is not possible to go back in the result set, it isn't true but it isn't just that simple, a way being to encode the current and the next pointer in the cursor argument, which can be used to go back, also, I think that you can commonly reverse the ordering and play with the query conditions to go back.
On the other hand, the biggest drawbacks I have experienced is:
- Dealing with a cursor where the item involved is actually deleted before you do the next query, there are many strategies but it is certainly something you need to plan for.
- Building APIs that allow sorting by different arguments in the result, which can get the query conditions tricky easily.
In any case, its always worth exploring this mechanism for people not aware of it.
The reason opaque pagination is an antipattern is because you can’t optimistically fetch resources.
So your customer, the person that paying you for your product, needs to wait for some number of synchronous reads.
With non-opaque offsets these can be done in parallel. If the typical request requires 4 pages, these can be done 4 at a time and of it is less than 4 pages those can be discarded.
This is a clever hack that ends up being user hostile in actual practice. Remember APIs are designed for the benefit of the consumer vs the benefit of the maintainers.
It seems worth noting a high number of concurrent queries to the same database shard as part of the same overall page load can be very wasteful of CPU as database load increase, due to the cost of context switching. https://github.com/brettwooldridge/HikariCP/wiki/About-Pool-... dives into that.
The customer is not always right.
It's also not clear what the practical issue with sequential requests are? Multiple parallel requests may get caught in a rate limiter, and impose much more work on the backend than a cursor (multiple unnecessarysort/discards). It's not a given that spamming a service gets you all the data any faster than using a cursor.
> With non-opaque offsets these can be done in parallel. If the typical request requires 4 pages, these can be done 4 at a time and of it is less than 4 pages those can be discarded.
If the typical request requires 4 pages, then your page size is suboptimal.
I definitely agree that just having opaque page tokens without the ability to say “I want up to 100” leads to needless pain for clients and overall system inefficiency.
As a user I find having only prev/next buttons a bit claustrophobic in this case. I think UX should trump whatever performance gains there are from it.
and why does the DB need to wait for some number of synchronous reads?
A starting read is needed to obtain the first cursor, hence the synchronous read.
i don't see anyone arguing for "next_page=abcdef1234". realistically it's more like "cursor=abcdef1234&limit=10". slightly less opaque. still your point about asking for multiple pages in parallel still stands.
i think i agree with mostly everyone here in that this is a fine tradeoff to make and so would favor cursors over offsets. (unclear how cursors relate to "keysets")
It is perfectly possible to have a "previous page" link. Your pagination needs to support "before" cursor semantic, allowing a consumer to retrieve the N items before the row identified by the cursor.
It is also possible to create a "next page". You only need to take your current page's last id and use it in your query like "where id > current_last_element_id".
"first page" and "last page" are possible as well, simply do "where id > 0" for first and "where id <= (subselect max id)".
You may need to invert order here and there, but it is perfectly possible to use cursor base pagination in a GUI as long as you don't need to provide jumping to a specific page number.
Offset pagination works fine in this scenario. But something like `where id > 42 limit 100` fails with an arbitrary sorting order on non-unique columns. All I could do would be to generate the unpaginated result, then find the cursor within it, and take the limit after that. Which is too inefficient to be practical.
If I missed a decent solution, please tell.
I managed to get this working with compound primary keys too: click "next" at the bottom of this page for a demo: https://latest.datasette.io/fixtures/compound_three_primary_...
The code is pretty complicated - mostly here: https://github.com/simonw/datasette/blob/0.53/datasette/view...
Cursor based is good when you know exactly where the next page go (Eg: chat message when you want to load previous messages). Offset based is good when you want to browse data randomly (Eg: you want to go to page 10 when you are in page 1)
It gives you a great overview on how things are generally priced etc if you order by price.
Here's what that looks like in ES: https://www.elastic.co/guide/en/elasticsearch/client/java-re...
The APIs I generally work will abstract away most of the scroll context configuration stuff, and just return a page of results, plus the ScrollId for the next page.
Looking back, I would exclude classic pagination from a public API and go full cursor. Possibly include an optional total count that can be requested (counts are expensive in PG though).
When using LIMIT, it is important to use an ORDER BY clause that constrains the result rows into a unique order. Otherwise you will get an unpredictable subset of the query's rows. You might be asking for the tenth through twentieth rows, but tenth through twentieth in what ordering? The ordering is unknown, unless you specified ORDER BY.
And looks like pagination queries provided by author do not take sorting in to account, which means that items on page 100 will be with id greater than 10000, but in undefined order. explain analyze select id from product where id > 10000 limit 100[1] https://medium.com/swlh/why-you-shouldnt-use-offset-and-limi... [2] https://github.com/IvoPereira/Efficient-Pagination-SQL-PoC
For me, with offset, the FIRST run was 25ms, but subsequet runs are around 2-3ms. For `id > 10000`, it was under 0.05ms.
It's also very odd that they don't do an `order by`. This seems important. Default ordering is semi-stable. It'll tend not to change, but it can at any point. Benchmarking without that may be misleading.
Perhaps it’s the world of ‘Django developers’ I find myself in. But the fact this simple design pattern reaches the front page of Hackernews worries me.
I’ve personally had backend developers give confused looks and diatribes about simplicity when I’ve suggested this approach. Frontend developers frequently give the greatest resistance, which I think is because they most often act in concert with product managers who are welded to certain UX idioms that they chose without fully imagining the engineering consequences of.
One approach which I try to push people towards is fetching a big list (N≈5000) and paginating it however the UX designer wants. The response of the big list should be sparse, that is without including additional fields that aren’t displayed by the front end (and fetching more data when the user navigates). You’ll frequently get puzzled looks suggesting this, but benchmarks will usually show that fetching 5000 rows and a small number of columns takes a few milliseconds, and can be serialised into 150kb. On balance it ends up being faster: as you’ll get fewer network requests; fewer round trips to the database; fewer fetches; and less time spend serialising (which is a major bottleneck itself that Python developers ignore).
It has the potential to become complex, so I would assume it was an "also" capability, as opposed to an "instead of" capability.
I like the idea of a "range" syntax ("[X..<Y], [X...Y], [X<..Y], [X<.<Y], etc).
When I write APIs, server performance isn't really an issue for me. Most of the performance bottleneck, in my experience, is data transfer, so being able to optimize that, pays the greatest dividends.
Also, making the API easy to understand, and express semantically, is important.
https://gist.github.com/briandilley/a0715f9f85b632b7080f8921...
The assumption is to include the primary key as mandatory order by at the end implicitly.
This makes a lot easy in other situations like if mulitple pages of records found with same value.. etc...
If you want to order by creation time, then have a column with creation time. With resolution down to nanoseconds it'll be unique. And if not, well your query should be deterministic and "order by ctime,id".
But the problem is always possible. Doesn't matter what method you use. It applies to OFFSET … LIMIT, guid, name, or ID.
It's up to you to decide what you want the user experience to be if someone is on page 1, presses "next page", but before you click someone deleted the entire first page. And maybe there is no page 2 anymore.
These questions are application dependent, I'd say. And while I may have my opinions on the best way to handle it, it's an orthogonal question to what this article is talking about.
Edit: Oh, and there's GUID type 1, so "GUID" doesn't inherently mean "not sortable".
https://github.com/hasura/graphql-engine/issues/141#issuecom...
or
https://stackoverflow.com/questions/38017054/mysql-cursor-ba...
[1] https://www.elastic.co/guide/en/elasticsearch/reference/curr...
Are GraphQL-fans so uncapable you have to put your GraphQL-spamming in every single post? Didn't you learn anything else in your life?
Design your api or page so that you receive all or more entries than you could possibly need.
If the user wants them all pagination is just in the way. If the user does not want them all give them as many as they are likely to be able to handle.
"Surely that's what he wanted."
Don’t design like you are Twitter if you are not. It is a waste of man hours. If I as a client need 10 000 rows of data that is what I will ask for either in one request or in a 100 consecutive.
I wrote something similar, includind a playground which serves as a benchmark to compare the various pagination options:
First, you have to maintain state in some kind of session on the app server.
Second, if you have more than one app server, you'll have to share the sessions across them using Redis or Hazelcast or something.
Third, cursors have to be closed, which means you have to know when the user is done with the result set. This is impossible to know. The user could go to page 2 and then go to lunch. So you have to leave it open for a while, and then have some kind of timeout. Have a lot of users doing the same thing? Memory fills, both on the app server and in the database server.
Fourth, your cursor is going to leave a transaction open, which interferes with updates and all kinds of other things.
Fifth, most users never go to page 2 anyway. They run another search.
Keeping cursors open in a web app is a spectacularly bad idea.
UPDATE -- I am in error. The article does not recommend a normal database cursor. Disregard my comment.
> Generally, you have some ordering criteria, for example, product id. In this case, you’ll encode your product id with some reversible algorithm (let’s say hashids). And on receiving a request with the cursor you decode it and generate a query like `WHERE id > :cursor LIMIT 100`.
Put a notification on the page "your search query returned too many results, please refine your criteria"
The problem with offset and limit is that you have to actually find the previous N results in order to get the N+1th. But with a cursor you can just "continue".
(when I say "seek" I mean use indexes. I'm comparing to listing a tar file (requires reading the whole thing to find the file in the middle) vs zipfile (seek to where the file is, and read))
Do you create sub tables to handle the arrays?