3 min read
On this page

Request & Response

Every HTTP handler in Go receives two things: an http.Request to read from and an http.ResponseWriter to write to. Understanding these two types is understanding Go web development. This topic covers everything you do with them -- reading JSON, extracting parameters, returning responses, handling file uploads, and streaming.

http.Request: What Came In

The *http.Request struct carries everything about the incoming request:

func handler(w http.ResponseWriter, r *http.Request) {
    r.Method     // "GET", "POST", "PUT", "DELETE", etc.
    r.URL.Path   // "/api/users/42"
    r.URL.Query() // url.Values map of query parameters
    r.Header     // http.Header map
    r.Body       // io.ReadCloser -- the request body
    r.Context()  // context.Context for cancellation and values
}

Path Parameters (Go 1.22+)

With Go 1.22 enhanced routing, path parameters are extracted with PathValue:

mux.HandleFunc("GET /api/users/{id}", func(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("id") // returns "42" for /api/users/42
    // ...
})

Query Parameters

Query parameters come from r.URL.Query():

func listUsers(w http.ResponseWriter, r *http.Request) {
    params := r.URL.Query()
    page := params.Get("page")       // returns "" if missing
    limit := params.Get("limit")
    tags := params["tags"]            // []string for repeated params

    // Always validate and convert
    pageNum, err := strconv.Atoi(page)
    if err != nil || pageNum < 1 {
        pageNum = 1
    }
}

Reading the Request Body

The body is an io.ReadCloser. You can only read it once:

func createUser(w http.ResponseWriter, r *http.Request) {
    body, err := io.ReadAll(r.Body)
    if err != nil {
        http.Error(w, "failed to read body", http.StatusBadRequest)
        return
    }
    defer r.Body.Close()
    // body is []byte
}

Reading JSON with json.Decoder

For JSON APIs, use json.NewDecoder directly on the body. It is more efficient than reading all bytes first:

type CreateUserRequest struct {
    Name  string `json:"name"`
    Email string `json:"email"`
}

func createUser(w http.ResponseWriter, r *http.Request) {
    var req CreateUserRequest

    decoder := json.NewDecoder(r.Body)
    decoder.DisallowUnknownFields() // reject unexpected fields
    if err := decoder.Decode(&req); err != nil {
        http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest)
        return
    }

    if req.Name == "" || req.Email == "" {
        http.Error(w, "name and email are required", http.StatusBadRequest)
        return
    }

    // Process the request...
}

Limit body size to prevent abuse:

r.Body = http.MaxBytesReader(w, r.Body, 1_048_576) // 1 MB max

If the body exceeds the limit, the next read returns an error.

Request Headers

Headers are a map[string][]string with convenience methods:

contentType := r.Header.Get("Content-Type")  // case-insensitive
authToken := r.Header.Get("Authorization")
acceptAll := r.Header.Values("Accept")        // all values

Request Context

Every request carries a context.Context that is cancelled when the client disconnects:

func slowHandler(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()

    result, err := database.QueryWithTimeout(ctx, query)
    if err != nil {
        if ctx.Err() == context.Canceled {
            return // client disconnected, no point responding
        }
        http.Error(w, "query failed", http.StatusInternalServerError)
        return
    }
    // Use result...
}

Pass r.Context() to every downstream call (database queries, HTTP clients, etc.) so work stops when the client disappears.

http.ResponseWriter: What Goes Out

http.ResponseWriter is an interface with three methods:

type ResponseWriter interface {
    Header() http.Header          // response headers (set before Write)
    Write([]byte) (int, error)    // write body bytes
    WriteHeader(statusCode int)   // set status code
}

Setting Headers

Set headers before calling Write or WriteHeader:

func handler(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "application/json")
    w.Header().Set("X-Request-ID", requestID)
    w.Write([]byte(`{"ok": true}`))
}

Once you call Write or WriteHeader, headers are sent and cannot be changed.

Setting Status Codes

Call WriteHeader before Write. If you do not call WriteHeader, the first call to Write implicitly sends 200 OK:

func notFound(w http.ResponseWriter, r *http.Request) {
    w.WriteHeader(http.StatusNotFound)
    w.Write([]byte(`{"error": "not found"}`))
}

You can only call WriteHeader once. The second call logs a warning and is ignored.

Returning JSON

A helper function keeps your handlers clean:

