Menu

Golang HTTP Client: GET, POST JSON, Timeouts and Headers

How to make HTTP requests in Go with net/http: http.Get, reading and closing the body, checking status codes, http.Client timeouts, requests with context and headers, query parameters, and POSTing JSON.

This page includes runnable editors - edit, run, and see output instantly.

A GET request

Every example on this page starts its own server with httptest.NewServer and calls it through a real TCP connection on localhost. Swap srv.URL for a real address like https://api.example.com and the client code is unchanged.

The three rules visible here:

  1. Check err first. It reports failures to get any response at all: DNS errors, refused connections, timeouts, TLS problems.
  2. defer resp.Body.Close() right after the error check. The body holds the connection open. An unclosed body leaks the connection.
  3. Read or decode the body. io.ReadAll for text, json.NewDecoder(resp.Body).Decode(&v) for JSON.

Status codes are not errors

http.Get returns err == nil for any response the server sends, including 404 and 500. You have to check the status yourself:

io.LimitReader caps how much of an error body you read, so a misbehaving server cannot make you load megabytes into an error message. Many APIs return 201, 202 or 204 for success; check resp.StatusCode >= 200 && resp.StatusCode < 300 when any 2xx is fine.

Timeouts: never use the default client for real traffic

http.Get, http.Post and http.DefaultClient have no overall timeout (the default transport only limits dialing and the TLS handshake). A server that accepts the connection and never answers blocks your goroutine forever. Create your own client:

Client.Timeout covers the whole exchange: connecting, redirects, and reading the body. The error it returns is a *url.Error that reports Timeout() == true, which errors.As finds through the net.Error interface. Create one client and reuse it everywhere; an http.Client is safe for concurrent use. The connection pool lives in its Transport, not in the Client: a new http.Client{} per request still shares http.DefaultTransport and its pool, but a new http.Transport per request starts with an empty pool every time.

Requests with a context and headers

http.NewRequestWithContext builds a request you can customize before sending it with client.Do. The context gives a per-request deadline or cancellation on top of the client's timeout, and ties outgoing calls to the lifetime of an incoming request in a server.

Concatenating user input into a URL ("?q=" + q) breaks on &, # and spaces. url.Values and its Encode method do the escaping, and sort the keys. For path segments, use url.PathEscape.

POST JSON

client.Post(url, contentType, body) is a shortcut for a request with that Content-Type header. For anything else (an auth header, PUT, PATCH, DELETE) use http.NewRequestWithContext with the method and client.Do. For form posts, client.PostForm(url, url.Values{...}) encodes the values and sets application/x-www-form-urlencoded.

Connection reuse

The client reuses a TCP connection only when you read the previous body to the end and close it. If you stop reading early (for example after an error status), drain it before closing when you make many requests to the same host:

defer func() {
	io.Copy(io.Discard, resp.Body)
	resp.Body.Close()
}()

For bodies that could be huge, drain through io.LimitReader instead, or accept losing the connection.

Redirects

The client follows up to 10 redirects automatically, and resp.Request.URL tells you where you ended up. To stop at the first redirect, for example to read a Location header, set CheckRedirect:

client := &http.Client{
	Timeout: 10 * time.Second,
	CheckRedirect: func(req *http.Request, via []*http.Request) error {
		return http.ErrUseLastResponse // return the 3xx response itself
	},
}

Common mistakes

  • Using http.Get or http.DefaultClient in production. No timeout. Make an http.Client with Timeout.
  • Forgetting resp.Body.Close(). Connections leak until requests start failing.
  • Closing the body before checking err. When err is not nil, resp is nil and resp.Body.Close() panics.
  • Treating err == nil as success. Check resp.StatusCode.
  • Creating a new http.Transport per request. Each transport has its own connection pool, so every request opens a fresh connection (and a fresh TLS handshake). Share one transport, usually through one shared http.Client.
  • Building query strings by hand. Use url.Values.

Frequently Asked Questions

How do I make a GET request in Go?

resp, err := http.Get(url), check err, then defer resp.Body.Close() and read the body with io.ReadAll(resp.Body) or decode it with json.NewDecoder(resp.Body).Decode(&v). Also check resp.StatusCode: a 404 or 500 is not an error from http.Get.

How do I set a timeout on an HTTP request in Go?

Create a client with a timeout, client := &http.Client{Timeout: 10 * time.Second}, and use it instead of http.Get or http.DefaultClient, which have no overall timeout: a server that accepts the connection and never answers blocks the call forever. For a per-request limit, build the request with http.NewRequestWithContext and a context from context.WithTimeout.

How do I send a POST request with a JSON body in Go?

Marshal the value, then post it: body, _ := json.Marshal(v) and http.Post(url, "application/json", bytes.NewReader(body)). To add headers such as Authorization, build it with http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)), set req.Header.Set("Content-Type", "application/json"), and send it with client.Do(req).

Why do I need to close resp.Body in Go?

The body is a stream on an open connection. Closing it returns the connection to the pool for reuse; forgetting leaks connections and file descriptors until the program hits its limit. Close it even when you do not read it, and read it to the end if you want the connection reused.

Coddy programming languages illustration

Learn to code with Coddy

GET STARTED