How to Implement Pagination in a Go API

Web
Implement Go API pagination by parsing query parameters and slicing your data array based on calculated offsets.

When the list gets too long

Your API returns a list of users. It works great with five users. Then the database grows to fifty thousand. The client requests the list, the server dumps the entire table into JSON, the network chokes, and the client's memory usage spikes until the tab crashes. Pagination isn't just a UI nicety. It's a survival mechanism for both the server and the client. Without it, your API becomes a denial-of-service vector waiting to happen.

Think of pagination like reading a book. You don't demand the entire text in one breath. You ask for page 1, read it, then ask for page 2. The server holds the book. The client asks for a specific range. The server calculates where to start and how many lines to show, then hands over just that chunk. The core math is simple: offset = (page - 1) * limit. If the client asks for page 3 with a limit of 10, you skip the first 20 items and grab the next 10. Humans count pages starting at 1. Arrays count indices starting at 0. The subtraction bridges that gap.

Minimal implementation

Here's the simplest goroutine: spawn one, send a message, close the channel.

Here's the simplest pagination handler: parse parameters, calculate bounds, slice the data, return JSON.

package main

import (
	"encoding/json"
	"net/http"
	"strconv"
)

// GetItems returns a paginated slice of items based on query parameters.
func GetItems(w http.ResponseWriter, r *http.Request) {
	// Parse page and limit from the query string, defaulting to 1 and 10.
	page, _ := strconv.Atoi(r.URL.Query().Get("page"))
	limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
	if page < 1 {
		page = 1
	}
	if limit < 1 {
		limit = 10
	}

	// Calculate the slice bounds to extract the correct window of data.
	offset := (page - 1) * limit
	end := offset + limit
	if end > len(items) {
		end = len(items)
	}

	// Encode only the requested slice to JSON.
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(items[offset:end])
}

var items = []string{"item1", "item2", "item3"} // Dummy data for example.

The underscore _ tells the compiler you intentionally dropped the value. Use it sparingly with errors. In production code, you should handle the error from strconv.Atoi. The community accepts the boilerplate of if err != nil because it makes the unhappy path visible. Hiding errors leads to silent failures.

What happens under the hood

When the request arrives, r.URL.Query().Get("page") pulls the string value. strconv.Atoi converts it to an integer. If the string is empty or garbage, Atoi returns 0 and an error. The defaults kick in if the value is less than 1. The math calculates the start index. Slicing items[offset:end] creates a new slice header pointing to the underlying array. This is cheap. Go doesn't copy the data. It just adjusts the pointer, length, and capacity. The encoder streams the JSON directly to the response writer. No intermediate string allocation.

If you forget to import a package and you get undefined: pkg from the compiler. Forget to use one and you get imported and not used. The compiler is strict about unused code. It keeps your binary small and your dependencies honest.

Realistic handler with validation

Real APIs return metadata. The client needs to know how many pages exist. You also need to validate input strictly. Malicious users might send limit=1000000 to exhaust memory. The code caps the limit. It also handles the case where the offset exceeds the data range. If you ask for page 9999, you get an empty list, not a panic. The response includes Total, Page, and Limit. This lets the client build a "Next" button or a page selector.

Functions that take a context should respect cancellation. In a handler, r.Context() is your entry point. Pass it down to database calls. If the client disconnects, the context cancels, and the query stops. This saves resources.

package main

import (
	"encoding/json"
	"net/http"
	"strconv"
)

// PaginatedResponse wraps the data with pagination metadata.
type PaginatedResponse struct {
	Data  []Item `json:"data"`
	Total int    `json:"total"`
	Page  int    `json:"page"`
	Limit int    `json:"limit"`
}

// Item represents a resource in the API.
type Item struct {
	ID   int    `json:"id"`
	Name string `json:"name"`
}

// GetItemsHandler processes pagination with validation and safety caps.
func GetItemsHandler(w http.ResponseWriter, r *http.Request) {
	// Parse parameters, defaulting on error or invalid values.
	page, _ := strconv.Atoi(r.URL.Query().Get("page"))
	limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
	if page < 1 {
		page = 1
	}
	if limit < 1 {
		limit = 10
	}

	// Enforce a maximum limit to protect against resource exhaustion.
	const maxLimit = 100
	if limit > maxLimit {
		limit = maxLimit
	}

	// Retrieve full dataset and calculate slice bounds.
	items := fetchItems()
	total := len(items)
	offset := (page - 1) * limit
	end := offset + limit
	if end > total {
		end = total
	}

	// Slice the data, handling out-of-range offsets gracefully.
	var result []Item
	if offset < total {
		result = items[offset:end]
	} else {
		result = []Item{}
	}

	// Return structured response with metadata for the client.
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(PaginatedResponse{
		Data:  result,
		Total: total,
		Page:  page,
		Limit: limit,
	})
}

// fetchItems simulates a database call.
func fetchItems() []Item {
	return []Item{{ID: 1, Name: "Alpha"}, {ID: 2, Name: "Beta"}}
}

Public names start with a capital letter. Private start lowercase. No keywords like public or private. The json tags map Go fields to JSON keys. If you omit the tag, the encoder uses the field name. If the field is unexported (lowercase), the encoder skips it. You get empty JSON, not a compiler error. That's a runtime surprise. Trust the type system. Export the fields you want to serialize.

Pitfalls and edge cases

The most common bug is the panic. If offset is huge, items[offset:end] panics with index out of range. The code must check offset < total before slicing. The realistic handler does this with the if offset < total guard. Without it, a request for page 99999 crashes the server.

Another trap is the off-by-one error. Page 1 should start at index 0. The formula (page - 1) * limit handles this. If you forget the subtraction, page 1 skips the first limit items. Clients will complain that the first page is missing data.

Large limits are a resource risk. If a client sends limit=100000, the server allocates memory for that many items. The cap maxLimit prevents this. The cap is a policy decision. Set it based on your server's memory and the client's needs.

If you pass a struct to Encode that isn't exported, you get empty JSON. The compiler doesn't catch this. The encoder silently drops unexported fields. You end up with {} instead of data. This happens when you return a struct with lowercase fields. Export the fields or use a wrapper struct with exported fields.

The worst goroutine bug is the one that never logs. If you spawn a goroutine to fetch data and it blocks forever, the request hangs. Use context.Context to set deadlines. If the fetch takes too long, cancel it and return an error.

Validate input at the edge. The database is not a validator.

When to use pagination strategies

Use offset-based pagination when the dataset is static or changes rarely, and the client needs random access to pages.

Use cursor-based pagination when the data changes frequently, or you need to avoid duplicate/missing items during navigation.

Use keyset pagination when you are querying a database directly and want to leverage index efficiency for large offsets.

Use a single unpaginated response when the dataset is small and bounded, like a list of configuration options.

Offset pagination is simple. Cursor pagination is robust. Pick the tool that matches your data volatility.

Where to go next