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.Bodyis 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
WriteorWriteHeaderis 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 withfilepath.Baseand 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.Requestcarries method, URL, headers, body, path parameters, and context.- Use
json.NewDecoderto read JSON bodies. CallDisallowUnknownFieldsfor strict parsing. - Always limit body size with
http.MaxBytesReaderin production. - Pass
r.Context()to all downstream calls for proper cancellation. http.ResponseWriterhas three methods:Header(),WriteHeader(), andWrite(). Call them in that order.- Create
writeJSONandwriteErrorhelpers to standardize responses. - Use
http.Flusherfor streaming responses and Server-Sent Events. - Always sanitize uploaded filenames and limit upload sizes.