Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

In Go 1.23, a database pagination generator can expose pages of rows as a standard iterator: return an iter.Seq2[Row, error], query one bounded page at a time, and call the iterator’s yield function for each scanned row. Use keyset pagination for a stable ordered walk through changing data, and make sure every *sql.Rows is closed when scanning finishes, an error occurs, or the consumer stops early.

What Go 1.23 adds for database iterators

Go 1.23, released on 13 August 2024, added range-over-function support and the standard-library iter package. The package defines iter.Seq[V] for a sequence of values and iter.Seq2[K, V] for a sequence of value pairs. A sequence is a push iterator: it invokes a yield function for each item, and it must stop when that function returns false.

For pagination, iter.Seq2[Row, error] is useful because the iterator can yield a row with a nil error, or yield a zero-value row with a non-nil error and then end. Go’s for range does not itself add database behavior; the iterator function still has to issue queries, scan rows, manage cancellation and cleanup, and choose how errors reach its caller.

Choose pagination semantics before writing the iterator

Go’s iterator support does not prescribe whether a database API should use OFFSET or keyset pagination. The right choice depends on whether callers need numbered-page navigation, how the target database handles the query plan, and whether rows may change while the caller is paging.

What’s actually slowing this PC down?

Pick the symptom - the matching free tool is one click away.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
Consideration Offset pagination Keyset pagination
Deep-page work Often requires the database to walk past skipped rows; actual cost depends on the database, query plan, and indexes. Can seek from the last ordering key when the query and index support that access pattern; measure against the target schema and workload.
Rows changing between requests Inserts or deletes before the offset can shift page membership, causing repeated or skipped rows across requests. A cursor continues after the last key observed, so earlier inserts do not shift the position in the same way. Changes to ordering values and deletions still affect what is seen.
Jump to an arbitrary page number Natural to express when page numbers are part of the interface. Requires a cursor for the desired position or a separate lookup; a page number alone does not identify the next key.
Index and ordering Use a deterministic ORDER BY; an index can help, but it does not remove the cost or shifting behavior of large offsets. Use a stable, indexed ordering key where possible, with a unique tie-breaker to distinguish rows that share the leading key.
Cursor and API Pass a bounded limit and offset; the caller can retain a page number. Retain and, for external APIs, encode the last ordering values. Cursor format and validation become part of the API contract.

There is no universal performance number that makes one method faster for every database. Check the query plan and benchmark representative data, indexes, and request patterns on the database you deploy.

Why a keyset needs a unique ordering tuple

Suppose rows are ordered by created_at. If multiple rows have the same timestamp, a cursor containing only that timestamp cannot unambiguously describe where the next page begins. Order by a tuple such as (created_at, id), where id is unique, and request rows strictly after the last tuple. The cursor must preserve both values. The example below assumes created_at is non-null and that id is a unique tie-breaker.

Implement a bounded keyset iterator

This example uses database/sql, PostgreSQL-style $n placeholders, and illustrative table and column names. Adapt the SQL placeholders, schema, and tuple comparison to the database driver and SQL dialect in use. The repository owns the SQL and binds cursor values as parameters; it does not interpolate them into query text.

package pagination

import (
    "context"
    "database/sql"
    "errors"
    "fmt"
    "iter"
)

type Row struct {
    ID        int64
    CreatedAt time.Time
    Name      string
}

type Cursor struct {
    CreatedAt time.Time
    ID        int64
}

type Repo struct {
    db *sql.DB
}

func (r *Repo) All(ctx context.Context, after *Cursor, pageSize int) iter.Seq2[Row, error] {
    return func(yield func(Row, error) bool) {
        if pageSize <= 0 {
            yield(Row{}, fmt.Errorf("page size must be positive"))
            return
        }

        cursor := after
        for {
            var (
                rows *sql.Rows
                err  error
            )

            if cursor == nil {
                rows, err = r.db.QueryContext(ctx, `
                    SELECT id, created_at, name
                    FROM items
                    ORDER BY created_at, id
                    LIMIT $1`, pageSize)
            } else {
                rows, err = r.db.QueryContext(ctx, `
                    SELECT id, created_at, name
                    FROM items
                    WHERE (created_at, id) > ($1, $2)
                    ORDER BY created_at, id
                    LIMIT $3`, cursor.CreatedAt, cursor.ID, pageSize)
            }
            if err != nil {
                yield(Row{}, err)
                return
            }

            count := 0
            var last Cursor
            for rows.Next() {
                var row Row
                if err := rows.Scan(&row.ID, &row.CreatedAt, &row.Name); err != nil {
                    _ = rows.Close()
                    yield(Row{}, err)
                    return
                }

                last = Cursor{CreatedAt: row.CreatedAt, ID: row.ID}
                count++
                if !yield(row, nil) {
                    _ = rows.Close()
                    return
                }
            }

            rowsErr := rows.Err()
            closeErr := rows.Close()
            if rowsErr != nil {
                yield(Row{}, rowsErr)
                return
            }
            if closeErr != nil {
                yield(Row{}, closeErr)
                return
            }
            if count < pageSize {
                return
            }
            cursor = &last
        }
    }
}

