paginate

package module
v1.0.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 2, 2026 License: MIT Imports: 7 Imported by: 0

README

paginate

Go Reference

Pagination math and template-ready data for Go. Zero dependencies, renderer-agnostic: the library computes page numbers, offsets, the ellipsis window, opaque cursors, and an RFC 8288 Link header — you render them however you like.

Nothing here returns an error for bad page input. Out-of-range, garbage, and overflowing values are clamped to a valid page; a pager should never be the reason a request fails.

Install

go get github.com/pigfox/paginate@v1.0.0

Requires Go 1.23+ (for the iter.Seq adapter).

30-second example

package main

import (
	"fmt"

	"github.com/pigfox/paginate"
)

func main() {
	items := make([]string, 47)
	for i := range items {
		items[i] = fmt.Sprintf("row %d", i+1)
	}

	// A page number straight from an untrusted query string.
	p := paginate.New(len(items), 10, paginate.ParsePage("3"))

	fmt.Println(p.CurrentPage, "of", p.TotalPages) // 3 of 5
	fmt.Println(paginate.Slice(items, p))          // rows 21..30

	for _, w := range p.Window(1) {
		if w.Gap {
			fmt.Print("… ")
		} else {
			fmt.Print(w.Page, " ")
		}
	}
	// 1 2 3 4 5
}

Every field of PageCurrentPage, PerPage, TotalItems, TotalPages, Offset, Limit, HasPrev, HasNext, PrevPage, NextPage — is exported and needs no method to render, so a Page drops straight into an html/template.

The window

Page.Window(radius) returns the numbered pager as a slice of WindowItem, each either a page number or a gap marker (). The algorithm obeys these laws for every total, page, and radius (proven by a property test sweeping totals 0–500):

  • Page 1 and the last page are always present.
  • Every page within radius of the current page is present.
  • Output is ascending, deduplicated, and never places two gaps side by side.
  • A gap of exactly one page is never an ellipsis: if pages 1 and 3 show, page 2 renders as a number, not . Ellipses appear only for runs of two or more hidden pages.
  • Radius 0, single-page, and empty (TotalPages == 0) sets all behave.

Worked examples:

total 23, perPage 5, page 3, radius 1   ->  1 2 3 4 5
total 100, perPage 10, page 5, radius 1 ->  1 … 4 5 6 … 10

Window allocates exactly one slice. In a hot path, AppendWindow writes into a buffer you own and, reused via buf[:0], allocates nothing.

Cursor pagination

Offset mode and cursor mode share vocabulary but not a forced abstraction — pick one per endpoint. Cursors are opaque, URL-safe, and tamper-tolerant: a malformed or tampered token decodes to "start from the beginning", never an error page.

tok := paginate.EncodeCursor(paginate.Cursor{ID: 1042, Dir: paginate.After})
c, ok := paginate.DecodeCursor(tok) // ok reports whether tok was well-formed

NewCursorPage builds the next/prev tokens from a window you just fetched.

Pinned rows

Items that render above the pager (a "featured" row) are a pattern, not machinery: pin them out, paginate the rest, and they never shift the window because they never enter the total.

pinned, rest := paginate.Partition(all, func(e Escrow) bool { return e.Featured })
p := paginate.New(len(rest), 20, page) // count excludes pinned

Examples

Runnable servers, each a single main.go:

  • examples/plainlinks — server-rendered ?page=N anchors with html/template, no JavaScript. Includes the reusable pager partial.
  • examples/fetchjson — a JSON endpoint with Link headers and a vanilla fetch() client.
  • examples/htmx — the same server with hx-get fragment swapping.

The htmx example loads htmx from a CDN for brevity. A no-CDN deploy vendors htmx into its own static assets; nothing about the server changes.

Versioning

Semantic versioning. The exported surface is frozen for the v1 line: no exported name, signature, or documented behavior changes without a major-version bump (a v2 module path). New functionality arrives as additions.

Tests

./tests.sh   # race detector + 100% coverage gate on the core package

License

MIT

Documentation

Overview

Package paginate computes correct pagination math and hands back template-ready data. It renders nothing itself: you get page numbers, offsets, an ellipsis window, opaque cursors, and an RFC 8288 Link header, and you render them however you like.

Nothing in this package returns an error for bad page input. Out-of-range, garbage, and overflowing values are clamped to a valid page. A pager should never be the reason a request fails.

Offset pagination

New turns a total item count, a page size, and a requested page into a Page whose fields drop straight into an html/template. ParsePage makes untrusted query strings safe to feed it. Page.Window produces the numbered pager with ellipses; Slice and SliceSeq cut the visible items out of a slice or a Go 1.23 iterator.

Cursor pagination

EncodeCursor and DecodeCursor move an opaque, tamper-tolerant cursor through a query string; NewCursorPage builds the next/prev tokens from a window you just fetched. Offset and cursor modes share vocabulary but not a forced abstraction — pick one per endpoint.

Versioning

This module follows semantic versioning. The exported surface is frozen for the v1 line: no exported name, signature, or documented behavior will change without a major-version bump (a v2 module path). New functionality arrives as additions.

