The body is a stream, not a string
You have a Go service that needs to send data to an API. You grab http.Post, write three lines, and it works. Then you need to add a timeout, set a custom header, or parse the response, and suddenly those three lines turn into a maze of interfaces and readers. The standard library gives you the tools, but it expects you to understand how HTTP bodies work in Go.
In many languages, you pass a string or a dictionary to a POST function. Go treats the request body as a stream. The http.Request struct has a Body field of type io.Reader. This interface means the request can read data from memory, a file, or a network socket without caring where it comes from. When you make a POST request, you are handing the HTTP client a reader that produces your payload. The client reads from that reader and writes the bytes onto the wire. This design keeps memory usage low for large payloads and unifies the API for all request types.
The trade-off is that you cannot pass a byte slice or a string directly. You must wrap it in a reader. This feels like extra work until you realize it allows you to stream gigabytes of data without loading it into RAM, or to compress the body on the fly before it hits the network. The type system forces you to think about the data flow.
The simplest POST request
Here's the helper function for quick scripts. It wraps the request creation and execution in one call.
package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
)
func main() {
// Define the payload as a map.
// Maps are convenient for dynamic keys but lack compile-time structure checks.
data := map[string]string{"action": "deploy"}
// Marshal the map to JSON bytes.
// The error is discarded with _ for brevity in this example.
// Production code should always check Marshal errors.
payload, _ := json.Marshal(data)
// http.Post creates the request, sets the content type, and executes it.
// It uses the DefaultClient, which has no timeout.
resp, err := http.Post("https://httpbin.org/post", "application/json", bytes.NewReader(payload))
if err != nil {
fmt.Println("Request failed:", err)
return
}
// Close the body to free resources.
// Always defer this immediately after checking the error.
defer resp.Body.Close()
fmt.Println("Status:", resp.Status)
}
http.Post is a convenience wrapper. It calls http.NewRequest with the method set to POST, sets the Content-Type header, wraps your byte slice in a bytes.NewReader, and sends it using http.DefaultClient.Do. The bytes.NewReader creates a reader that points to your JSON bytes. The HTTP client reads from this reader until it hits the end.
The response comes back with a Body that is also a reader. You must close that body, or you risk leaking connections. The defer resp.Body.Close() pattern is standard. If you forget to close the body, the underlying TCP connection might not return to the pool, and your program will eventually run out of file descriptors. The compiler does not check for this. It is a runtime responsibility.
http.Post is useful for throwaway scripts. It is dangerous for production code because http.DefaultClient has no timeout. If the server hangs, your goroutine hangs forever. Production code needs control over the request lifecycle.
Building a production request
Real code needs timeouts, custom headers, context cancellation, and response parsing. You switch from http.Post to http.NewRequestWithContext and a custom http.Client.
Here's how you construct the request with context and headers.
// PostRequest prepares a JSON POST request with context and headers.
// It returns the request object for execution by a client.
func PostRequest() (*http.Request, error) {
// Define the payload struct.
// Structs provide type safety and explicit JSON field mapping.
payload := struct {
Action string `json:"action"`
ID int `json:"id"`
}{
Action: "deploy",
ID: 42,
}
// Marshal the struct to JSON bytes.
// Check the error because marshaling can fail on invalid types.
body, err := json.Marshal(payload)
if err != nil {
// Wrap the error with context.
// The %w verb allows callers to unwrap and inspect the original error.
return nil, fmt.Errorf("marshal payload: %w", err)
}
// Create a context with a timeout.
// This cancels the request if the server takes too long.
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
// Ensure the context is cancelled to release resources.
// This is critical even if the request succeeds.
defer cancel()
// Build the request with the context.
// NewRequestWithContext attaches the context for cancellation.
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://httpbin.org/post", bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
// Set headers on the request.
// Content-Type tells the server how to interpret the body.
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Request-ID", "abc-123")
return req, nil
}
The context flows through the request. context.WithTimeout creates a context that triggers cancellation after five seconds. The defer cancel() releases the context resources. http.NewRequestWithContext attaches the context to the request. When the timeout fires, the HTTP client stops reading and writing, and the goroutine returns with a context deadline exceeded error.
The compiler rejects http.NewRequest("POST", url, payload) with cannot use payload (type []byte) as io.Reader value in argument if you pass the byte slice directly. You must wrap it in bytes.NewReader(payload). This error saves you from a runtime panic where the client tries to read from a type that doesn't implement the read interface.
Here's how you execute the request with a client and handle the response.
// sendRequest executes the HTTP request and validates the response.
// It separates transport logic from request construction.
func sendRequest(req *http.Request) error {
// Create a client with a timeout.
// The client timeout is a fallback; the context deadline is preferred.
client := &http.Client{Timeout: 10 * time.Second}
// Execute the request.
// Do sends the request and returns the response or a transport error.
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("send request: %w", err)
}
// Close the body immediately.
// This returns the TCP connection to the pool.
defer resp.Body.Close()
// Check the status code.
// A non-2xx status is a logical error, not a network error.
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("unexpected status: %s", resp.Status)
}
// Read the response body.
// io.ReadAll reads until EOF. For large bodies, stream instead.
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("read response: %w", err)
}
fmt.Println("Response:", string(respBody))
return nil
}
The http.Client manages connection pooling. It reuses TCP connections for multiple requests to the same host. Creating a new client for every request destroys this benefit. Best practice is to create one client per service or use a global client configured with your timeout and TLS settings. The client timeout is a safety net. The context deadline is the primary control mechanism.
The response body must be read or drained if you want the connection to be reused for HTTP/1.1. In modern Go, closing the body is usually sufficient for the client to handle connection reuse, but if you skip reading the body, the client might discard the connection. If you don't need the response data, call io.Copy(io.Discard, resp.Body) before closing, or just close it and accept that the connection might not be reused.
Pitfalls and errors
The compiler catches type mismatches, but runtime errors require careful handling.
If you forget to close the response body, you leak connections. The leak is silent. Your program runs fine for a while, then starts failing with too many open files or connection timeouts. The worst goroutine bug is the one that never logs. Always defer resp.Body.Close().
If you pass a nil body to NewRequest, the request sends with no content. The compiler allows this. The server might reject it with a 400 Bad Request. If you need to send an empty body, pass http.NoBody instead of nil. http.NoBody is a sentinel value that signals an intentional empty body.
If the context times out, you get a context deadline exceeded error. This error wraps the underlying transport error. You can use errors.Is(err, context.DeadlineExceeded) to check for timeouts. If you cancel the context manually, you get context canceled.
If you use http.Post and need a timeout, you hit a wall. http.Post uses http.DefaultClient, which has no timeout configuration. You must switch to http.NewRequestWithContext and a custom client.
The convention for error handling is if err != nil { return err }. It is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. Wrap errors with fmt.Errorf("...: %w", err) to add context while preserving the error chain.
Convention aside: context.Context always goes as the first parameter, conventionally named ctx. Functions that take a context should respect cancellation and deadlines. This is a universal Go pattern.
When to use what
Use http.Post when you need a quick one-off request with no custom headers, no timeout, and no context. It is a convenience wrapper for simple scripts and tests.
Use http.NewRequestWithContext when you need to control the request lifecycle, set custom headers, or attach a context for cancellation. This is the standard pattern for production code.
Use a custom http.Client when you need to configure timeouts, TLS settings, or transport options like connection pooling. The default client has no timeout, which can hang your program.
Use http.PostForm when you are sending form-encoded data instead of JSON. It handles the encoding and content-type automatically.
Use io.Pipe when you need to stream the request body from a goroutine. This allows you to generate the payload on the fly without buffering it in memory.