RaviAnand Mohabir
RaviAnand Mohabir
RaviAnand Mohabir
RaviAnand Mohabir
RaviAnand Mohabir
RaviAnand Mohabir
RaviAnand Mohabir
RaviAnand Mohabir
RaviAnand Mohabir
RaviAnand Mohabir
RaviAnand Mohabir
RaviAnand Mohabir
RaviAnand Mohabir
RaviAnand Mohabir
RaviAnand Mohabir
RaviAnand Mohabir
RaviAnand Mohabir
RaviAnand Mohabir
RaviAnand Mohabir
RaviAnand Mohabir
RaviAnand Mohabir
RaviAnand Mohabir
RaviAnand Mohabir
RaviAnand Mohabir
RaviAnand Mohabir
RaviAnand Mohabir
RaviAnand Mohabir
RaviAnand Mohabir
RaviAnand Mohabir
RaviAnand Mohabir
RaviAnand Mohabir
RaviAnand Mohabir
RaviAnand Mohabir
RaviAnand Mohabir
RaviAnand Mohabir
RaviAnand Mohabir
RaviAnand Mohabir
RaviAnand Mohabir
RaviAnand Mohabir
RaviAnand Mohabir
RaviAnand Mohabir
RaviAnand Mohabir
RaviAnand Mohabir
RaviAnand Mohabir
RaviAnand Mohabir
RaviAnand Mohabir
RaviAnand Mohabir
RaviAnand Mohabir
RaviAnand Mohabir
RaviAnand Mohabir
RaviAnand Mohabir
RaviAnand Mohabir
RaviAnand Mohabir
RaviAnand Mohabir
RaviAnand Mohabir
RaviAnand Mohabir
RaviAnand Mohabir
RaviAnand Mohabir
RaviAnand Mohabir
RaviAnand Mohabir

Cursor-based Pagination with Multiple Column Ordering in Go

Cursor-based pagination is a powerful technique for maintaining API performance with large datasets, enabling smooth navigation through thousands of rows. It also helps avoid issues like duplicate or missing rows when data is added or removed during pagination. In this post, we'll explore an implementation in Go that supports sorting by multiple columns.

At InnoPeak, we're building the Consulting Tool, a platform designed to support infinite scrolling, advanced filtering, and multi-column ordering using Mantine React Table. To handle these requirements efficiently, we chose cursor-based pagination for its seamless integration with Relay-style Connections and Apollo Client. This approach ensures smooth client-server communication and optimal performance when dealing with large datasets. In this post, we'll dive into our Go-based implementation, showcasing how cursor-based pagination can handle complex sorting and filtering scenarios effectively.

Screenshot 2025-01-07 152629.png

Cursor-based pagination uses row references to paginate, allowing clients to request elements that appear after or before the given row in our database. Cursors can be a simple string, although they are usually encoded in some way such as base64.

11|RaviAnand|Mohabir

On the backend side of things, when receiving a cursor, we use the information in it to filter out elements that were already retrieved by the client in previous requests. So it's up to us to ensure all the column data is included that is used during pagination so we're able to properly perform the request.

Challenges with Cursor-based Pagination

Non-Unique Columns

While cursor pagination brings a lot of benefits, it can become challenging when allowing users to sort, especially when those sorted fields are non-unique.

Since we want to avoid returning the same row twice, we need to make sure to always append at least one unique column to our sorting, and include its value in the cursor. A simple solution to that is always including an ID as the final sorting column.

Multiple Sort Fields

When sorting by multiple fields, we want to try and find the next row after each individually sorted field, if the previous fields are the same.

For example, let's take a users table, we have the first_name and last_name columns, and two entries in our database with the first_name "Mark":

users.sql
1| id | first_name | last_name |
2| -- | ---------- | --------- |
3| 1  | Mark       | Brown     |
4| 2  | Mark       | Johnson   |

In order to account for the fact that we're going to be sorting by first_name and last_name we first need to check if first_name > :first_name , if not, we need to check if first_name = :first_name AND last_name > :last_name to account for the duplicate names.

A Simple Implementation

Now that we know the requirements of cursor-based pagination, we can write a simple implementation that takes all these things into account. We'll allow our client to pass us a list of fields to order by, as well as the after argument which is a cursor. Returning a list of our users along with the cursor.

