Go Cheatsheet

HTTP Servers

Use this Go reference while you build software engineering projects, review code, or refresh the syntax you reach for most.

Minimal Server

net/http is a production HTTP server in the standard library. No framework needed.

package main

import (
    "fmt"
    "log"
    "net/http"
)

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("GET /hello", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintln(w, "hello")
    })
    log.Fatal(http.ListenAndServe(":8080", mux))
}

ListenAndServe blocks and always returns a non-nil error. It sets no timeouts at all, so a slow client can hold a connection forever. Use an explicit &http.Server{} for anything real. Passing nil as the handler uses http.DefaultServeMux, the package global that top-level http.HandleFunc writes into. Avoid it, since any imported package can register routes on it.

Handler vs HandlerFunc

A handler is any type with one method. http.HandlerFunc adapts a plain function to it.

type Handler interface {
    ServeHTTP(ResponseWriter, *Request)
}

func hello(w http.ResponseWriter, r *http.Request) {
    w.Write([]byte("hello"))
}
mux.Handle("/hello", http.HandlerFunc(hello))
mux.HandleFunc("/hello", hello)      // same thing, less typing

// Dependencies go on a struct, methods become the handlers
type api struct{ db *sql.DB }

func (a *api) getItem(w http.ResponseWriter, r *http.Request) {
    row := a.db.QueryRow("SELECT name FROM items WHERE id = $1",
        r.PathValue("id"))
    _ = row
}
mux.HandleFunc("GET /items/{id}", a.getItem)

ServeMux Patterns (Go 1.22+)

Since Go 1.22 a pattern is [METHOD ][HOST]/[PATH]. All three parts are optional, and the method must be followed by at least one space.

PatternMatches
/items/subtree: /items/, /items/1, /items/a/b
/itemsexactly /items, any method
GET /items/{id}one segment after /items/, GET and HEAD only
POST /itemsPOST to /items only
/items/{$}exactly /items/, no subtree
/{$}the root path / only
/every path (catch-all)
/files/{path...}rest of the path, slashes included
api.example.com/v1/that host only
mux.HandleFunc("GET /items/{id}", func(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "item %s\n", r.PathValue("id"))   // "" if absent
})
mux.HandleFunc("GET /files/{path...}", func(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintln(w, r.PathValue("path"))             // "a/b/c.txt"
})

Rules that bite:

- Most specific pattern wins. /images/thumbs/ beats /images/, and GET /items/{id} beats GET /items/. Two patterns that conflict with no winner make Handle panic at registration, not at request time. - A pattern with GET also matches HEAD. Any other method matches exactly. - A pattern with no method matches every method, so a bare /items swallows POST too. Register each method and let the mux answer 405 for the rest. - Wildcards must be whole segments. /b_{bucket} is invalid and panics, and {name...} is legal only at the end. - A trailing slash acts as an anonymous {...} wildcard, so /items/ is a subtree, not a literal path. - Paths are unescaped per segment before matching, and .. or doubled slashes get a 301 to the cleaned path. - mux.Handler(r) returns the matched handler and its pattern, useful for logging the route template instead of the raw path.

Reading the Request

// Query string: /search?q=go&tag=a&tag=b
q := r.URL.Query()
term := q.Get("q")             // "" if absent
tags := q["tag"]               // []string{"a", "b"}
page, _ := strconv.Atoi(q.Get("page"))

r.URL.Path, r.Method, r.Host, r.RemoteAddr
r.Header.Get("Authorization")
r.Cookie("session")            // (*http.Cookie, error)

name := r.FormValue("name")    // query + urlencoded body, parses for you
r.PostFormValue("name")        // body only

JSON bodies, with a size cap so one huge upload cannot exhaust memory:

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

func create(w http.ResponseWriter, r *http.Request) {
    r.Body = http.MaxBytesReader(w, r.Body, 1<<20)   // 1 MiB
    dec := json.NewDecoder(r.Body)
    dec.DisallowUnknownFields()

    var in createReq
    if err := dec.Decode(&in); err != nil {
        http.Error(w, "bad request body", http.StatusBadRequest)
        return
    }
}

File uploads:

if err := r.ParseMultipartForm(32 << 20); err != nil {  // 32 MiB in memory
    http.Error(w, "bad form", http.StatusBadRequest)
    return
}
file, hdr, err := r.FormFile("avatar")
if err != nil { /* ... */ }
defer file.Close()
fmt.Println(hdr.Filename, hdr.Size)

