package export
import (
"encoding/csv"
"log"
"net/http"
)
type Handler struct {
Store *Store
}
func (h *Handler) ExportUsers(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
w.Header().Set("Content-Disposition", `attachment; filename="users.csv"`)
w.Header().Set("X-Content-Type-Options", "nosniff")
cw := csv.NewWriter(w)
flusher, canFlush := w.(http.Flusher)
if err := cw.Write([]string{"id", "email", "created_at"}); err != nil {
log.Printf("export: header write failed: %v", err)
return
}
var n int
err := h.Store.streamRows(r.Context(), func(row []string) error {
if err := cw.Write(row); err != nil {
return err
}
n++
if n%500 == 0 {
cw.Flush()
if err := cw.Error(); err != nil {
return err
}
if canFlush {
flusher.Flush()
}
}
return nil
})
cw.Flush()
if err != nil || cw.Error() != nil {
// Response is already committed; we can only log and abandon the stream.
log.Printf("export: aborted after %d rows: %v / %v", n, err, cw.Error())
return
}
if canFlush {
flusher.Flush()
}
}
package export
import (
"context"
"database/sql"
"strconv"
"time"
)
type Store struct {
DB *sql.DB
}
const usersQuery = `SELECT id, email, created_at FROM users ORDER BY id`
func (s *Store) streamRows(ctx context.Context, emit func([]string) error) error {
rows, err := s.DB.QueryContext(ctx, usersQuery)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var (
id int64
email string
createdAt time.Time
)
if err := rows.Scan(&id, &email, &createdAt); err != nil {
return err
}
record := []string{
strconv.FormatInt(id, 10),
email,
createdAt.UTC().Format(time.RFC3339),
}
if err := emit(record); err != nil {
return err
}
}
return rows.Err()
}
package export
import (
"database/sql"
"net/http"
"time"
)
func NewServer(db *sql.DB) *http.Server {
h := &Handler{Store: &Store{DB: db}}
mux := http.NewServeMux()
mux.HandleFunc("GET /exports/users.csv", h.ExportUsers)
return &http.Server{
Addr: ":8080",
Handler: mux,
// No WriteTimeout: streaming a large export can outlast a fixed deadline.
ReadHeaderTimeout: 10 * time.Second,
IdleTimeout: 60 * time.Second,
}
}
Exporting a large dataset to CSV is a classic place where a naive implementation quietly falls over: building the entire file in a bytes.Buffer or a [][]string slice before writing it means memory usage grows linearly with row count, and the client sees nothing until the last byte is ready. This snippet shows how to stream rows straight from the database to the HTTP response so that memory stays flat regardless of how many rows are exported, and the download begins immediately.
The key insight in ExportHandler is that Go's http.ResponseWriter is itself an io.Writer, and encoding/csv's csv.NewWriter can wrap it directly. Each row is encoded and written to the socket as it is produced, so nothing accumulates in process memory. The handler sets Content-Type and a Content-Disposition attachment header before writing any body, because headers must be sent before the first byte. Notably there is no Content-Length: the total size is unknown up front, so the server relies on chunked transfer encoding, which net/http selects automatically once data is streamed.
The streaming is made reliable by two details. First, the code type-asserts the writer to http.Flusher and periodically calls Flush after a batch of rows, which pushes buffered bytes out to the client rather than letting them sit in the internal buffer. The csv.Writer is also flushed and checked with w.Error() so that a mid-stream write failure (for example, the client disconnecting) is detected. Second, the query is driven by context from the request, so if the client goes away, r.Context() is cancelled and the database driver aborts the query instead of scanning millions of rows into the void.
In streamRows, the data layer uses QueryContext and iterates with rows.Next(), scanning one row at a time. This is the counterpart to the streaming write side: the database/sql driver keeps a cursor open and pulls rows incrementally rather than materializing the full result set. The function takes an emit callback so the transport concern (CSV formatting) stays out of the data layer. Returning rows.Err() after the loop is important, because rows.Next() returning false can mean either normal completion or an error that must not be swallowed.
The main pitfall this design avoids is the temptation to set headers or return an error after streaming has begun. Once the first row is flushed, the 200 OK status is already committed, so a later failure cannot be turned into a 500; the best the handler can do is log it and stop, which is why the error from streamRows is only logged when partial output has been sent. For truly robust exports some teams write a sentinel trailer or checksum row so the client can verify completeness. This pattern is the right reach whenever an export can grow unbounded — reports, audit logs, analytics dumps — where buffering would blow the memory budget or make the request time out before the first byte.
Share this code
Here's the card — post it anywhere.