Efficient database pagination using cursors

Introduction

Usually, you can paginate through results using LIMIT X OFFSET Y:

  1. SELECT * FROM entries ORDER BY id ASC LIMIT 10 OFFSET 0; -- first page
  2. SELECT * FROM entries ORDER BY id ASC LIMIT 10 OFFSET 10; -- second page
  3. SELECT * FROM entries ORDER BY id ASC LIMIT 10 OFFSET 20; -- third page

Such pagination method works well, but you may notice that the query becomes slower and slower as the offset grows. That happens because OFFSET 100000 tells database to read and discard 100,000 rows which makes performance with large offsets unacceptable. The usual response here is to limit the allowed offset range, for example, you could limit the number of allowed pages to 1000.

But what if you can’t limit the number of pages? For example, GitHub must allow users to view all commits in a repo no matter how big a repo can be. The answer is cursor pagination.

Cursor pagination

Cursor-based pagination works by returning to the client a pointer (cursor) to the last item on the page. To get the next page, the client passes the cursor to the server and the server returns results after the given cursor. The main limitation of this approach is that the client can’t jump to a specific page and does not know the total number of pages.

TIP

Cursor-based pagination provides much worse user experience than the classic pagination. Use it only when you must.

Because the cursor must unambiguously identify the row, you can only use cursor-based pagination on primary keys or columns with an unique constraint. That also ensures that the query uses an index and can quickly skip already paginated rows.

Example

Let’s paginate the following model using primary key as a pointer:

  1. type Entry struct {
  2. ID int64
  3. Text string
  4. }

Our helper Cursor struct may look like this:

  1. type Cursor struct {
  2. Start int64 // pointer to the first item for the previous page
  3. End int64 // pointer to the last item for the next page
  4. }

To retrieve the next page, we need to continue from the cursor pointing to the last item:

  1. func selectNextPage(ctx context.Context, db *bun.DB, cursor int64) ([]Entry, Cursor, error) {
  2. var entries []Entry
  3. if err := db.NewSelect().
  4. Model(&entries).
  5. Where("id > ?", cursor).
  6. OrderExpr("id ASC").
  7. Limit(10).
  8. Scan(ctx); err != nil {
  9. return nil, Cursor{}, err
  10. }
  11. return entries, NewCursor(entries), nil
  12. }

To retrieve the previous page, we need to iterate backwards starting from the cursor pointing to the first item:

  1. func selectPrevPage(ctx context.Context, db *bun.DB, cursor int64) ([]Entry, Cursor, error) {
  2. var entries []Entry
  3. if err := db.NewSelect().
  4. Model(&entries).
  5. Where("id < ?", cursor).
  6. OrderExpr("id DESC").
  7. Limit(10).
  8. Scan(ctx); err != nil {
  9. return nil, Cursor{}, err
  10. }
  11. return entries, NewCursor(entries), nil
  12. }

We can use those methods like this:

  1. page1, cursor, err := selectNextPage(ctx, db, 0)
  2. if err != nil {
  3. panic(err)
  4. }
  5. page2, cursor, err := selectNextPage(ctx, db, cursor.End)
  6. if err != nil {
  7. panic(err)
  8. }
  9. prevPage, _, err := selectPrevPage(ctx, db, cursor.Start)
  10. if err != nil {
  11. panic(err)
  12. }

See exampleCursor pagination - 图1open in new window for details.