r.Body is never nil on the server and the server closes it, so no defer Close is needed here (it is required on the client side).

Writing the Response

Order matters. Headers, then status, then body.

w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusCreated)          // 201
json.NewEncoder(w).Encode(map[string]any{"ok": true})
  • Any w.Header().Set after WriteHeader or the first Write is ignored.
  • The first Write implicitly sends 200 OK, so WriteHeader must come first.
  • A second WriteHeader logs "superfluous response.WriteHeader call".
  • Always return after http.Error, otherwise you write the body twice.
  • Content-Type is sniffed from the first 512 bytes when you do not set it.
http.Error(w, "not found", http.StatusNotFound)    // plain text + status
http.NotFound(w, r)                                 // 404 shorthand
http.Redirect(w, r, "/login", http.StatusSeeOther)  // 303
http.SetCookie(w, &http.Cookie{
    Name: "session", Value: tok, Path: "/",
    HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode,
})

Marshal before writing, so an encoding failure cannot land inside a 200:

func writeJSON(w http.ResponseWriter, status int, v any) {
    buf, err := json.Marshal(v)
    if err != nil {
        http.Error(w, "internal error", http.StatusInternalServerError)
        return
    }
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    w.Write(buf)
}

Static Files

// Serve ./public at /static/
fs := http.FileServer(http.Dir("public"))
mux.Handle("GET /static/", http.StripPrefix("/static/", fs))

// One file, with Range, If-Modified-Since and Content-Type handled
mux.HandleFunc("GET /favicon.ico", func(w http.ResponseWriter, r *http.Request) {
    http.ServeFile(w, r, "public/favicon.ico")
})

Without StripPrefix the file server looks for public/static/... and 404s. FileServer also lists any directory with no index.html, so prefer http.FileServerFS(sub) over an embed.FS when you want a fixed asset set.

Middleware

Middleware is a function that wraps a handler. That is the whole pattern.

type middleware func(http.Handler) http.Handler

func logging(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        sw := &statusWriter{ResponseWriter: w, status: http.StatusOK}
        next.ServeHTTP(sw, r)
        slog.Info("request", "method", r.Method, "path", r.URL.Path,
            "status", sw.status, "dur", time.Since(start))
    })
}

// ResponseWriter does not expose the status, so wrap it
type statusWriter struct {
    http.ResponseWriter
    status int
}

func (s *statusWriter) WriteHeader(code int) {
    s.status = code
    s.ResponseWriter.WriteHeader(code)
}

func recoverPanic(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if rec := recover(); rec != nil {
                slog.Error("panic", "err", rec, "stack", string(debug.Stack()))
                w.Header().Set("Connection", "close")
                http.Error(w, "internal error", http.StatusInternalServerError)
            }
        }()
        next.ServeHTTP(w, r)
    })
}

// Chain, outermost first
func chain(h http.Handler, ms ...middleware) http.Handler {
    for i := len(ms) - 1; i >= 0; i-- {
        h = ms[i](h)
    }
    return h
}

handler := chain(mux, recoverPanic, logging)

The server survives a handler panic on its own, but it kills that connection and dumps a raw stack, so a recover middleware that returns a clean 500 still earns its place. It cannot catch a panic in a goroutine the handler started. Wrapping the mux covers every route including 404s, wrapping one handler covers that route.

Server Timeouts and Graceful Shutdown

srv := &http.Server{
    Addr:              ":8080",
    Handler:           handler,
    ReadHeaderTimeout: 5 * time.Second,   // Slowloris defense, always set it
    ReadTimeout:       15 * time.Second,
    WriteTimeout:      30 * time.Second,
    IdleTimeout:       60 * time.Second,
    MaxHeaderBytes:    1 << 20,
}
FieldCoversNote
ReadHeaderTimeoutrequest line + headersthe non-negotiable one
ReadTimeoutheaders + bodytoo small breaks large uploads
WriteTimeoutwriting the responsetoo small breaks SSE and streaming
IdleTimeoutkeep-alive gapfalls back to ReadTimeout if zero

For streaming, leave the read and write timeouts generous and put per-request deadlines on r.Context() instead, or wrap with http.TimeoutHandler(h, d, msg). TLS is one call: srv.ListenAndServeTLS("cert.pem", "key.pem").

