How to Read an HTTP Response Body in Go

Web
Read an HTTP response body in Go using io.ReadAll on resp.Body and ensure you defer resp.Body.Close().

The response body is a stream, not a string

You call an API. The request succeeds. You grab the response and try to print the body, but you see nothing. Or worse, you get a panic because you tried to treat a stream like a string. The response body isn't a blob of text sitting in memory. It's a pipe. Data flows through it once. If you don't read it, the data vanishes. If you don't close it, you leak resources.

Go represents the response body as an io.ReadCloser. This interface combines reading and closing. It tells you that data arrives in chunks and that you are responsible for cleaning up when you are done. The compiler won't force you to read the body, and it won't force you to close it. You have to write the code to drain the stream and release the connection.

Catching the data

Think of resp.Body like a firehose. The water is the data. The hose doesn't hold the water; it delivers it. You need a bucket to catch the water. io.ReadAll is your bucket. You hold the bucket under the hose until the flow stops. Once the water is in the bucket, you can do whatever you want with it. The hose itself is just the delivery mechanism. If you walk away without closing the valve, water keeps spraying until the source runs dry or the system shuts down. In Go, that "valve" is the Close method.

Here's the standard pattern: make the request, close the body immediately to prevent leaks, read everything into a byte slice, then convert to string.

package main

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

func main() {
	// Send the GET request to the target URL.
	resp, err := http.Get("https://httpbin.org/json")
	if err != nil {
		// Network failure or DNS error stops execution.
		log.Fatal(err)
	}
	// Close the body as soon as main returns to release the connection.
	defer resp.Body.Close()

	// Read all bytes from the stream into a slice.
	body, err := io.ReadAll(resp.Body)
	if err != nil {
		// Read error usually means the connection dropped mid-stream.
		log.Fatal(err)
	}

	// Convert bytes to string for display.
	fmt.Println(string(body))
}

The defer statement schedules resp.Body.Close() to run when the function returns. This ensures the connection goes back to the pool even if you hit an error later. If you skip the close, the TCP connection stays open. Eventually, you exhaust the connection pool or hit file descriptor limits. The compiler won't catch this. It's a runtime resource leak that only shows up under load.

Close the body before you read the body. Or defer it immediately. Never let a response body escape a function without a close.

The io.Reader abstraction

Go's I/O is built on a single interface: io.Reader. The interface has one method: Read. It takes a byte slice, fills it with data, and returns the number of bytes written plus an error. resp.Body implements this interface. io.ReadAll is just a utility that calls Read in a loop until it gets io.EOF.

This design makes Go's I/O highly composable. Files, network streams, strings, and compressors all implement io.Reader. You can chain them together. If you wrap a network body with a gzip decompressor, the decompressor also implements io.Reader. You read from the decompressor, and it pulls from the network transparently. You don't need different code for different sources. The same reading logic works for everything.

One interface for all input. Compose readers to build pipelines.

Reading in production

In production, you rarely just print the body. You check the status code, handle errors, and parse the data. This function fetches JSON and decodes it directly into a struct, skipping the intermediate byte slice.

First, create the request and execute it. Using http.NewRequest gives you control over the method and headers.

package main

import (
	"encoding/json"
	"fmt"
	"net/http"
)

// User represents the JSON structure from the API.
type User struct {
	Name string `json:"name"`
	Age  int    `json:"age"`
}

// FetchUser retrieves a user from the API and decodes the response.
func FetchUser(url string) (User, error) {
	// Create a new request to allow more control than http.Get.
	req, err := http.NewRequest(http.MethodGet, url, nil)
	if err != nil {
		return User{}, fmt.Errorf("failed to create request: %w", err)
	}

	// Execute the request using the default client.
	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return User{}, fmt.Errorf("request failed: %w", err)
	}
	// Ensure the body is closed regardless of how the function exits.
	defer resp.Body.Close()

	// Check for HTTP errors like 404 or 500.
	if resp.StatusCode != http.StatusOK {
		return User{}, fmt.Errorf("unexpected status: %s", resp.Status)
	}

	// Decode JSON directly from the stream into the struct.
	var user User
	if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
		return User{}, fmt.Errorf("decode error: %w", err)
	}

	return user, nil
}

The json.NewDecoder(resp.Body).Decode call streams the JSON and parses it directly into the struct. This avoids allocating a full byte slice in memory. It also handles character encoding more robustly than reading all bytes first. For large payloads, this keeps your heap flat.

In real code, you'll see http.NewRequestWithContext instead of http.NewRequest. The context carries deadlines and cancellation signals. Functions that take a context should respect cancellation. Always pass ctx as the first parameter, conventionally named ctx. This allows the caller to abort long-running requests.

Stream when you can. Allocating the whole body into memory is fine for small responses, but streaming keeps your heap flat.

Pitfalls and leaks

The most common bug is reading the body twice. The body is a stream, not a buffer. After io.ReadAll drains it, the pointer is at the end. A second read returns zero bytes and io.EOF. If you need the data multiple times, read it once into a variable and reuse that variable.

Another trap is http.Get blocking forever. http.Get uses http.DefaultClient, which has no timeout. If the server stops responding, your goroutine hangs indefinitely. Always use http.Client with a configured Timeout for production code. A timeout prevents your program from stalling on unresponsive servers.

If you forget to handle the error from ReadAll, the compiler rejects the code with err declared and not used. Go forces you to acknowledge the error. You can use _ to discard it intentionally, but discarding a read error is usually a mistake. The underscore discards a value intentionally. result, _ := ... says "I considered the second return value and chose to drop it". Use it sparingly with errors.

Security matters when reading untrusted data. If you call io.ReadAll on a malicious server that sends gigabytes of data, your program will run out of memory. Use io.LimitReader to cap the read size.

// Limit the read to 1MB to prevent memory exhaustion.
limited := io.LimitReader(resp.Body, 1024*1024)
body, err := io.ReadAll(limited)

io.LimitReader wraps the body and returns EOF after the limit is reached. This protects against denial-of-service attacks via oversized responses.

Read once. Store the result. Reuse the variable, not the stream.

Decision matrix

Use io.ReadAll when the response is small and you need the raw bytes for processing or logging.

Use json.NewDecoder(resp.Body).Decode when the response is JSON and you want to parse it directly into a struct without allocating the full payload.

Use io.LimitReader when you need to cap the read size to protect against malicious servers sending gigabytes of data.

Use http.Get for simple scripts and quick checks where you don't need custom headers or timeouts.

Use http.Client.Do when you need to configure timeouts, headers, or reuse a client across multiple requests.

Use a buffer reader like bufio.NewReader when you need to peek at headers or read line-by-line without loading everything into memory.

Timeouts are mandatory. A request without a timeout is a ticking time bomb.

Where to go next