How to make HTTP requests

Use the standard library's `net/http` package for most cases, as it provides a robust, dependency-free client with built-in support for timeouts and TLS.

The shared car trap

You write a Python script that calls an API. It works. You port it to Go, copy the URL, and suddenly your program hangs forever when the network drops. Or you forget to close a response body and your memory usage climbs until the operating system kills the process. HTTP in Go feels different because it refuses to hide the plumbing. The standard library gives you the engine, the transmission, and the brakes. You have to know how to use them.

Think of net/http like a car rental agency. The convenience functions like http.Get are like grabbing the keys to a shared company car. It works for a quick trip, but you have no control over the speed limit, the fuel gauge, or what happens if the car breaks down on the highway. The http.Client is your own vehicle. You configure the engine, set the safety features, and you know exactly what happens when you turn the key.

Go expects you to treat network calls as fallible, long-running operations. The language does not silently swallow connection timeouts or retry failed requests. It hands you the raw response and expects you to handle the lifecycle. That design choice keeps your programs predictable and keeps your servers from running out of file descriptors.

How the client actually works

The http.Client manages a connection pool under the hood. When you make a request, Go checks if there is an idle TCP connection to that host. If one exists, it reuses it. If not, it opens a new socket, performs the TLS handshake, and sends the request. When the response arrives, the connection does not close immediately. It sits idle in the pool, waiting for the next request to the same host. This pooling is what makes Go fast at scale.

The response body is an io.ReadCloser. That interface means two things: you can read from it, and you must close it. Closing the body signals to the connection pool that you are finished with this response. If you skip the close, the pool thinks the connection is still in use. Your program slowly leaks sockets until the operating system refuses to open any more.

Go's error handling convention makes the unhappy path visible. You will see if err != nil { return err } everywhere. It looks verbose compared to try-catch blocks, but it forces you to acknowledge every failure point. The compiler will not let you ignore an error unless you explicitly discard it with _. That discipline prevents silent failures in production.

Minimal example

Here is the simplest correct GET request: spawn a client, make the call, check the status, read the body, and close it.

package main

import (
	"fmt"
	"io"
	"net/http"
)

// main demonstrates a basic HTTP GET with proper lifecycle management.
func main() {
	// explicit client gives us control over timeouts and transport settings
	client := &http.Client{}

	// NewRequest builds the HTTP message without sending it yet
	req, err := http.NewRequest(http.MethodGet, "https://api.example.com/data", nil)
	if err != nil {
		fmt.Println("failed to build request:", err)
		return
	}

	// Do sends the request and blocks until the server responds
	resp, err := client.Do(req)
	if err != nil {
		fmt.Println("request failed:", err)
		return
	}
	// defer ensures the body closes even if we panic or return early
	defer resp.Body.Close()

	// non-200 status codes are still valid HTTP responses
	if resp.StatusCode != http.StatusOK {
		fmt.Printf("server returned: %d\n", resp.StatusCode)
		return
	}

	// ReadAll pulls the entire stream into memory
	body, err := io.ReadAll(resp.Body)
	if err != nil {
		fmt.Println("failed to read body:", err)
		return
	}

	fmt.Println("response:", string(body))
}

The code above follows the standard pattern. You build the request, you send it, you verify the status, you read the payload, and you close the stream. Every step is explicit. There are no hidden retries. There are no automatic JSON decoders. You get exactly what the server sends.

Goroutines are cheap. Channels are not magic. HTTP clients are safe to share across them. Create one http.Client at package level and reuse it everywhere.

Walking through the lifecycle

When client.Do(req) runs, Go serializes the request into bytes. It looks up the DNS record, opens a TCP socket, and negotiates TLS if the URL uses HTTPS. The request headers and method travel across the wire. The server processes it and sends back a status line, headers, and a body stream.

Go reads the status and headers first. It hands you a *http.Response object. The body is not fully downloaded yet. It is a stream. You pull bytes from it using io.ReadAll, io.Copy, or a JSON decoder. Each read call pulls more data from the underlying TCP buffer. When you finish reading, you call resp.Body.Close(). That call returns the TCP connection to the pool.

