go 141 lines · 3 tabs

Cursor-Based Keyset Pagination for a Postgres Feed API in Go

Shared by codesnips Jul 2026
3 tabs
package feed

import (
	"encoding/base64"
	"encoding/json"
	"time"
)

type Cursor struct {
	CreatedAt time.Time `json:"c"`
	ID        int64     `json:"i"`
}

func (c Cursor) Encode() string {
	b, _ := json.Marshal(c)
	return base64.RawURLEncoding.EncodeToString(b)
}

func DecodeCursor(token string) (*Cursor, error) {
	if token == "" {
		return nil, nil
	}
	raw, err := base64.RawURLEncoding.DecodeString(token)
	if err != nil {
		return nil, err
	}
	var c Cursor
	if err := json.Unmarshal(raw, &c); err != nil {
		return nil, err
	}
	return &c, nil
}
3 files · go Explain with highlit

Keyset pagination (also called seek pagination) avoids the well-known problem with OFFSET: as a client walks deep into a result set, the database must still scan and discard every skipped row, so page 10,000 gets progressively slower and can even skip or duplicate rows when data changes underneath. Instead of an offset, keyset pagination remembers the sort position of the last row seen and asks for rows strictly after it. This snippet shows the full path from an opaque cursor token to a bounded SQL query.

In cursor.go, a page position is encoded as a Cursor holding the composite sort key (CreatedAt, ID). The ID tiebreaker matters: created_at is not unique, so ordering on it alone would make the boundary ambiguous and risk dropping rows that share a timestamp. Encode serializes the cursor to JSON and base64-url encodes it so it survives as a query-string token, while DecodeCursor reverses that and returns nil for an empty token, which the caller treats as "start from the beginning".

In repository.go, ListPosts builds the query around that key. The WHERE (created_at, id) < ($1, $2) row-value comparison is the heart of the technique: Postgres compares the tuple lexicographically, which exactly expresses "everything before this position" in the ORDER BY created_at DESC, id DESC ordering, and it maps directly onto a composite index on (created_at, id) for an index-only seek. The query fetches limit + 1 rows so the repository can tell whether a further page exists without a separate COUNT. If the extra row comes back, it is trimmed off and a next cursor is built from the last kept row.

In handler.go, ListPostsHandler parses limit and the opaque cursor param, clamps the limit to a sane maximum to stop clients requesting huge pages, and returns the rows alongside a next_cursor. An empty NextCursor signals the end of the feed. The main trade-off is that keyset pagination only supports next/previous traversal, not random jumps to arbitrary page numbers, and the cursor must be built from the same columns as the ORDER BY. For infinite-scroll feeds and large tables that constraint is a good deal.


Related snips

Share this code

Here's the card — post it anywhere.

Cursor-Based Keyset Pagination for a Postgres Feed API in Go — share card
Link copied