Index

Constants

View Source
const DefaultPerPage = 20

DefaultPerPage is the page size New falls back to when it is given a perPage of zero or less.

Variables

This section is empty.

Functions

func EncodeCursor

func EncodeCursor(c Cursor) string

EncodeCursor renders c as an opaque, URL-safe token (base64url, unpadded). The encoding is not a security boundary — it is tamper-tolerant, not tamper-proof; see DecodeCursor.

func LinkHeader

func LinkHeader(base *url.URL, p Page, param string) string

LinkHeader builds an RFC 8288 Link header value advertising the first, prev, next, and last pages relative to base. Existing query parameters on base are preserved and re-escaped; only the page parameter (param, or "page" when param == "") is rewritten. The prev and next rels are omitted at the edges; first and last are always present when there is at least one page. Returns "" when TotalPages == 0 or base is nil.

The links inherit base's form. A server-side r.URL has no scheme or host, so they come out as relative references (RFC 8288 permits these):

w.Header().Set("Link", paginate.LinkHeader(r.URL, p, "page"))
// </items?page=1&sort=new>; rel="first", ...

For absolute links, set base.Scheme and base.Host first:

u := *r.URL
u.Scheme, u.Host = "https", r.Host
w.Header().Set("Link", paginate.LinkHeader(&u, p, "page"))
// <https://api.example.com/items?page=1&sort=new>; rel="first", ...

func ParsePage

func ParsePage(s string) int

ParsePage turns untrusted query input into a page number that is always >= 1 and never panics. It trims surrounding whitespace and reads a base-10 integer:

  • "", "abc", "1.5", "-7", "0" -> 1
  • "007", " 42 " -> 7, 42
  • a value larger than int can hold -> the maximum int, which New then clamps down to the last page ("give me the far end" reads better than "bounced to page 1")

Pair it with New:

p := paginate.New(total, 20, paginate.ParsePage(q.Get("page")))

func Partition

func Partition[T any](items []T, keep func(T) bool) (pinned, rest []T)

Partition splits items into those matching keep and the rest, preserving order in both. It expresses the "pinned rows render outside pagination" pattern: pin the featured items, then paginate what is left. Pinned items never enter the total, so they never shift the page window.

pinned, rest := paginate.Partition(all, func(e Escrow) bool { return e.Featured })
p := paginate.New(len(rest), 20, page) // count excludes pinned
view := paginate.Slice(rest, p)

func Slice

func Slice[T any](items []T, p Page) []T

Slice returns the items visible on page p from an in-memory slice: items[p.Offset:][:p.Limit], clamped to len(items) so an over-range page (or a hand-built Page with odd bounds) yields an empty slice rather than a panic. The result is capped so appending to it cannot overwrite the next page's backing array.

p := paginate.New(len(all), 20, paginate.ParsePage(q))
view := paginate.Slice(all, p)

For a Go 1.23 iterator, materialize first with the stdlib and reuse this:

all := slices.Collect(seq)
view := paginate.Slice(all, paginate.New(len(all), 20, page))

func SliceSeq

func SliceSeq[T any](seq iter.Seq[T], p Page) []T

SliceSeq pulls only page p's items from a Go 1.23 iterator, consuming at most p.Offset+p.Limit values and then stopping — it never materializes the tail of seq. Use it when the sequence is large and the total comes from elsewhere (for example a COUNT); use Slice when you already hold the whole slice.

view := paginate.SliceSeq(rows, p) // rows is an iter.Seq[Row]

Types

type Cursor

type Cursor struct {
	ID  int64
	Dir Direction
}

Cursor is the decoded form of an opaque pagination cursor: a row ID and a direction. The zero Cursor (ID 0, After) means "from the beginning".

func DecodeCursor

func DecodeCursor(s string) (c Cursor, ok bool)

DecodeCursor parses a token from EncodeCursor. It never errors and never panics: any malformed, truncated, or tampered input decodes to the zero Cursor with ok == false, which callers treat as "start from the beginning" — a bad cursor sends the user to the first page, never to an error page. ok is advisory (well-formed vs. fell-back); the zero Cursor is always safe to act on regardless.

type CursorPage

type CursorPage struct {
	PerPage    int
	HasPrev    bool
	HasNext    bool
	PrevCursor string // "" when !HasPrev; EncodeCursor({firstID, Before})
	NextCursor string // "" when !HasNext; EncodeCursor({lastID, After})
}

CursorPage is the cursor-mode counterpart of Page: the tokens to advance in each direction. A cursor is emitted only when that direction has more rows; otherwise the field is "".

func NewCursorPage

func NewCursorPage(perPage int, firstID, lastID int64, hasPrev, hasNext bool) CursorPage

NewCursorPage builds a CursorPage from the window you just fetched. The caller owns the query (typically "fetch perPage+1 rows" to learn hasNext); this constructor owns only the cursor vocabulary. firstID and lastID are the boundary IDs of the returned rows. perPage <= 0 falls back to DefaultPerPage, matching New.

type Direction

