HTTP Client in Go: Requests, Timeouts and Decoding JSON
Call APIs with net/http the production way: timeouts, closing bodies, status codes, contexts, streaming and JSON decoding.
On this page
Calling other services over HTTP is something almost every backend does: payment APIs, webhooks, internal microservices. Go's net/http package has a production-grade client built in. It is easy to use, but a few defaults will hurt you in production if you do not know them.
The quick way: http.Get
package main
import (
"fmt"
"io"
"net/http"
)
func main() {
res, err := http.Get("https://jsonplaceholder.typicode.com/posts/1")
if err != nil {
fmt.Println("network error:", err)
return
}
defer res.Body.Close() // always close the body
body, err := io.ReadAll(res.Body)
if err != nil {
fmt.Println("read error:", err)
return
}
fmt.Println(res.StatusCode, string(body))
}Two things to notice:
err is only non-nil for transport problems (DNS failure, connection refused, timeout). A 404 or 500 response is not an error; you must check res.StatusCode yourself.Always set a timeout
http.Get uses http.DefaultClient, which has no timeout. If the server never answers, your goroutine waits forever. Create your own client once and reuse it:
var client = &http.Client{
Timeout: 10 * time.Second, // covers connect, headers and reading the body
}Reuse matters too: the client keeps a pool of keep-alive connections. Creating a new http.Client (or worse, a new Transport) for every request throws that pool away.
Streaming large responses
io.ReadAll loads the entire body into memory. For large downloads, stream instead:
res, err := client.Get(url)
if err != nil {
return err
}
defer res.Body.Close()
out, err := os.Create("download.bin")
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, res.Body) // copies in small chunksThis works because res.Body is an io.Reader and a file is an io.Writer (see the interfaces article).
Decoding JSON responses
Decode directly from the body into a struct:
type Post struct {
ID int `json:"id"`
UserID int `json:"userId"`
Title string `json:"title"`
Body string `json:"body"`
}
func fetchPost(ctx context.Context, id int) (*Post, error) {
url := fmt.Sprintf("https://jsonplaceholder.typicode.com/posts/%d", id)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/json")
res, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status: %s", res.Status)
}
var post Post
if err := json.NewDecoder(res.Body).Decode(&post); err != nil {
return nil, fmt.Errorf("decode: %w", err)
}
return &post, nil
}This is the shape of a well-behaved API call:
NewRequestWithContext, so callers can cancel it or apply a deadline.Accept, Content-Type).client.Do it; wrap transport errors with context.defer res.Body.Close().Sending JSON (POST)
payload, err := json.Marshal(map[string]any{"title": "Hello", "userId": 1})
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
"https://jsonplaceholder.typicode.com/posts", bytes.NewReader(payload))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
res, err := client.Do(req)Checklist for production clients
Timeout (or use per-request context deadlines).http.Client per destination.defer res.Body.Close(), even when you do not read it.io.LimitReader(res.Body, 1<<20).Key takeaways
err covers transport failures only; check StatusCode for HTTP errors.NewRequestWithContext + client.Do for headers, methods and cancellation.io.Copy and decode JSON with json.NewDecoder straight from the body.Last updated on