Go Cheatsheet
HTTP Clients
Use this Go reference while you build software engineering projects, review code, or refresh the syntax you reach for most.
Quick Requests
The package-level helpers are fine for a throwaway script and wrong for a service.
resp, err := http.Get("https://api.example.com/items") if err != nil { return err } defer resp.Body.Close() resp, err = http.Post(url, "application/json", bytes.NewReader(body)) resp, err = http.PostForm(url, url.Values{"q": {"go"}}) resp, err = http.Head(url)
They all use http.DefaultClient, which has Timeout: 0. A hung server
blocks that goroutine forever, and every package in your binary shares the same
client, so anyone can mutate its Transport or Jar. Build your own.
A Client You Can Ship
One *http.Client per process (or per upstream), stored and reused. Creating a
client per request leaks connections and defeats keep-alive entirely.
var httpClient = &http.Client{ Timeout: 10 * time.Second, // whole request, incl. redirects + body read Transport: &http.Transport{ Proxy: http.ProxyFromEnvironment, DialContext: (&net.Dialer{ Timeout: 5 * time.Second, KeepAlive: 30 * time.Second, }).DialContext, MaxIdleConns: 100, MaxIdleConnsPerHost: 100, // default is 2, raise it for one upstream MaxConnsPerHost: 200, // 0 = unlimited IdleConnTimeout: 90 * time.Second, TLSHandshakeTimeout: 5 * time.Second, ExpectContinueTimeout: 1 * time.Second, ResponseHeaderTimeout: 10 * time.Second, ForceAttemptHTTP2: true, }, }
| Field | Default | Why you touch it |
|---|---|---|
Client.Timeout | 0 (none) | the single most important setting |
MaxIdleConnsPerHost | 2 | the classic throughput bug under load |
MaxConnsPerHost | 0 | cap concurrency against a fragile upstream |
IdleConnTimeout | 90s | how long a pooled conn is kept |
ResponseHeaderTimeout | 0 | catch a server that accepts and stalls |
ForceAttemptHTTP2 | false on a custom Transport | re-enable HTTP/2 |
DisableKeepAlives | false | debugging only, it kills the pool |
Client.Timeout covers the entire exchange, including reading the body, so it is
wrong for large downloads and for streaming. Use a context deadline per request
there instead, and leave Client.Timeout off.
Building Requests
http.NewRequestWithContext is the form to use. Always. It is the only way
cancellation reaches the transport.
type payload struct { Name string `json:"name"` } func createItem(ctx context.Context, in payload) error { body, err := json.Marshal(in) if err != nil { return err } req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://api.example.com/items", bytes.NewReader(body)) if err != nil { return err } req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("User-Agent", "myapp/1.0") resp, err := httpClient.Do(req) if err != nil { return fmt.Errorf("post item: %w", err) } defer resp.Body.Close() return nil }
Query strings belong to url.Values, never to string concatenation:
q := url.Values{}
q.Set("q", "go generics")
q.Set("page", "2")
q.Add("tag", "a")
q.Add("tag", "b")
u := url.URL{
Scheme: "https",
Host: "api.example.com",
Path: "/search", // Path is escaped for you
RawQuery: q.Encode(), // q=go+generics&page=2&tag=a&tag=b
}
req, _ := http.NewRequestWithContext(ctx, "GET", u.String(), nil)req.Header.Set replaces, Add appends. Setting Host needs req.Host = "...",
the header map is ignored for it. A bytes.Reader, bytes.Buffer or
strings.Reader body lets the transport set ContentLength and makes the request
retryable. Any other io.Reader sends chunked and cannot be replayed.
Reading the Response
resp, err := httpClient.Do(req) if err != nil { return err // transport error only, NOT a 4xx/5xx } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { b, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10)) return fmt.Errorf("api: %s: %s", resp.Status, bytes.TrimSpace(b)) } var out []Item if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { return fmt.Errorf("decode items: %w", err) }
The rules people get wrong:
- err == nil does not mean success. A 404 or a 500 is a normal response.
Check resp.StatusCode (or resp.StatusCode/100 == 2) before decoding.
- defer resp.Body.Close() always, even on a status you are about to reject.
A body left open pins a connection out of the pool until GC.
- Closing without reading also loses the connection. To reuse it, drain first:
io.Copy(io.Discard, io.LimitReader(resp.Body, 512<<10)) then Close.
- json.NewDecoder(resp.Body).Decode streams and skips a full copy in memory.
io.ReadAll is better when you need the raw bytes for an error message or a
log, and should be bounded with io.LimitReader.
- Compression: the transport adds Accept-Encoding: gzip and transparently
decompresses, but only when you did not set that header. If you set it, you
own the gunzip. resp.Uncompressed tells you which happened.
- resp.ContentLength is -1 for a chunked response.
Multipart Upload
func upload(ctx context.Context, path string) error { f, err := os.Open(path) if err != nil { return err } defer f.Close() var buf bytes.Buffer mw := multipart.NewWriter(&buf) if err := mw.WriteField("caption", "hello"); err != nil { return err } part, err := mw.CreateFormFile("file", filepath.Base(path)) if err != nil { return err } if _, err := io.Copy(part, f); err != nil { return err } if err := mw.Close(); err != nil { // MUST close before sending return err } req, err := http.NewRequestWithContext(ctx, "POST", uploadURL, &buf) if err != nil { return err } req.Header.Set("Content-Type", mw.FormDataContentType()) resp, err := httpClient.Do(req) if err != nil { return err } defer resp.Body.Close() return nil }
mw.Close() writes the closing boundary. Skip it and the server sees a truncated
body. For a file too big to buffer, use io.Pipe with the multipart writer in a
goroutine and pass the pipe reader as the body.
Retries and Timeouts
Retry idempotent requests only, with exponential backoff plus jitter, and stop as soon as the context is done.
func getWithRetry(ctx context.Context, url string) (*http.Response, error) { var lastErr error for attempt := 0; attempt < 4; attempt++ { if attempt > 0 { back := time.Duration(1<<attempt) * 100 * time.Millisecond jitter := time.Duration(rand.Int64N(int64(back / 2))) select { case <-ctx.Done(): return nil, ctx.Err() case <-time.After(back + jitter): } } req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { return nil, err } resp, err := httpClient.Do(req) if err != nil { if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { return nil, err // do not retry, the caller gave up } lastErr = err continue } if resp.StatusCode == 429 || resp.StatusCode >= 500 { io.Copy(io.Discard, resp.Body) resp.Body.Close() lastErr = fmt.Errorf("status %s", resp.Status) continue } return resp, nil } return nil, fmt.Errorf("after 4 attempts: %w", lastErr) }
Timeout errors from Do are wrapped in a *url.Error, so compare with
errors.Is, never with == or a string match:
if errors.Is(err, context.DeadlineExceeded) { /* our deadline */ } var uerr *url.Error if errors.As(err, &uerr) && uerr.Timeout() { /* any timeout, incl. Client.Timeout */ }
Honor Retry-After on a 429 or 503 when the server sends it, in preference to
your own backoff.
TLS and Debugging
Transport: &http.Transport{
TLSClientConfig: &tls.Config{
MinVersion: tls.VersionTLS12,
RootCAs: pool, // from x509.NewCertPool + AppendCertsFromPEM
ServerName: "api.internal",
Certificates: []tls.Certificate{cert}, // mutual TLS
},
}
InsecureSkipVerify: truedisables certificate verification entirely and makes the connection trivially interceptable. It belongs in a lab and nowhere near production. Add the internal CA to aRootCAspool instead.
Dumping the exact bytes on the wire:
b, _ := httputil.DumpRequestOut(req, true) // client side, real headers fmt.Printf("%s\n", b) b, _ = httputil.DumpResponse(resp, true) // consumes and replaces Body fmt.Printf("%s\n", b)
DumpRequestOut shows what the transport will actually send (Host,
Accept-Encoding, User-Agent), while DumpRequest is the server-side view. For
connection-level detail set GODEBUG=http2debug=1 or use
httptrace.WithClientTrace to time DNS, dial, TLS and first byte.