If you close the body without reading it fully, the connection is still returned to the pool, but the server might have sent more data than you consumed. The next request that reuses that connection will read leftover bytes from the previous response. That causes a protocol error. Always read or drain the body before closing it, unless you are intentionally discarding it.

The compiler enforces type safety at every step. If you pass a string where a byte slice is expected, you get cannot use "text" (untyped string constant) as []byte value in argument. If you forget to import net/http, you get undefined: http. These errors happen at compile time, not in production. Trust the compiler. Argue logic, not formatting.

Realistic example

Production code needs timeouts, custom headers, and structured payloads. Here is how you build a POST request with JSON and a deadline.

package main

import (
	"bytes"
	"fmt"
	"net/http"
	"time"
)

// main demonstrates a production-ready POST request with timeout and JSON.
func main() {
	// Timeout covers DNS, TCP, TLS, and the full response read
	client := &http.Client{
		Timeout: 5 * time.Second,
	}

	// payload holds the JSON body we want to send
	payload := []byte(`{"action":"deploy","env":"staging"}`)

	// bytes.NewReader creates an io.Reader from the byte slice
	body := bytes.NewReader(payload)

	// NewRequestWithBody is the modern way to attach a reader to a request
	req, err := http.NewRequest(http.MethodPost, "https://api.example.com/submit", body)
	if err != nil {
		fmt.Println("failed to build request:", err)
		return
	}

	// Content-Type tells the server how to parse the incoming stream
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("X-Request-ID", "abc-123")

	// Do executes the request and returns the server response
	resp, err := client.Do(req)
	if err != nil {
		fmt.Println("request failed:", err)
		return
	}
	defer resp.Body.Close()

	// always read the body to return the connection to the pool
	buf := new(bytes.Buffer)
	_, err = buf.ReadFrom(resp.Body)
	if err != nil {
		fmt.Println("failed to read response:", err)
		return
	}

	fmt.Printf("status: %d, body: %s\n", resp.StatusCode, buf.String())
}

The Timeout field on http.Client is a hard deadline. It covers DNS resolution, TCP connection, TLS handshake, request writing, and response reading. If any step exceeds five seconds, Go cancels the operation and returns a timeout error. That prevents your program from hanging on a dead server.

Functions that accept a context.Context should always take it as the first parameter, conventionally named ctx. The context carries cancellation signals and deadlines. If you wrap your HTTP calls in a function, pass the context through. Context is plumbing. Run it through every long-lived call site.

Pitfalls and compiler traps

The most common mistake is using http.Get in production. That function uses a global client with zero timeout. If the server stops responding, your goroutine blocks forever. The compiler will not warn you. The runtime will just sit there. Use http.Client with an explicit timeout instead.

Another trap is ignoring the response body. If you check the status code and return early without reading or closing the body, you leak the connection. The pool keeps it open. After a few hundred requests, your process hits the OS file descriptor limit. The compiler complains with resp.Body.Close() called but not all data read only if you enable race detection or use a linter. The fix is simple: always drain or close the body.

Type mismatches show up quickly. If you try to pass a string to a function expecting []byte, the compiler rejects it with cannot use body (type string) as []byte value in argument. Convert it with []byte(body) or use strings.NewReader. Go does not do implicit conversions. That design choice keeps memory layouts predictable.

Goroutine leaks happen when a background task waits on a channel that never closes. HTTP clients do not leak goroutines by themselves, but if you spawn a goroutine to read a response and forget to cancel it, it hangs. Always have a cancellation path. The worst goroutine bug is the one that never logs.

Decision matrix

Use http.Get or http.Post only for throwaway scripts where you do not care about timeouts or connection pooling. Use http.Client with http.NewRequest when you need control over timeouts, headers, or transport settings. Use http.NewRequestWithContext when your request must respect a parent deadline or cancellation signal. Use a shared package-level http.Client when multiple goroutines or handlers make requests to the same host. Use io.Copy or a streaming decoder when the response body is large and you do not want to load it entirely into memory. Use plain sequential code when you do not need concurrency: the simplest thing that works is usually the right thing.

Where to go next