Table of contents
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)
| Function | ns/op | allocs/op |
|---|---|---|
ComputeOffsetAndLimit | 0.45 | 0 |
New | 7.8 | 0 |
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
| Field | Use |
|---|---|
Offset, PageSize | the SQL OFFSET and LIMIT |
CurrentPage, TotalPages, TotalItems | the counts a response envelope reports |
PreviousPage, NextPage | link targets, already clamped to valid pages |
HasPreviousPage, HasNextPage | whether 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.