To start we'll apply ordering. I'm using Squirrel to query my database, but you can use whatever you're most familiar with.

We're going to iterate through the orderBy provided to us by the client, and apply them to our query:

main.go
1package main
2
3import (
4	"database/sql"
5	"encoding/base64"
6	"encoding/json"
7	"fmt"
8	"log"
9	"os"
10	"strconv"
11	"strings"
12
13	"github.com/Dan6erbond/cursor-pagination-squirrel/model"
14	sq "github.com/Masterminds/squirrel"
15	_ "github.com/lib/pq"
16)
17
18type OrderDirection string
19
20const (
21	OrderDirectionASC  OrderDirection = "ASC"
22	OrderDirectionDESC OrderDirection = "DESC"
23)
24
25type UserOrderField string
26
27const (
28	UserOrderFieldID        UserOrderField = "ID"
29	UserOrderFieldFirstName UserOrderField = "FirstName"
30	UserOrderFieldLastName  UserOrderField = "LastName"
31)
32
33type UserOrderBy struct {
34	Field     UserOrderField
35	Direction *OrderDirection
36}
37
38type UserWithCursor struct {
39	model.User
40	Cursor string
41}
42
43var (
44	userOrderFieldMap = map[UserOrderField]string{
45		UserOrderFieldID:        "id",
46		UserOrderFieldFirstName: "first_name",
47		UserOrderFieldLastName:  "last_name",
48	}
49	db *sql.DB
50)
51
52func marshalCursor(user model.User) string {
53	return base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%d|%s|%s", user.ID, user.FirstName, user.LastName)))
54}
55
56func find(orderBy []UserOrderBy, after *string, limit *uint64) ([]*UserWithCursor, error) {
57	users := sq.Select("*").From("users")
58
59	for _, ob := range orderBy {
60		direction := OrderDirectionASC
61
62		if ob.Direction != nil {
63			direction = *ob.Direction
64		}
65
66		users = users.OrderBy(userOrderFieldMap[ob.Field] + " " + string(direction))
67	}
68
69	if limit != nil {
70		users = users.Limit(*limit)
71	} else {
72		users = users.Limit(10)
73	}
74
75	rows, err := users.RunWith(db).Query()
76
77	if err != nil {
78		return nil, err
79	}
80
81	var us []*UserWithCursor
82	for rows.Next() {
83		var user UserWithCursor
84
85		if err := rows.Scan(&user.ID, &user.FirstName, &user.LastName); err != nil {
86			return nil, err
87		}
88
89		user.Cursor = marshalCursor(user.User)
90
91		us = append(us, &user)
92	}
93
94	return us, nil
95}

Now, in order to return the next rows if the after parameter is provided, we have to check each ordered column's direction. If it's ascending, all elements after will use the > operator, otherwise for descending order we'll use <.

Additionally, as previously mentioned, for each order field we have to ensure that all previous fields are equal to the previous element, since we only want to order by a field if the previous fields were identical.

Then, finally, we're going to check if the user is ordering by a unique column - in this case ID - if not, we'll append it to the end.

