paginate

Overview

Offset (page/per-page) pagination with the paginate module, normalized parameters and a typed page wrapper.

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.

Offset pagination is the comfortable default for small, admin-style listings, where 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.

main.go
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.
Copyright © 2026