Making HTTP Requests with net/http Client
The net/http package’s Client type is how Go programs talk to other servers over HTTP: fetching a web page, calling a REST API, downloading a file, or posting JSON to a service. Every outgoing HTTP request in Go — whether it is a one-line http.Get or a fully customized request with headers, a body, and a deadline — ultimately flows through an http.Client. Understanding how Client, Request, and Response fit together, and how connections are pooled and timed out under the hood, is essential for writing Go programs that talk to the network reliably instead of hanging or leaking resources.
Overview / How the net/http Client Works
An http.Client is a struct with a small set of fields: Transport (how connections are made and reused), CheckRedirect (a hook controlling redirect behavior), Jar (an optional cookie store), and Timeout (an overall deadline for the whole request/response cycle, including redirects and reading the body). The package-level helpers http.Get, http.Post, and http.Head are convenience functions that simply call methods on http.DefaultClient, a zero-value *http.Client shared by the whole program.
The zero value of http.Client is perfectly usable — it works out of the box — but it has one dangerous property: its Timeout is zero, which in Go’s timeout conventions means no timeout at all. If a remote server accepts your connection but never sends a response, a request made with http.DefaultClient (or any client you built without setting Timeout) can block forever. This is why production code almost always constructs its own &http.Client{Timeout: ...} rather than relying on the default.
Under the hood, a Client delegates the actual network work to its Transport, which is usually http.DefaultTransport if you don’t set one. The transport maintains a pool of idle, keep-alive TCP (and TLS) connections keyed by host. When you make a request to a host you’ve already talked to recently, the transport reuses an existing connection instead of paying for a new TCP handshake (and TLS handshake, for HTTPS) every time. This is precisely why you should construct one http.Client and reuse it for many requests, rather than creating a new client for every call — a fresh client with a fresh, unshared transport cannot benefit from connection pooling.
A request is represented by an *http.Request: it carries the HTTP method, the target URL, a header map, and an optional body (an io.Reader, typically wrapped so it also satisfies io.Closer). You build one directly with http.NewRequest (or, better, http.NewRequestWithContext, which ties the request’s lifetime to a context.Context) whenever you need to set custom headers, use a method other than GET, or attach a body. A response comes back as *http.Response, whose Body field is an io.ReadCloser backed directly by the underlying connection — the body is streamed lazily, so you must read it (or at least close it) to free the connection.
Syntax
The general shapes you’ll use, from simplest to most explicit:
resp, err := http.Get(url)
// ...
client := &http.Client{Timeout: d}
req, err := http.NewRequest(method, url, body)
// ...
req.Header.Set(key, value)
resp, err := client.Do(req)
| Function / Type | Purpose |
|---|---|
http.Get(url) |
Convenience GET using http.DefaultClient; no way to set headers. |
http.Post(url, contentType, body) |
Convenience POST using http.DefaultClient. |
http.NewRequest(method, url, body) |
Builds a *http.Request you can customize before sending. |
http.NewRequestWithContext(ctx, method, url, body) |
Same, but bound to a context.Context for cancellation/deadlines. |
client.Do(req) |
Sends a fully-built request and returns the response. |
Client.Timeout |
Deadline covering the entire round trip: connect, redirects, headers, and body. |
Client.Transport |
Controls connection pooling, proxies, TLS config. |
Client.Jar |
Optional http.CookieJar for automatic cookie handling. |
Examples
Example 1: A basic GET request
The examples below spin up a local test server with httptest.NewServer so the program is fully self-contained and its output is exact and reproducible — in real code, server.URL would simply be a live URL like https://api.example.com.
package main
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
)
func main() {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "Hello from the server!")
}))
defer server.Close()
resp, err := http.Get(server.URL)
if err != nil {
fmt.Println("request failed:", err)
return
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
fmt.Println("read failed:", err)
return
}
fmt.Println("Status:", resp.Status)
fmt.Println("Body:", strings.TrimSpace(string(body)))
}
Output:
Status: 200 OK
Body: Hello from the server!
The err check happens before we ever touch resp, so defer resp.Body.Close() is only reached once we know resp is non-nil. io.ReadAll drains the body into a byte slice, which we convert to a string to print.
Example 2: A custom Client with headers and a timeout
Real programs rarely use the package-level http.Get, because it gives no way to set headers or a timeout. Instead, build a request and send it through your own *http.Client.
package main
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
"time"
)
func main() {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
agent := r.Header.Get("User-Agent")
fmt.Fprintf(w, "your agent: %s", agent)
}))
defer server.Close()
client := &http.Client{
Timeout: 5 * time.Second,
}
req, err := http.NewRequest(http.MethodGet, server.URL, nil)
if err != nil {
fmt.Println("build request failed:", err)
return
}
req.Header.Set("User-Agent", "GoLessonBot/1.0")
resp, err := client.Do(req)
if err != nil {
fmt.Println("request failed:", err)
return
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
fmt.Println("read failed:", err)
return
}
fmt.Println(string(body))
}
Output:
your agent: GoLessonBot/1.0
We built the request with http.NewRequest, set a header on it directly, and sent it with client.Do. Because client has a Timeout, this call can never hang indefinitely, unlike a bare http.Get.
Example 3: POST-ing JSON and decoding a JSON response
This example marshals a struct to JSON, sends it as a POST body with a context deadline, and decodes the JSON response straight into another struct.
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"time"
)
type createUserRequest struct {
Name string `json:"name"`
}
type createUserResponse struct {
ID int `json:"id"`
Name string `json:"name"`
}
func main() {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req createUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(createUserResponse{ID: 42, Name: req.Name})
}))
defer server.Close()
payload, err := json.Marshal(createUserRequest{Name: "Ada"})
if err != nil {
fmt.Println("marshal failed:", err)
return
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, server.URL, bytes.NewReader(payload))
if err != nil {
fmt.Println("build request failed:", err)
return
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
fmt.Println("request failed:", err)
return
}
defer resp.Body.Close()
var result createUserResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
fmt.Println("decode failed:", err)
return
}
fmt.Printf("Created user #%d: %s\n", result.ID, result.Name)
}
Output:
Created user #42: Ada
http.NewRequestWithContext ties the request to a 5-second context deadline — if that deadline passes before the round trip finishes, client.Do returns an error wrapping context.DeadlineExceeded. On the response side, json.NewDecoder(resp.Body).Decode(&result) streams and parses JSON directly from the network connection without buffering the whole body into memory first, which is the idiomatic way to consume JSON responses.
How It Works Step by Step
When you call client.Do(req) (which is also what http.Get and http.Post do internally), roughly this sequence happens:
- The
Transportlooks in its idle-connection pool for a live keep-alive connection to the request’s host and scheme. - If none is available, it dials a new TCP connection, and for HTTPS performs a TLS handshake.
- The request line, headers, and body (if any) are written to the connection.
- The transport blocks until the response status line and headers arrive, then returns a
*http.Responseimmediately — the body is not fully read yet. - Your code reads
resp.Bodyas anio.Reader, pulling bytes off the wire as needed (directly, or via helpers likeio.ReadAllorjson.Decoder). - If
Client.Timeoutis set, a single deadline covers this entire sequence — connect, write, headers, and reading the body — and the call fails with a timeout error if it’s exceeded. - Calling
resp.Body.Close()either lets the transport return the now-drained connection to the idle pool for reuse, or closes the underlying connection if it can’t be reused; failing to close the body leaks that connection. - By default the client follows up to 10 redirects automatically; supplying
CheckRedirectlets you inspect, limit, or block that behavior.
Common Mistakes
Mistake 1: Deferring Close before checking the error
If http.Get or client.Do returns an error, resp is nil. Deferring resp.Body.Close() before the error check dereferences a nil pointer and panics.
// Wrong: panics if err != nil, because resp is nil
resp, err := http.Get(url)
defer resp.Body.Close()
if err != nil {
log.Fatal(err)
}
Always check the error first, and only defer the close once you know the response is valid:
// Correct
resp, err := http.Get(url)
if err != nil {
log.Fatal(err)
}
defer resp.Body.Close()
Mistake 2: Treating a nil error as a successful response
A nil error from client.Do only means the round trip completed — it says nothing about the HTTP status code. A 404 or 500 response still comes back with err == nil.
// Wrong: ignores resp.StatusCode entirely
resp, err := http.Get(url)
if err != nil {
log.Fatal(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
fmt.Println(string(body))
Check the status code explicitly before trusting the body:
// Correct
resp, err := http.Get(url)
if err != nil {
log.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
log.Fatalf("unexpected status: %s", resp.Status)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
log.Fatal(err)
}
fmt.Println(string(body))
Mistake 3: Relying on the default client’s lack of a timeout
Because http.DefaultClient (used by http.Get) has Timeout: 0, a request to a server that accepts the connection but never replies will hang forever, with no way to recover.
// Wrong: no deadline anywhere in this call
resp, err := http.Get("https://example.com/slow")
Give every outbound client an explicit timeout, either on the client itself or via a context:
// Correct
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Get("https://example.com/slow")
Best Practices
- Construct one
*http.Clientand reuse it for many requests so itsTransportcan pool and reuse connections. - Always set a
Timeouton any client you build, or attach a deadline viacontext.WithTimeoutfor finer-grained control per request. - Check
errbefore touchingresp, and checkresp.StatusCodebefore trusting the body — a nil error is not the same as a 2xx response. - Always close
resp.Body, ideally withdeferplaced immediately after the nil-error check. - Set
Content-Typeexplicitly when sending a request body, since Go does not guess it for you. - Prefer
http.NewRequestWithContextoverhttp.NewRequestso request cancellation can propagate from a parent operation (like an incoming HTTP handler being canceled by its own client). - Decode JSON responses directly with
json.NewDecoder(resp.Body).Decode(&v)instead of reading the whole body into memory first, when the response can be large. - Avoid using
http.DefaultClientdirectly in production code — build your own so its timeout and transport are under your control.
Practice Exercises
- Write a program that spins up an
httptest.Serverreturning a JSON array of objects, then usehttp.Getandjson.Decoderto fetch and decode it into a slice of structs, printing the count of items. - Take Example 2 and change the handler to call
time.Sleep(3 * time.Second)before responding, while keeping the client’sTimeoutat a shorter value (e.g. 1 second). Confirm thatclient.Donow returns a timeout error instead of the response. - Write a helper function
func postJSON(client *http.Client, url string, v any) (*http.Response, error)that marshalsvto JSON, builds a POST request withContent-Type: application/json, and returns the response fromclient.Do.
Summary
http.Clientsends requests;http.Get/http.Postare shortcuts that use a sharedhttp.DefaultClientwith no timeout.- Build custom requests with
http.NewRequestorhttp.NewRequestWithContextto set headers, methods, and bodies before sending them withclient.Do. - The
Transportpools keep-alive connections per host, which is why reusing oneClientis more efficient than creating a new one per call. resp.Bodystreams from the live connection and must always be closed, only after the error from the call has been checked.- A nil error does not mean success — always inspect
resp.StatusCode. - Set an explicit
Timeoutor use a context deadline on every outbound request in real programs.
