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,
    },
}
FieldDefaultWhy you touch it
Client.Timeout0 (none)the single most important setting
MaxIdleConnsPerHost2the classic throughput bug under load
MaxConnsPerHost0cap concurrency against a fragile upstream
IdleConnTimeout90show long a pooled conn is kept
ResponseHeaderTimeout0catch a server that accepts and stalls
ForceAttemptHTTP2false on a custom Transportre-enable HTTP/2
DisableKeepAlivesfalsedebugging 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.

Redirects, Cookies, and Proxies

// Do not follow redirects, inspect the 30x yourself
client := &http.Client{
    CheckRedirect: func(req *http.Request, via []*http.Request) error {
        return http.ErrUseLastResponse
    },
}

// Cap the chain (the default is 10)
CheckRedirect: func(req *http.Request, via []*http.Request) error {
    if len(via) >= 3 {
        return errors.New("too many redirects")
    }
    return nil
},

Auth headers are dropped on a cross-domain redirect. Re-set them in CheckRedirect from via[0].Header when you need them to survive.

// Session cookies across requests
jar, err := cookiejar.New(nil)          // add publicsuffix.List for real hosts
if err != nil { return err }
client := &http.Client{Jar: jar, Timeout: 10 * time.Second}

// Proxies: honor HTTP_PROXY / HTTPS_PROXY / NO_PROXY
Transport: &http.Transport{Proxy: http.ProxyFromEnvironment}

// Or a fixed one
p, _ := url.Parse("http://proxy.internal:3128")
Transport: &http.Transport{Proxy: http.ProxyURL(p)}

http.ProxyFromEnvironment is the default only on http.DefaultTransport. A Transport you construct yourself has Proxy: nil and ignores the environment until you wire it up.

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: true disables certificate verification entirely and makes the connection trivially interceptable. It belongs in a lab and nowhere near production. Add the internal CA to a RootCAs pool 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.