go 88 lines · 2 tabs

Streaming Large SQL Query Results as a JSON Array in Go

Shared by codesnips Aug 2026
2 tabs
package httpstream

import (
	"bufio"
	"database/sql"
	"fmt"
	"io"
)

type rowEncoder func(rows *sql.Rows, w io.Writer) error

func StreamRowsAsJSON(dst io.Writer, rows *sql.Rows, encode rowEncoder) error {
	bw := bufio.NewWriterSize(dst, 32*1024)
	defer bw.Flush()

	if _, err := bw.WriteString("["); err != nil {
		return err
	}

	first := true
	for rows.Next() {
		if !first {
			if _, err := bw.WriteString(","); err != nil {
				return err
			}
		}
		first = false

		if err := encode(rows, bw); err != nil {
			return fmt.Errorf("encode row: %w", err)
		}
	}

	if err := rows.Err(); err != nil {
		return fmt.Errorf("row iteration: %w", err)
	}

	if _, err := bw.WriteString("]"); err != nil {
		return err
	}
	return bw.Flush()
}
2 files · go Explain with highlit

Loading an entire result set into a slice before serializing it works fine for small tables but falls apart when a query returns hundreds of thousands of rows: memory usage spikes, garbage collection thrashes, and the client waits for the whole set before receiving a single byte. This snippet shows how to stream rows straight from the database driver to the HTTP response, encoding each row as it is scanned so peak memory stays roughly constant regardless of result size.

In streamer.go, StreamRowsAsJSON accepts an io.Writer, a *sql.Rows, and a rowEncoder callback. It writes the opening [ by hand, then iterates with rows.Next(), calling the encoder for each row and manually inserting , separators between elements. Writing the array brackets and commas directly — rather than building a []interface{} and handing it to json.Marshal — is what keeps the whole set from ever living in memory at once. A bufio.Writer wraps the destination so many small Write calls coalesce into fewer syscalls, and Flush is deferred to push the final buffered bytes. Crucially, rows.Err() is checked after the loop, because rows.Next() returning false can mean either normal completion or a mid-stream failure, and the two must be distinguished.

A subtle correctness point handled here is the header-versus-body problem: once the first bytes are flushed, the HTTP status code is already sent, so an error surfacing halfway through cannot become a clean 500. The code addresses this by only returning an error before any write happens where possible, and otherwise surfacing the failure to the caller for logging while the connection is simply broken.

In users_handler.go, ListUsers issues the query, defers rows.Close(), sets the Content-Type to application/json, and delegates to StreamRowsAsJSON with a closure that scans each User and writes it with a per-row json.Encoder. Reusing scan destinations across iterations avoids per-row allocation churn. Passing r.Context() into QueryContext ensures a client disconnect cancels the underlying database query rather than letting it run to completion pointlessly. This pattern is the go-to when exporting reports, feeding data pipelines, or serving any endpoint whose result size is unbounded and driven by user input.


Related snips

Share this code

Here's the card — post it anywhere.

Streaming Large SQL Query Results as a JSON Array in Go — share card
Link copied