paging

paging computes pagination metadata and SQL OFFSET/LIMIT from three inputs, clamping every out-of-range case including offset overflow.

Part of nurago, a collection of independent Go packages for backend services.

import "github.com/tecnickcom/nurago/pkg/paging"

Computes pagination metadata from a current page, a page size and a total item count: total pages, previous and next page, and the OFFSET/LIMIT pair for a SQL query.

Three numbers in, a filled-in struct out:

p := paging.New(3, 5, 17)   // page 3, 5 per page, 17 items total

// p.TotalPages      == 4
// p.PreviousPage    == 2
// p.NextPage        == 4
// p.Offset          == 10
// p.HasPreviousPage == true
// p.HasNextPage     == true

The arithmetic is three lines. The edge cases are where the implementations differ.

What it does at the boundaries

Zero items. New(1, 10, 0) returns TotalPages == 1, not zero. An empty result set is still one page, the one showing nothing. Returning zero pages forces every caller to special-case a division or a loop bound. Detect emptiness from TotalItems == 0, not from the page fields.

A page beyond the end. New clamps CurrentPage to the last page. A user editing ?page=9999 in the URL bar gets the last page of results rather than an empty response or an error.

A page size of zero. Clamped, so no division by zero and no panic.

Offset overflow. This is the interesting one. The offset is (currentPage - 1) * pageSize on uint, and with a large enough page number that multiplication wraps. A wrapped offset is worse than a wrong one: it is a small, plausible number, so the query succeeds and returns rows from the wrong place. The package clamps to math.MaxUint instead, which is a sentinel that selects no rows. A query that returns nothing is a visible bug; a query that returns the wrong page silently is not.

That path is only reachable through ComputeOffsetAndLimit, since New has already clamped the page number by the time it computes the offset.

Two entry points

New gives the whole struct, including the fields you want in a JSON response envelope: previous and next page numbers, the booleans, the totals.

ComputeOffsetAndLimit gives just the two values a SQL LIMIT ... OFFSET ... needs, for the case where you are not rendering navigation.

offset, limit := paging.ComputeOffsetAndLimit(currentPage, pageSize)
Functionns/opallocs/op
ComputeOffsetAndLimit0.450
New7.80

Measured with go test -bench on Go 1.27.1, linux/amd64, Intel Core i7-1260P. Run go test -bench=. ./pkg/paging/ to reproduce them on your own hardware; the absolute values will differ, the ratios much less so.

Sub-nanosecond means ComputeOffsetAndLimit inlined to nothing. Neither allocates, so there is no argument for caching the result or hand-rolling the arithmetic to save work.

It is fuzz tested

Both entry points have fuzz targets, FuzzNew and FuzzComputeOffsetAndLimit, and for this problem that matters more than the unit tests. Pagination arithmetic fails on combinations nobody writes a test case for: page size one with a huge total, a page number near MaxUint, totals that land exactly on a page boundary. Fuzzing is how the overflow case above gets found.

Unsigned throughout

Both entry points take and return uint. Negative page numbers and negative page sizes are unrepresentable rather than validated, so the clamping logic only ever handles the upper bound and zero.

This leaves overflow as the only arithmetic hazard, and explains the clamp target: on an unsigned type there is no negative sentinel available, and math.MaxUint is guaranteed to select no rows from any table.

The fields, and what each is for

FieldUse
Offset, PageSizethe SQL OFFSET and LIMIT
CurrentPage, TotalPages, TotalItemsthe counts a response envelope reports
PreviousPage, NextPagelink targets, already clamped to valid pages
HasPreviousPage, HasNextPagewhether to render those links at all

The booleans exist so that a template does not have to compare page numbers to decide whether a link is live, which is the comparison people get wrong at the first and last page.

Example

p := paging.New(3, 5, 17)

fmt.Println(p.TotalPages, p.Offset)

Full source is in example_paging_test.go. More runnable examples are on pkg.go.dev.

Dependencies

This package reaches no external module: it uses only the Go standard library.