type Direction int8

Direction is the travel direction of a cursor relative to its anchor ID.

const (
	After  Direction = iota // forward: rows with ID beyond the anchor
	Before                  // backward: rows before the anchor
)

type Page

type Page struct {
	CurrentPage int  // 1-based; the page this struct describes
	PerPage     int  // items per page (>= 1)
	TotalItems  int  // size of the full result set
	TotalPages  int  // number of pages; 0 when TotalItems == 0
	Offset      int  // items to skip: (CurrentPage-1) * PerPage
	Limit       int  // items to take: == PerPage
	HasPrev     bool // CurrentPage > 1
	HasNext     bool // CurrentPage < TotalPages
	PrevPage    int  // CurrentPage-1, clamped to >= 1
	NextPage    int  // CurrentPage+1, clamped to <= max(1, TotalPages)
}

Page is the computed state of one page over a result set of TotalItems. Every field is exported and needs no method to render, so a Page drops straight into an html/template.

Invariants after New:

  • 1 <= CurrentPage <= max(1, TotalPages)
  • Offset == (CurrentPage-1) * PerPage, and 0 <= Offset
  • Limit == PerPage
  • PrevPage and NextPage are always themselves valid page numbers, even when HasPrev/HasNext is false (they clamp to the edge, never to 0 or TotalPages+1), so a template that forgets the guard still links safely.
  • When TotalItems == 0: TotalPages == 0, CurrentPage == 1, both Has* false. This is the one case where CurrentPage > TotalPages; see New.

func New

func New(total, perPage, requestedPage int) Page

New computes the Page for requestedPage over total items at perPage each. It never fails; it clamps:

  • perPage <= 0 -> DefaultPerPage
  • requestedPage < 1 -> 1
  • requestedPage > last -> last (last = max(1, TotalPages))
  • total <= 0 -> empty Page (TotalPages 0, CurrentPage 1)

perPage is not clamped on the high side: it is a server-chosen size, not user input. If you expose perPage to callers, validate it yourself.

To 404 on an out-of-range page instead of clamping, compare before and after:

p := paginate.New(total, 20, requested)
if requested != p.CurrentPage {
	http.NotFound(w, r)
	return
}

func (Page) AppendWindow

func (p Page) AppendWindow(dst []WindowItem, radius int) []WindowItem

AppendWindow appends p's window (see Window) to dst and returns the extended slice, growing dst only if its capacity is exceeded. Reusing one buffer across calls makes windowing zero-allocation:

buf = p.AppendWindow(buf[:0], 2)

func (Page) Window

func (p Page) Window(radius int) []WindowItem

Window returns the pager slots for p with the given radius: page 1, the last page, and every page within radius of CurrentPage, with gap markers filling the runs between. It allocates exactly one slice — the result — and nothing else. For a zero-allocation hot path, use AppendWindow.

Laws, true for every total, page, and radius (enforced by property tests):

  • Page 1 and the last page are always present (when TotalPages >= 1).
  • Every page in [CurrentPage-radius, CurrentPage+radius] ∩ [1, last] is present.
  • Output is ascending, deduplicated, and contains no two adjacent Gaps.
  • A gap of exactly one page is never elided to "…": if pages 1 and 3 show, page 2 renders as a number, not an ellipsis. Ellipses appear only for runs of two or more hidden pages.
  • radius 0 gives {1, …, current, …, last}; a single-page set gives {1}; TotalPages 0 gives an empty slice.

Worked examples:

total 23, perPage 5, page 3, radius 1  ->  1 2 3 4 5
total 100, perPage 10, page 5, radius 1 -> 1 … 4 5 6 … 10

In the first, the ends (1, 5) sit next to the radius band (2..4) with no run longer than one page, so no ellipsis appears. In the second, pages 2..3 and 7..9 are each hidden runs of two or more, so each collapses to a single "…".

type WindowItem

type WindowItem struct {
	Page int  // the page number; 0 when Gap is true
	Gap  bool // true -> render an ellipsis, not a link
}

WindowItem is one slot in a rendered pager: either a page number to link, or a gap (…) standing in for a run of hidden pages.

Directories

Path Synopsis
examples
fetchjson command
Command fetchjson is a JSON pagination demo: a /api/items endpoint that returns the page as JSON and advertises neighbors via an RFC 8288 Link header, plus a tiny vanilla fetch() client at / that consumes it.
Command fetchjson is a JSON pagination demo: a /api/items endpoint that returns the page as JSON and advertises neighbors via an RFC 8288 Link header, plus a tiny vanilla fetch() client at / that consumes it.
htmx command
Command htmx is the same server-rendered pager as plainlinks, but the links swap in a fragment via htmx instead of reloading the page.
Command htmx is the same server-rendered pager as plainlinks, but the links swap in a fragment via htmx instead of reloading the page.
plainlinks command
Command plainlinks is a server-rendered pagination demo using plain ?page=N anchors and html/template — no JavaScript at all.
Command plainlinks is a server-rendered pagination demo using plain ?page=N anchors and html/template — no JavaScript at all.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL