How to Set HTTP Response Headers and Status Codes in Go

Web
Set HTTP status codes with WriteHeader and custom headers with Header.Set before writing the response body in Go.

The HTTP Contract

You build an endpoint to create a user. The client sends a POST request with JSON. Your Go server parses the request, saves the user to the database, and sends back a JSON response. The client receives the body correctly. The client also receives a 200 OK status. The client library checks the status, sees 200, and assumes an update happened instead of a creation. The UI gets confused. The bug isn't in the database logic. The bug is in the HTTP contract. You need to tell the client exactly what happened using status codes and headers.

Go's net/http package gives you precise control over the response. The http.ResponseWriter interface handles three distinct jobs: setting headers, setting the status code, and writing the body. The HTTP protocol enforces a strict order. The server must send the status line and headers before the body begins. Go buffers headers in memory until the first write, then flushes them to the network. Once the body starts flowing, the headers are locked. Any attempt to change a header after that point is ignored.

How ResponseWriter Works

The http.ResponseWriter is the connection between your handler and the client. It exposes three methods. Header() returns a map of headers. WriteHeader(code) sends the status code. Write(data) sends the body bytes.

When your handler starts, the header map is empty. Calling w.Header().Set("Key", "Value") updates the map in memory. Nothing goes to the network yet. You can call Set as many times as you need. The moment you call w.WriteHeader(code), Go sends the status line and all headers to the client. The response is now committed. If you skip WriteHeader and call w.Write, Go assumes you want 200 OK, sends that status automatically, and then writes the body. This implicit behavior saves typing for simple success cases, but it can hide bugs if you forget to set a non-200 status in an error path.

Here's the simplest handler that sets a custom header and a specific status code.

package main

import (
	"net/http"
)

// handleRequest demonstrates setting headers and status before the body.
func handleRequest(w http.ResponseWriter, r *http.Request) {
	// Set custom header. Must happen before WriteHeader or Write.
	w.Header().Set("X-Request-Id", "abc-123")
	// Set Content-Type so the client knows how to parse the body.
	w.Header().Set("Content-Type", "application/json")
	// Write status code. 201 means "Created".
	// This flushes headers to the client.
	w.WriteHeader(http.StatusCreated)
	// Write the response body.
	w.Write([]byte(`{"result": "success"}`))
}

func main() {
	http.HandleFunc("/", handleRequest)
	http.ListenAndServe(":8080", nil)
}

Headers are metadata. Set them before the body or they vanish.

The Header Map and Canonical Keys

The Header type is a map[string][]string. HTTP headers are case-insensitive, but Go normalizes keys to a canonical form. If you call w.Header().Set("content-type", "text/html"), Go stores the key as Content-Type. This means you can check for headers with any casing, and Go finds the right one. However, if you iterate over the map, the keys are canonical. This matters when you write middleware that inspects headers.

The map values are slices because some headers can have multiple values. The Set method replaces any existing value for that key. The Add method appends to the list. Use Set for headers like Content-Type where only one value makes sense. Use Add for headers like Set-Cookie where multiple values are valid.

package main

import (
	"net/http"
)

// handleCookies demonstrates adding multiple headers with the same key.
func handleCookies(w http.ResponseWriter, r *http.Request) {
	// Set overwrites any existing value for Set-Cookie.
	w.Header().Set("Set-Cookie", "session=abc; Path=/")
	// Add appends a second cookie without removing the first.
	w.Header().Add("Set-Cookie", "theme=dark; Path=/")
	// Both cookies are sent in the response.
	w.WriteHeader(http.StatusOK)
	w.Write([]byte("Cookies set"))
}

func main() {
	http.HandleFunc("/cookies", handleCookies)
	http.ListenAndServe(":8080", nil)
}

Use Set to replace. Use Add to append.

Realistic Handler

Real handlers usually marshal JSON and handle errors. Here's a pattern that sets headers, marshals data, and writes the status safely. The Content-Type is set at the top. If the handler encounters an error later, the client still receives the correct content type, which helps debugging tools parse the error response.

package main

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

// User represents a user resource.
type User struct {
	ID   string `json:"id"`
	Name string `json:"name"`
}

// createUser handles POST /users.
func createUser(w http.ResponseWriter, r *http.Request) {
	// Set Content-Type early. If an error occurs later,
	// the client still knows the response is JSON.
	w.Header().Set("Content-Type", "application/json")

	// Simulate creating a user.
	newUser := User{ID: "u-99", Name: "Alice"}

	// Marshal the struct to JSON bytes.
	body, err := json.Marshal(newUser)
	if err != nil {
		// Internal error marshaling. Set 500 and write error message.
		w.WriteHeader(http.StatusInternalServerError)
		w.Write([]byte(`{"error": "server failed to encode response"}`))
		return
	}

	// Success. Set 201 Created.
	w.WriteHeader(http.StatusCreated)
	w.Write(body)
}

func main() {
	http.HandleFunc("/users", createUser)
	http.ListenAndServe(":8080", nil)
}

Status codes are the contract. Use the constants, not magic numbers.

Pitfalls and Compiler Errors

The most common mistake is calling w.Write before setting headers. Once Write runs, the headers flush. You can't add X-Custom-Header after the body starts. The header silently disappears. Another trap is relying on the implicit 200. If your logic has a branch that returns early without calling WriteHeader, the client gets 200 even if the operation failed. You must call WriteHeader in every path where the status isn't 200.

If you try to pass a string to w.Write, the compiler rejects it with cannot use "text" (untyped string constant) as []byte value in argument. You have to convert to []byte or use fmt.Fprint. Also, WriteHeader is idempotent. Calling it twice doesn't panic. The second call is ignored. This is useful for error paths, but it means you can't change the status after the first call.

The ResponseWriter is an interface. It has three methods. Because it's an interface, you can wrap it. This is how logging middleware works. You create a struct that implements ResponseWriter, delegates calls to the real writer, but captures the status code or body length. This is a powerful pattern for observability.

package main

import (
	"net/http"
)

// loggingWriter wraps ResponseWriter to capture the status code.
type loggingWriter struct {
	http.ResponseWriter
	statusCode int
}

// WriteHeader captures the status code before delegating.
func (lw *loggingWriter) WriteHeader(code int) {
	lw.statusCode = code
	lw.ResponseWriter.WriteHeader(code)
}

// handleWithLogging demonstrates wrapping the writer.
func handleWithLogging(w http.ResponseWriter, r *http.Request) {
	// Wrap the writer to capture status.
	lw := &loggingWriter{ResponseWriter: w, statusCode: http.StatusOK}
	// Use the wrapped writer.
	lw.Header().Set("Content-Type", "text/plain")
	lw.WriteHeader(http.StatusNotFound)
	lw.Write([]byte("Not found"))
	// After the handler returns, lw.statusCode holds 404.
	// You can log this in middleware.
}

func main() {
	http.HandleFunc("/log", handleWithLogging)
	http.ListenAndServe(":8080", nil)
}

WriteHeader commits the response. There is no undo.

Decision Matrix

Use w.WriteHeader(http.StatusOK) when you want to be explicit about the success status, even though 200 is the default. Use w.Header().Set("Content-Type", "application/json") at the top of the handler so error responses also declare the format. Use w.Write([]byte(...)) for small static responses or when you already have a byte slice from marshaling. Use fmt.Fprint(w, ...) when you want to write a string directly without manual conversion to bytes. Use http.Error(w, "message", http.StatusBadRequest) for quick error responses that include the status code and a plain-text body in one call. Use a response writer wrapper when you need to capture the status code or body for logging or metrics after the handler returns.

Trust the implicit 200 only when you are sure.

Where to go next