Step 6 — Filtering, paging, and a monthly report
Let the database do the counting, and never return an unbounded list.
- Build paginated, filtered list queries with a capped page size.
- Add filters with placeholders instead of string concatenation.
- Write a monthly report that groups and sums in SQL, avoiding N+1.
Every command for this step
touch internal/httpx/params.gogo build ./... && go run ./cmd/api# filtering and pagingcurl -s -H "Authorization: Bearer $TOKEN" 'localhost:8080/v1/expenses?limit=5&offset=0' | jqcurl -s -H "Authorization: Bearer $TOKEN" 'localhost:8080/v1/expenses?category=food' | jqcurl -s -H "Authorization: Bearer $TOKEN" 'localhost:8080/v1/expenses?limit=100000' | jq 'length'# the reportcurl -s -H "Authorization: Bearer $TOKEN" 'localhost:8080/v1/reports/monthly?month=2026-09-01' | jq# check the index is actually useddocker exec -it exp-pg psql -U postgres -d expenses -c \ 'EXPLAIN ANALYZE SELECT * FROM expenses WHERE user_id=1 ORDER BY spent_on DESC LIMIT 20;'What pagination is and why it is not optional
Pagination means returning one page of results instead of everything. limit says how many, offset says how many to skip.
It is not a feature you add when the data grows. By then the endpoint is in production, clients expect the full list, and adding a limit changes what they get back. Until then, every call builds one response holding every row the user has.
// IntParam reads a query parameter, falls back to def, and clamps the range.func IntParam(r *http.Request, key string, def, min, max int) int { v, err := strconv.Atoi(r.URL.Query().Get(key)) if err != nil { return def } return Clamp(v, min, max)}// in the handler:limit := httpx.IntParam(r, "limit", 20, 1, 100) // hard ceiling of 100offset := httpx.IntParam(r, "offset", 0, 0, 1_000_000)Filtering without opening a hole
Users will want expenses by category and by date range. The tempting way to build a query with optional filters is to paste the values into the SQL string, and that is how SQL injection happens.
Build the conditions as fixed strings and collect the values separately, so every value still travels as a placeholder.
func (r *Repository) List(ctx context.Context, userID int64, f Filter) ([]Expense, error) { // $1 is always the user id. It is never optional. where := []string{"user_id = $1"} args := []any{userID} if f.Category != "" { args = append(args, f.Category) where = append(where, fmt.Sprintf("category = $%d", len(args))) } if !f.From.IsZero() { args = append(args, f.From) where = append(where, fmt.Sprintf("spent_on >= $%d", len(args))) } args = append(args, f.Limit, f.Offset) q := fmt.Sprintf(` SELECT id, amount_cents, category, note, spent_on, created_at FROM expenses WHERE %s ORDER BY spent_on DESC, id DESC LIMIT $%d OFFSET $%d`, strings.Join(where, " AND "), len(args)-1, len(args)) rows, err := r.db.QueryContext(ctx, q, args...) ...}fmt.Sprintf near SQL is normally the injection warning sign. It is safe here only because the pieces being formatted in are $2, $3 and fixed column names that you wrote. The moment a value from the request goes into that Sprintf, you have a vulnerability. If in doubt, do not do it.The monthly report
A report is a question: how much did I spend per category last month? You could load every row and add them up in Go. Do not. The database can do it in one query, faster than it could send you the rows.
type CategoryTotal struct { Category string `json:"category"` Total int64 `json:"total_cents"` Count int64 `json:"count"`}func (r *Repository) MonthlyReport(ctx context.Context, userID int64, month time.Time) ([]CategoryTotal, error) { rows, err := r.db.QueryContext(ctx, ` SELECT category, SUM(amount_cents) AS total, COUNT(*) AS n FROM expenses WHERE user_id = $1 AND spent_on >= $2 AND spent_on < ($2::date + INTERVAL '1 month') GROUP BY category ORDER BY total DESC`, userID, month) if err != nil { return nil, err } defer rows.Close() var out []CategoryTotal for rows.Next() { var c CategoryTotal if err := rows.Scan(&c.Category, &c.Total, &c.Count); err != nil { return nil, err } out = append(out, c) } return out, rows.Err()}The mistake to avoid: N+1
Suppose you list twenty expenses and then, for each one, run a query to fetch something extra. That is 1 + 20 = 21 round trips. At two hundred expenses it is 201, and at a millisecond or two per round trip the endpoint gets slow for reasons you cannot see in the Go code. The loop looks innocent.
This is called the N+1 problem, and it is one of the most common performance bugs in database-backed applications. The fix is a JOIN, or one extra query that fetches everything at once with WHERE id = ANY($1).
- Paginate from the first version, with a hard maximum, and clamp instead of rejecting.
- Build optional filters as fixed condition strings with the values in a separate args slice.
- Let the database aggregate.
GROUP BYandSUMbeat loading rows and looping. - A query inside a loop is N+1, one of the most common performance bugs there is.
EXPLAIN ANALYZE on the list query and confirm it uses your (user_id, spent_on DESC) index.- 1Insert 5,000 expenses and compare the list endpoint with and without the index.
- 2Write the report the wrong way (load all rows, sum in Go) and time both.
- 3Add a
?category=filter and check that a category containing a quote cannot break the query. - 4Try
?limit=100000and confirm your clamp caps it.