ctx, stop := signal.NotifyContext(context.Background(),
    os.Interrupt, syscall.SIGTERM)
defer stop()

go func() {
    if err := srv.ListenAndServe(); err != nil &&
        !errors.Is(err, http.ErrServerClosed) {
        log.Fatalf("listen: %v", err)
    }
}()

<-ctx.Done()
stop()                                   // restore default signal handling

shutCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
if err := srv.Shutdown(shutCtx); err != nil {
    log.Printf("forced close: %v", err)
    srv.Close()
}

ListenAndServe returns http.ErrServerClosed on a clean shutdown, which is not a failure. Shutdown drains idle connections and waits for active requests, then returns the context error if the grace period expires. It does not wait for hijacked connections, so track those yourself. srv.Close() cuts everything now.

Request Context

r.Context() is cancelled when the client disconnects or the handler returns, so passing it down cancels DB queries and outbound calls for free.

ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
defer cancel()

rows, err := db.QueryContext(ctx, "SELECT id FROM items")
switch {
case errors.Is(err, context.Canceled):
    return                              // client is gone, nobody to answer
case errors.Is(err, context.DeadlineExceeded):
    http.Error(w, "timeout", http.StatusGatewayTimeout)
    return
case err != nil:
    http.Error(w, "db error", http.StatusInternalServerError)
    return
}
defer rows.Close()

Request-scoped values ride the context under an unexported key type, so no other package can collide:

type userKey struct{}

func auth(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        u, err := userFrom(r.Header.Get("Authorization"))
        if err != nil {
            http.Error(w, "unauthorized", http.StatusUnauthorized)
            return
        }
        ctx := context.WithValue(r.Context(), userKey{}, u)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

func userOf(r *http.Request) (*User, bool) {
    u, ok := r.Context().Value(userKey{}).(*User)
    return u, ok
}

Never hand r.Context() to a goroutine that outlives the handler, it is cancelled the moment the handler returns. Use context.WithoutCancel(r.Context()) for genuine background work.

Streaming, Flusher, and SSE

http.ResponseWriter optionally implements more interfaces. Assert for them.

InterfaceMethodUse
http.FlusherFlush()push buffered bytes to the client now
http.HijackerHijack()take over the raw TCP conn (WebSockets)
io.ReaderFromReadFrom()zero-copy file sends
http.CloseNotifierCloseNotify()deprecated, use r.Context()
func events(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "text/event-stream")
    w.Header().Set("Cache-Control", "no-cache")
    w.Header().Set("Connection", "keep-alive")

    flusher, ok := w.(http.Flusher)
    if !ok {
        http.Error(w, "streaming unsupported", http.StatusInternalServerError)
        return
    }

    tick := time.NewTicker(time.Second)
    defer tick.Stop()
    for {
        select {
        case <-r.Context().Done():
            return
        case t := <-tick.C:
            fmt.Fprintf(w, "data: %s\n\n", t.Format(time.RFC3339))
            flusher.Flush()
        }
    }
}

SSE needs WriteTimeout: 0, a blank line after every event, and response buffering off in any reverse proxy in front. Flush behaves the same under HTTP/2.

Testing Handlers

httptest covers both directions and needs no open port.

func TestGetItem(t *testing.T) {
    req := httptest.NewRequest("GET", "/items/42", nil)
    rec := httptest.NewRecorder()

    routes().ServeHTTP(rec, req)         // your real *http.ServeMux

    if rec.Code != http.StatusOK {
        t.Fatalf("status = %d, want 200", rec.Code)
    }
    // rec.Body is a *bytes.Buffer, rec.Result() is a real *http.Response
}

// A real server on a random port, for end-to-end tests
srv := httptest.NewServer(routes())
defer srv.Close()
resp, err := srv.Client().Get(srv.URL + "/items/42")

Because PathValue is filled in by the mux, always test through the mux, not by calling the handler function directly. httptest.NewTLSServer plus srv.Client() gives you HTTPS with the cert already trusted.

ServeMux Limits

Post-1.22 ServeMux does methods, wildcards and host matching, which is enough for most services. What it lacks: regex or typed path constraints, per-group middleware, route naming and URL building, and richer 405 handling. Reach for chi (plain http.Handler, groups, middleware) only when you need one of those, and never pick a framework that hides http.Handler.