Pagination
Part 2's GET /tasks handler returns every task in the store, encoded as one
JSON array. That's fine for a handful of rows in a tutorial. It's a mistake
in production: a table with ten thousand tasks means a ten-thousand-element
response, built entirely in memory, shipped over the wire, and decoded in
full by a client that probably only wanted to render the first twenty. This
lesson doesn't change the handler's shape — it adds the one piece that keeps
a list endpoint viable as the underlying data grows: a window into the
results instead of all of them.
The limit/offset window
The standard shape is two query parameters: limit, how many items to
return, and offset, how many to skip before returning them. A client
asking for page three of a twenty-item page size sends ?limit=20&offset=40.
The server's job is to slice its data to that window and return only those
rows, along with (typically) a total count so the client knows how many
pages exist. Nothing about this requires new machinery — it's ordinary slice
indexing applied at the boundary between the store and the response.
Reading the parameters
r.URL.Query() returns a url.Values — effectively a map[string][]string —
so r.URL.Query().Get("limit") gets the raw string, or "" if the client
didn't send one. That string has to become an int via strconv.Atoi, which
returns an error for anything that isn't a valid integer — a client sending
?limit=abc or omitting the parameter entirely shouldn't crash the handler.
The usual pattern is: try to parse, and fall back to a sensible default (a
page size like 20, an offset of 0) whenever parsing fails or the parameter is
absent. That keeps GET /tasks working exactly as it did before for a
client that doesn't know about pagination at all — the defaults reproduce
the old "give me everything reasonable" behavior up to a page size, rather
than requiring every caller to opt in.
Clamping: the panic you're avoiding
Once limit and offset are integers, the temptation is to slice directly:
items[offset : offset+limit]. That panics the instant a client sends
offset past the end of the slice, or a negative number, or a limit large
enough that offset+limit overflows past len(items). A single malformed
query parameter shouldn't be able to crash a request. The fix is to clamp
every bound before it touches a slice expression: a negative offset becomes
zero, an offset beyond the length becomes the length itself (which slices to
an empty result, not an error), and the computed end index gets capped at
the length too. Once both bounds are clamped, items[offset:end] is always
a valid — possibly empty — slice, no matter what the client sent.
Generic, because it doesn't need to know about tasks
The clamping logic has nothing to do with what's being paginated — it's pure
index arithmetic over a slice of any element type. The generics chapter
covered writing functions parameterized over a type argument for exactly
this reason: a Page[T any](items []T, limit, offset int) []T function
applies unchanged whether T is a task, a user, or a log entry, and it can
be tested and reasoned about without a database or an HTTP handler in the
picture at all. That's the shape this lesson's exercise isolates.
Beyond limit/offset
Limit/offset pagination has a real limitation at scale: computing OFFSET 40000 in a database usually means scanning and discarding the first 40,000
rows, which gets slower as the offset grows, and results can shift under a
client paging through data that's being written concurrently. The common
production alternative is cursor-based pagination — the client sends back an
opaque token derived from the last item it saw ("give me everything after
this ID"), and the server does a bounded query instead of a skip. That's a
larger design than this lesson covers, but it's worth knowing limit/offset
is the simple starting point, not the only answer.
Practice
1. Pagination
IntermediateGET /tasksreturning ten thousand rows is a mistake. Pagination hands back a window:?limit=20&offset=40. Implement the slicing, generically so it works for any element type.Page[T any](items []T, limit, offset int) []T: return the items starting atoffset, at mostlimitof them. Clamp everything — a negative offset becomes 0, an offset past the end yields an empty slice, an end past the length stops at the length. Never panic, and return a non-nil empty slice ([]T{}via a slice expression) when the window is empty.Slicing
items[offset:end]with clamped bounds does all the work.package main // Page returns the window of items starting at offset, up to limit items. // Both offset and the end are clamped to valid bounds, so out-of-range paging // yields an empty (non-nil) slice rather than a panic. func Page[T any](items []T, limit, offset int) []T { // TODO: clamp offset to [0, len(items)]; clamp limit to >= 0; compute // end := offset + limit, clamped to len(items); return items[offset:end]. return nil } func main() {}Sign in to write and run your solution in the browser.
Sign in for hints and the solution.