Keyset (cursor) pagination for Go — the stable, fast alternative to OFFSET/LIMIT.
OFFSET gets slow on deep pages (the database still scans everything it skips) and shifts results when rows are inserted mid-scroll. Keyset pagination seeks straight to the last item you saw, so page 1 and page 10,000 cost the same and the order stays stable. This package handles the two annoying parts: encoding an opaque cursor token and slicing a fetched batch into a page plus the next cursor.
type key struct {
CreatedAt time.Time `json:"c"`
ID string `json:"id"`
}
// Fetch limit+1 rows ordered by the SAME key you paginate on.
rows := fetchUsers(ctx, afterCursor, limit+1) // []User
page, err := cursor.Slice(rows, limit, func(u User) (any, error) {
return key{u.CreatedAt, u.ID}, nil
})
// page.Items -> up to `limit` users
// page.Next -> opaque token for ?cursor=... (empty on the last page)
// page.HasMore -> whether another page existsDecode an incoming cursor to build the next query's WHERE:
var k key
if c := r.URL.Query().Get("cursor"); c != "" {
if err := cursor.Decode(c, &k); err != nil {
http.Error(w, "bad cursor", http.StatusBadRequest)
return
}
// WHERE (created_at, id) > ($1, $2) ORDER BY created_at, id
}Fetch one more row than you intend to return. If it comes back, there's another page (HasMore=true) and the sentinel row is trimmed; the Next cursor is built from the last returned item, not the sentinel.
Encode(v any) (string, error)— opaque, URL-safe token from any JSON-serializable key.Decode(token string, v any) error— parse a token; returnsErrInvalidCursoron garbage.Slice[T](batch []T, limit int, keyOf func(T) (any, error)) (Page[T], error)— batch → page.
Cursors are base64url-encoded JSON. They are opaque, not encrypted — don't put secrets in the sort key.
go get github.com/hpower2/cursorMIT