main.go
1func unmarshalCursor(cursor string) (uint, string, string, error) {
2	bytes, err := base64.StdEncoding.DecodeString(cursor)
3
4	if err != nil {
5		return 0, "", "", err
6	}
7
8	str := string(bytes)
9
10	id, err := strconv.ParseUint(strings.Split(str, "|")[0], 10, 64)
11
12	if err != nil {
13		return 0, "", "", err
14	}
15
16	return uint(id), strings.Split(str, "|")[1], strings.Split(str, "|")[2], nil
17}
18
19func find(orderBy []UserOrderBy, after *string, limit *uint64) ([]*UserWithCursor, error) {
20	users := sq.Select("*").From("users").PlaceholderFormat(sq.Dollar)
21
22	var (
23		hasID            bool
24		prevFields       = make([]UserOrderField, 0)
25		cursorConditions = make(sq.Or, 0)
26	)
27	for _, ob := range orderBy {
28		direction := OrderDirectionASC
29
30		if ob.Direction != nil {
31			direction = *ob.Direction
32		}
33
34		users = users.OrderBy(userOrderFieldMap[ob.Field] + " " + string(direction))
35
36		if after != nil {
37			id, firstName, lastName, err := unmarshalCursor(*after)
38
39			if err != nil {
40				return nil, err
41			}
42
43			conds := make(sq.And, len(prevFields))
44			for i, field := range prevFields {
45				switch field {
46				case UserOrderFieldID:
47					conds[i] = sq.Eq{"id": id}
48				case UserOrderFieldFirstName:
49					conds[i] = sq.Eq{"first_name": firstName}
50				case UserOrderFieldLastName:
51					conds[i] = sq.Eq{"last_name": lastName}
52				}
53			}
54
55			if direction == OrderDirectionASC {
56				switch ob.Field {
57				case UserOrderFieldID:
58					conds = append(conds, sq.Gt{"id": id})
59				case UserOrderFieldFirstName:
60					conds = append(conds, sq.Gt{"first_name": firstName})
61				case UserOrderFieldLastName:
62					conds = append(conds, sq.Gt{"last_name": lastName})
63				}
64			} else {
65				switch ob.Field {
66				case UserOrderFieldID:
67					conds = append(conds, sq.Lt{"id": id})
68				case UserOrderFieldFirstName:
69					conds = append(conds, sq.Lt{"first_name": firstName})
70				case UserOrderFieldLastName:
71					conds = append(conds, sq.Lt{"last_name": lastName})
72				}
73			}
74
75			cursorConditions = append(cursorConditions, conds)
76
77			if ob.Field == UserOrderFieldID {
78				hasID = true
79			}
80		}
81
82		prevFields = append(prevFields, ob.Field)
83	}
84
85	if !hasID && after != nil {
86		id, firstName, lastName, err := unmarshalCursor(*after)
87
88		if err != nil {
89			return nil, err
90		}
91
92		conds := make(sq.And, len(prevFields))
93		for i, field := range prevFields {
94			switch field {
95			case UserOrderFieldID:
96				conds[i] = sq.Eq{"id": id}
97			case UserOrderFieldFirstName:
98				conds[i] = sq.Eq{"first_name": firstName}
99			case UserOrderFieldLastName:
100				conds[i] = sq.Eq{"last_name": lastName}
101			}
102		}
103
104		conds = append(conds, sq.Gt{"id": id})
105		cursorConditions = append(cursorConditions, conds)
106	}
107
108	if len(cursorConditions) != 0 {
109		users = users.Where(cursorConditions)
110	}
111
112	if limit != nil {
113		users = users.Limit(*limit)
114	} else {
115		users = users.Limit(10)
116	}
117
118	rows, err := users.RunWith(db).Query()
119
120	if err != nil {
121		return nil, err
122	}
123
124	var us []*UserWithCursor
125	for rows.Next() {
126		var user UserWithCursor
127
128		if err := rows.Scan(&user.ID, &user.FirstName, &user.LastName); err != nil {
129			return nil, err
130		}
131
132		user.Cursor = marshalCursor(user.User)
133
134		us = append(us, &user)
135	}
136
137	return us, nil
138}

Given an ordering of first_name ASC, last_name DESC, and a cursor, this code will output the following SQL:

paginated.sql
1SELECT *
2FROM users
3WHERE (
4    (first_name > $1)
5    OR (
6      first_name = $2
7      AND last_name < $3
8    )
9    OR (
10      first_name = $4
11      AND last_name = $5
12      AND id > $6
13    )
14  )
15ORDER BY first_name ASC,
16  last_name DESC
17LIMIT 10

As you can see, with this logic we're ensuring that we're filtering based on the priority of the order fields. And by doing this in the same loop as the ordering itself, we get concise code that remains readable.

Final Words

While we explored a working implementation of cursor-based pagination in Go using the lightweight Squirrel library for dynamic queries, it's worth noting that every project should create its own set of helper functions. These helpers can standardize the columns and values used for pagination, reducing the reliance on switch statements in your main query methods. This approach not only streamlines your code but also makes it easier to add support for new order columns as your project evolves.

Thank you for reading! To see the full implementation and dive deeper into the code, visit the GitHub repository.