The three-step dance of JSON requests
You have a struct with user data. You need to send it to an API endpoint. You try to pass the struct directly to http.Post and the compiler yells at you. Or you pass a string and the server rejects the request because the content type is wrong. Sending JSON in Go feels like a three-step dance: marshal the struct to bytes, set the content type, and make the request. The standard library handles this cleanly, but the types matter.
A Go struct lives in your program's memory. It has fields, types, and values. JSON is a string of text. The network only understands bytes. To send a struct over HTTP, you must convert the in-memory object into a byte stream, attach the right headers, and hand the stream to the transport layer. encoding/json does the conversion. net/http does the transport.
Think of your struct as a blueprint for a house. The JSON is the set of instructions you mail to the contractor. You cannot mail the blueprint directly. You have to write it down on paper first. json.Marshal writes the blueprint to paper. http.Post mails the paper. The Content-Type header is the label on the envelope that tells the contractor this is a blueprint, not a grocery list.
Minimal example
Here is the simplest way to post JSON: marshal the struct, call http.Post, and handle the response.
package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
)
// Payload defines the structure of the JSON body.
// The json tags control the key names in the output.
type Payload struct {
Name string `json:"name"`
Age int `json:"age"`
}
func main() {
// Instantiate the struct with data.
payload := Payload{Name: "Alice", Age: 30}
// Marshal converts the struct to a JSON byte slice.
// This returns an error if the struct contains unexported fields
// or types that cannot be serialized.
jsonData, err := json.Marshal(payload)
if err != nil {
fmt.Println("marshal error:", err)
return
}
// Post sends a request with the specified content type.
// The body must implement io.Reader, so we wrap the bytes.
// bytes.NewReader creates a Reader from the byte slice.
resp, err := http.Post("https://api.example.com/users", "application/json", bytes.NewReader(jsonData))
if err != nil {
fmt.Println("request failed:", err)
return
}
defer resp.Body.Close()
}
What happens under the hood
json.Marshal walks the struct. It reads the tags. It produces {"name":"Alice","age":30} as a []byte. bytes.NewReader makes that slice readable by implementing the io.Reader interface. http.Post creates a request, sets Content-Type: application/json, writes the body to the connection, and sends it.
Go's standard library is built on interfaces. io.Reader is the universal interface for input. Any type that implements Read(p []byte) (n int, err error) can be used as a body. bytes.NewReader wraps a slice and implements that method. This decouples the data source from the transport. You could swap bytes.NewReader for a file or a network stream without changing the HTTP code. The HTTP client only cares that it can read bytes.
The defer resp.Body.Close() call is critical. The HTTP client maintains a pool of TCP connections. If you read the body but do not close it, the connection stays open until the garbage collector runs. Under load, this exhausts the connection pool and blocks future requests. Always close the body immediately after reading.
Go error handling is verbose by design. The community accepts the boilerplate because it forces you to acknowledge the failure path. Every Marshal, NewRequest, and Do can fail. Check the error immediately. Do not ignore it with _ unless you have a specific reason. The compiler will not warn you if you discard an error, but the runtime will punish you.
Marshal bytes. Wrap the reader. Close the body.
Realistic request handling
Production code rarely stops at http.Post. You usually need to set timeouts, add authentication headers, or inspect the response status. http.NewRequest gives you full control over the request object. You can also inject a context.Context to support cancellation and deadlines.
Start with the request construction. http.NewRequestWithContext is the standard way to build a request when you need headers or context.
// BuildRequest prepares an HTTP request with JSON body and headers.
// It returns the request object or an error.
func BuildRequest(ctx context.Context, url string, payload interface{}) (*http.Request, error) {
// Marshal the payload to JSON bytes.
// The interface{} allows any struct to be passed.
jsonData, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("marshal: %w", err)
}
// NewRequestWithContext creates a cancellable request.
// The context propagates deadlines to the transport layer.
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(jsonData))
if err != nil {
return nil, fmt.Errorf("new request: %w", err)
}
// Set the content type header.
// Servers use this to parse the body correctly.
req.Header.Set("Content-Type", "application/json")
return req, nil
}
Execute the request and handle the response. Always check the status code and read the body.
// ExecuteRequest sends the request, checks status, and returns the body.
func ExecuteRequest(req *http.Request) ([]byte, error) {
// Create a client with a timeout.
// Timeouts prevent goroutine leaks on slow networks.
client := &http.Client{Timeout: 5 * time.Second}
// Do sends the request and waits for the response.
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("do: %w", err)
}
defer resp.Body.Close()
// Validate the status code.
// Non-2xx responses are treated as errors here.
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("status: %d", resp.StatusCode)
}
// Read the entire response body.
// This blocks until the server sends all data.
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read body: %w", err)
}
return body, nil
}
The context.Context parameter follows Go convention. It always goes first and is named ctx. Functions that accept a context should respect cancellation. If the context is cancelled, the request stops. This prevents hanging goroutines when the user navigates away or a parent operation times out.
Context flows down. Errors bubble up. Timeouts save lives.
Pitfalls and compiler errors
The compiler catches type mismatches. If you try to pass a []byte directly to http.Post, the compiler rejects it with cannot use jsonData (variable of type []byte) as io.Reader value in argument to http.Post: []byte does not implement io.Reader (missing Read method). Wrap the slice with bytes.NewReader or strings.NewReader.
JSON marshaling is silent about structure. If your struct has lowercase fields, json.Marshal ignores them. You get an empty object {}. The compiler will not warn you. The server will likely reject the request with a 400 Bad Request. Always capitalize struct fields you want to serialize. Go's visibility rules apply to reflection. Unexported fields are invisible to encoding/json.
JSON tags control the output keys. Without tags, the keys match the Go field names. {"Name":"Alice"}. Many APIs expect lowercase keys. Use json:"name" to map the field. The tag also supports options. json:"name,omitempty" omits the field if it has the zero value. This is useful for optional fields. Without omitempty, the server sees "name":"" for an empty string. With it, the key disappears. Use omitempty when the API treats missing keys differently from empty values.
Forgetting defer resp.Body.Close() leaks connections. The HTTP client reuses connections. If you do not close the body, the connection remains in use. The leak is subtle. It does not panic. It just slows down until the client runs out of connections. Always close the body.
The compiler checks types. The server checks structure. You check the body close.
When to use what
Use http.Post when you have a simple request, no custom headers, and accept the default client behavior.
Use http.NewRequest with a configured http.Client when you need timeouts, custom headers, authentication, or connection pooling control.
Use json.NewEncoder with a bytes.Buffer when you want to avoid the intermediate byte slice allocation, though the buffer still holds the data in memory.
Use http.Get with URL-encoded parameters when the payload is small, the operation is safe to retry, and the API accepts query strings.
Simple requests get shortcuts. Complex requests get clients.