Overview
paginate is a standalone Go module (github.com/gp-system/paginate) that centralizes the two boring-but-dangerous halves of pagination: normalizing client input (bounds, defaults, overflow protection) and wrapping results for the response model. It offers two modes: classic page/per-page (offset) pagination, covered on this page, and cursor (keyset) pagination, covered on its own page since it carries a different parameter shape and a different set of correctness rules.
import "github.com/gp-system/paginate"
Install
go get github.com/gp-system/paginate@v0.1.0 # the kit itself uses this tag
go get github.com/gp-system/paginate@latest
Go 1.25+. paginate depends only on errs (for ErrInvalidCursor); there is no query builder, no database driver, nothing else to pull in.
Offset pagination
Generated request parameters arrive as optional pointers (*int); Normalize clamps them once, instead of in every handler:
func Normalize(page, perPage *int, opts ...Option) Params
type Params struct{ Page, PerPage int }
func (p Params) Limit() int // SQL LIMIT
func (p Params) Offset() int // SQL OFFSET: (Page-1)*PerPage
p := paginate.Normalize(req.Params.Page, req.Params.PerPage) // defaults: page 1, perPage 20
rows, err := r.db.Query(ctx,
`SELECT ... ORDER BY created_at DESC LIMIT $1 OFFSET $2`, p.Limit(), p.Offset())
The bounds are tunable with options:
p := paginate.Normalize(page, perPage,
paginate.WithDefaultPerPage(15), // when the request omits perPage (default: 20)
paginate.WithMaxPerPage(50), // hard cap, whatever the client asks for (default: 100)
paginate.WithMaxPage(10_000)) // cap on the page number (default: 1,000,000)
WithMaxPage is not cosmetic: Offset() is the product (page-1)*perPage. Without a cap, a hostile ?page= value could provoke an int overflow or a pathological OFFSET. After Normalize, Offset() is guaranteed safe.
Results are wrapped for the response by Page[T]:
func NewPage[T any](items []T, total int64, p Params) Page[T]
type Page[T any] struct {
Items []T
Total int64
Page int
PerPage int
}
Page[T] mirrors the scaffolded shared/paginator.tsp Paginated<T> model, so mapping onto the generated response model is mechanical.
Total and jump-to-page matter more than raw throughput. On a large or fast-growing table, cursor pagination is the one that stays fast and stable as the table grows; see Which one, when for the full comparison.Standalone example
paginate needs nothing from the kit: a plain net/http handler is enough to show the normalize-then-wrap shape.
package main
import (
"encoding/json"
"net/http"
"strconv"
"github.com/gp-system/paginate"
)
type Product struct {
ID string `json:"id"`
Name string `json:"name"`
}
func listProducts(w http.ResponseWriter, r *http.Request) {
page := atoiPtr(r.URL.Query().Get("page"))
perPage := atoiPtr(r.URL.Query().Get("per_page"))
p := paginate.Normalize(page, perPage, paginate.WithMaxPerPage(50))
items, total := fetchProducts(p.Limit(), p.Offset()) // your own query
json.NewEncoder(w).Encode(paginate.NewPage(items, total, p))
}
func atoiPtr(s string) *int {
if s == "" {
return nil
}
n, err := strconv.Atoi(s)
if err != nil {
return nil
}
return &n
}
func fetchProducts(limit, offset int) ([]Product, int64) {
return nil, 0 // your repository call
}
The SQL is yours
An important property: paginate doesn't hide pagination behind a query builder: the SQL is yours, and the module only provides the parameter discipline and the page mechanics (paginate.Normalize for input, paginate.NewPage for the response). In exchange, the LIMIT/OFFSET (or, for cursors, the seek predicate and its index) is in your hands.
Patterns used
- Generic result wrapper (
Page[T]): Design patterns catalogs the cursor sibling,CursorPage[T], in depth. - Functional options (the
With*bound tuning): Design patterns.
Where to next
- Cursor pagination:
CursorParams,NormalizeCursor,CursorPage[T], the seek predicate, and when to reach for it instead of offset.