The snippet needs time in its import list for time.Time. It deliberately executes each page query only when the sequence is consumed. Each query is limited to pageSize; after a full page, the next query begins strictly after the last row’s cursor. When a page contains fewer rows than requested, iteration ends.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

The iterator reports a scan, query, iteration, or close error as one terminal yield with a zero-value Row. Callers must check the error before using the row. If a scan error and a close error occur together, this example reports the scan error. A project can instead choose a terminal error field or callback, but should document the behavior: plain iter.Seq[Row] has no built-in error channel.

Consume the sequence and stop safely

With Go 1.23 or later, callers can use the two yielded values directly in a range loop:

for row, err := range repo.All(ctx, nil, 500) {
    if err != nil {
        return err
    }
    if err := process(row); err != nil {
        return err
    }
}

A plain break also stops iteration. When the loop exits early, range tells the sequence’s yield function to return false; the iterator must return immediately. In the example, it closes the active *sql.Rows before returning, so breaking does not leave that result set open. If the loop continues, the iterator closes the completed page before issuing the next query.

Iterator style Useful when Trade-off
Push: iter.Seq or iter.Seq2 Consumers benefit from a direct for range loop and the iterator controls how values are produced. The producer must honor a false yield result and decide how to expose errors.
Pull: explicit next and stop Callers need explicit look-ahead or control over when to request the next value. The caller must remember to stop the iterator so its resources are released; the API is more manual.
Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Support on Ko-Fi

Handle cancellation, errors, and row cleanup

Use QueryContext or the driver’s equivalent context-aware query method. A context can carry a deadline or timeout, and cancellation can stop database work when a client disconnects or the operation exceeds its limit. Pass the same request context to each page query, as in the example; a context canceled between pages will cause the next query to fail.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
  • Close every *sql.Rows on normal completion, after a scan or iteration error, and when the consumer stops early.
  • Check rows.Err() after the Next loop. A loop ending does not by itself establish that iteration completed without error.
  • Return immediately when yield returns false; do not continue querying pages after the consumer has stopped.
  • Decide whether a database error is delivered as a terminal iterator value, stored elsewhere, or handled through another API, and document how consumers recognize it.
  • Keep the page size bounded and validate it before querying. Add any project-specific maximum to avoid unexpectedly large requests.

database/sql supplies a lower-level relational access layer for database systems including MySQL, Oracle, PostgreSQL, SQL Server, and SQLite. Driver support and SQL syntax differ; the Go API does not make PostgreSQL tuple comparison or placeholder syntax portable by itself.

Decide whether pages need a shared database snapshot

Closing each page’s rows before requesting the next page limits how long an individual result set remains open. It does not make separate page queries a single consistent snapshot. With concurrent writes, later queries may observe a different database state, and the rows returned depend on the database’s ordering and isolation rules.

Approach Consistency across pages Operational consideration
Independent page queries Each query follows the database’s normal statement-level behavior; the full traversal is not necessarily one snapshot. Simple lifecycle and shorter-lived queries, but concurrent changes may affect what later pages contain.
Transaction with snapshot-capable isolation May provide a consistent view across the traversal, depending on the database and isolation level selected. A long traversal may retain a transaction and connection for longer. Exact snapshot and locking behavior is database-specific.

Choose an isolation level only after checking the target database’s semantics and the application’s consistency needs. A transaction does not automatically mean the same kind of snapshot on every database, and holding one open while a consumer processes many rows can have resource and concurrency costs.

When OFFSET is still the right interface

Offset pagination remains useful when callers need to request a numbered page or jump directly to a position without first obtaining a cursor. Use a deterministic ORDER BY, bind a bounded limit and offset as query parameters, and explain that inserts or deletions between requests can change which rows appear on a page. For deep traversal or a feed that changes while it is read, test whether keyset pagination better matches the desired behavior.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.