func writeJSON(w http.ResponseWriter, status int, data any) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)

    if err := json.NewEncoder(w).Encode(data); err != nil {
        slog.Error("failed to write JSON response", "error", err)
    }
}

type User struct {
    ID    int    `json:"id"`
    Name  string `json:"name"`
    Email string `json:"email"`
}

func getUser(w http.ResponseWriter, r *http.Request) {
    user := User{ID: 1, Name: "Alice", Email: "alice@example.com"}
    writeJSON(w, http.StatusOK, user)
}
HTTP/1.1 200 OK
Content-Type: application/json

{"id":1,"name":"Alice","email":"alice@example.com"}

Error Responses

Standardize error responses with a struct:

type ErrorResponse struct {
    Error   string `json:"error"`
    Details string `json:"details,omitempty"`
}

func writeError(w http.ResponseWriter, status int, message string) {
    writeJSON(w, status, ErrorResponse{Error: message})
}

func getUser(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("id")
    user, err := store.FindUser(r.Context(), id)
    if err != nil {
        writeError(w, http.StatusNotFound, "user not found")
        return
    }
    writeJSON(w, http.StatusOK, user)
}

Streaming Responses

For large responses, stream data instead of buffering everything in memory:

func streamLogs(w http.ResponseWriter, r *http.Request) {
    flusher, ok := w.(http.Flusher)
    if !ok {
        http.Error(w, "streaming not supported", http.StatusInternalServerError)
        return
    }

    w.Header().Set("Content-Type", "text/event-stream")
    w.Header().Set("Cache-Control", "no-cache")

    for i := 0; i < 10; i++ {
        select {
        case <-r.Context().Done():
            return // client disconnected
        case <-time.After(1 * time.Second):
            fmt.Fprintf(w, "data: event %d\n\n", i)
            flusher.Flush()
        }
    }
}

The http.Flusher interface pushes buffered data to the client immediately. This is how you implement Server-Sent Events.

File Uploads

Handle multipart form uploads with r.FormFile or r.MultipartReader:

func uploadFile(w http.ResponseWriter, r *http.Request) {
    // Limit upload size
    r.Body = http.MaxBytesReader(w, r.Body, 10<<20) // 10 MB

    file, header, err := r.FormFile("document")
    if err != nil {
        writeError(w, http.StatusBadRequest, "failed to read upload")
        return
    }
    defer file.Close()

    // header.Filename -- original filename (never trust for paths)
    // header.Size     -- file size in bytes
    // header.Header   -- MIME headers

    dst, err := os.Create(filepath.Join("/uploads", filepath.Base(header.Filename)))
    if err != nil {
        writeError(w, http.StatusInternalServerError, "failed to save file")
        return
    }
    defer dst.Close()

    if _, err := io.Copy(dst, file); err != nil {
        writeError(w, http.StatusInternalServerError, "failed to write file")
        return
    }

    writeJSON(w, http.StatusCreated, map[string]string{
        "filename": header.Filename,
        "size":     fmt.Sprintf("%d", header.Size),
    })
}

Common Pitfalls

  • Reading the body twice. r.Body is a stream. Once read, it is gone. If you need to read it multiple times (for logging, for example), read into a buffer first.
  • Setting headers after Write. Once Write or WriteHeader is called, headers are flushed to the network. Set all headers first.
  • Calling WriteHeader twice. The second call is ignored and logs a superfluous warning. Structure your handler to have a single exit path for each status code.
  • Trusting uploaded filenames. The client controls header.Filename. Always sanitize it with filepath.Base and never use it directly in file paths.
  • Forgetting http.MaxBytesReader. Without a body size limit, a malicious client can exhaust your server memory with a huge request body.
  • Not checking r.Context().Done() in long operations. If the client disconnects, continuing work wastes resources. Pass the context through and check it.

Key Takeaways

  • http.Request carries method, URL, headers, body, path parameters, and context.
  • Use json.NewDecoder to read JSON bodies. Call DisallowUnknownFields for strict parsing.
  • Always limit body size with http.MaxBytesReader in production.
  • Pass r.Context() to all downstream calls for proper cancellation.
  • http.ResponseWriter has three methods: Header(), WriteHeader(), and Write(). Call them in that order.
  • Create writeJSON and writeError helpers to standardize responses.
  • Use http.Flusher for streaming responses and Server-Sent Events.
  • Always sanitize uploaded filenames and limit upload sizes.