Returning JSON from a Go HTTP Server
You are building an API. Your frontend sends a request to /api/users. The browser receives the response, but the network tab shows a blob of text with the wrong content type, or the JSON is malformed because a field name doesn't match the contract. The client crashes. The fix is straightforward: set the Content-Type header to application/json and encode your data using the encoding/json package. Go makes this reliable, but the details matter.
Think of JSON as a standardized shipping label and packing slip. Your Go program holds data in memory as structs. The client does not speak Go structs. It speaks JSON. You have to translate the memory layout into a text format and tell the client, "This is JSON, not HTML, not plain text." The header is the label. The encoder is the packing process.
The minimal working example
Here is the bare minimum to return JSON: set the header, encode the struct, and handle the error.
package main
import (
"encoding/json"
"net/http"
)
// User represents a user in the system.
type User struct {
Name string `json:"name"`
Age int `json:"age"`
}
// handler returns a JSON response.
func handler(w http.ResponseWriter, r *http.Request) {
// Set Content-Type before writing the body so the client knows how to parse it.
w.Header().Set("Content-Type", "application/json")
// Create a user instance to return.
user := User{Name: "Alice", Age: 30}
// Encode writes the JSON to the ResponseWriter and returns an error if encoding fails.
if err := json.NewEncoder(w).Encode(user); err != nil {
// Handle encoding errors. For simple types, this is rare.
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
}
func main() {
http.HandleFunc("/api/user", handler)
http.ListenAndServe(":8080", nil)
}
The struct tag `json:"name"` tells the encoder which key to use in the JSON output. Without it, the encoder uses the field name Name, which might not match your API contract. The json.NewEncoder(w) creates an encoder that writes directly to the response writer. This streams the output, which is efficient for large payloads. Encode appends a newline character at the end. This is useful for log files but sometimes unwanted in APIs. If you need to suppress the newline, use json.Marshal and w.Write instead.
Headers first. Body second. Always.
How encoding works under the hood
When you call json.NewEncoder(w).Encode(user), the encoder walks the struct fields. It checks the struct tags. It converts each field to its JSON representation. Strings become quoted strings. Integers become numbers. Slices become arrays. Maps become objects. The encoder writes the bytes directly to the http.ResponseWriter.
The http.ResponseWriter interface has three methods: Header, Write, and WriteHeader. You must call Header before Write or WriteHeader. Once you write the body, the headers flush automatically. Calling w.Header().Set after w.Write does nothing. The runtime might warn with http: superfluous response.WriteHeader call if you try to set the status code late. The compiler does not catch this mistake. You have to enforce the order in your code.
The if err != nil check is verbose by design. The Go community accepts the boilerplate because it makes the unhappy path visible. Encoding can fail if the value contains unsupported types, like a map[interface{}]interface{} or a channel. The encoder rejects a map with non-string keys with json: unsupported value: map[interface {}]interface {}. JSON keys must be strings. If you unmarshal into a generic map, you get map[string]interface{}, which encodes fine. If you accidentally create a map[interface{}]interface{}, the encoder panics or returns an error.
Trust the error check. It catches the edge cases that break clients.
Realistic API handler
Real APIs return lists, handle errors, and set status codes explicitly. Here is a handler that returns a slice of users and uses omitempty to skip zero values.
// getUsers returns a list of users as JSON.
func getUsers(w http.ResponseWriter, r *http.Request) {
// Set status code early so the client receives the correct HTTP status.
w.WriteHeader(http.StatusOK)
// Set header before writing the body.
w.Header().Set("Content-Type", "application/json")
// Prepare the response data.
users := []User{
{Name: "Alice", Age: 30},
{Name: "Bob", Age: 25},
}
// Encode the slice. Slices encode to JSON arrays automatically.
if err := json.NewEncoder(w).Encode(users); err != nil {
// If encoding fails, write a 500 error.
// In production, log the error here.
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
}
}
The omitempty option in struct tags changes behavior. If you add `json:"age,omitempty"` to the Age field, the encoder skips the field if the value is zero. For strings, zero is empty. For numbers, zero is 0. For pointers, zero is nil. This is useful for optional fields. If a field is required, omit omitempty.
Nil slices encode to null, not []. A nil map encodes to null. This breaks clients expecting an empty array. Initialize slices with make([]User, 0) if you need [] in the JSON output. The distinction matters. A client might crash if it tries to iterate over null.
The time.Time type implements MarshalJSON. It encodes as an RFC3339 string, like "2024-01-15T10:30:00Z". This is the standard for APIs. You don't need custom logic for timestamps. The encoder handles it.
Set headers before the body. Initialize nil slices if the client expects arrays.
Pitfalls and compiler errors
The compiler helps with types, but JSON encoding has runtime traps.
Unexported fields vanish from JSON. If you have a field secret string, it does not appear in the output. The compiler does not warn. You just get missing data. Export the field with a capital letter if you want it in the JSON. The convention is clear: public names start with a capital letter. Private start lowercase. JSON encoding respects this boundary.
The compiler rejects programs with undefined variables or type mismatches. If you pass the wrong type to a function, you get cannot use x (type int) as string in argument. If you forget to import a package, you get undefined: pkg. If you import a package and don't use it, you get imported and not used. These errors stop you before runtime. JSON errors happen at runtime. You have to test the output.
Goroutine leaks happen when a goroutine waits on a channel that never gets closed. This does not apply to JSON encoding, but it applies to the handlers that call it. If you spawn a goroutine to fetch data and it blocks, the server hangs. Always have a cancellation path. Use context.Context as the first parameter in functions that do work. Handlers receive the request, which carries the context. Check r.Context().Err() before heavy work.
The worst goroutine bug is the one that never logs.
Decision matrix
Use json.NewEncoder(w).Encode when you want to stream JSON directly to the response writer for memory efficiency. Use json.Marshal followed by w.Write when you need to control the exact bytes, such as suppressing the trailing newline or wrapping the JSON in a custom envelope. Use json.MarshalIndent during development to produce human-readable JSON with indentation, then switch back to Marshal or Encode for production. Use http.Error for simple text error responses when you don't need structured JSON errors. Use a custom Response struct with a Status and Data field when your API contract requires a consistent envelope around all payloads.
Stream when you can. Marshal when you must control the bytes.