Building an HTTP server in Go

Go’s standard library comes with a production-quality web server. In this lesson you’ll write handlers, route requests by method and path with http.ServeMux, send and receive JSON, use the right status codes, set timeouts, shut the server down cleanly, and test it all without opening a single network port.

Everything here comes from the net/http package. No framework needed.

Your first server

package main

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

func hello(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintln(w, "Hello from Go!")
}

func main() {
	http.HandleFunc("/", hello)
	log.Println("listening on http://localhost:8080")
	log.Fatal(http.ListenAndServe(":8080", nil))
}Code language: Go (go)

Save it as main.go and start it with go run .. It keeps running (that’s what servers do) until you press Ctrl+C. Open http://localhost:8080 in your browser, or use curl in a second terminal:

curl http://localhost:8080/Code language: Bash (bash)
Hello from Go!Code language: plaintext (plaintext)

http.ListenAndServe(":8080", nil) listens on port 8080 on all network interfaces and handles every request in its own goroutine, so many requests can be served at once (you learned about goroutines in stage 5). It only returns if something goes wrong, for example when the port is already in use, which is why it’s wrapped in log.Fatal.

Handlers

A handler function has this signature:

func hello(w http.ResponseWriter, r *http.Request)Code language: Go (go)
  • r is the incoming request: r.Method, r.URL.Path, r.URL.Query() for ?key=value parameters, r.Header, and r.Body for the data the client sent.
  • w is where you write the response. It’s an io.Writer, so fmt.Fprintln(w, …) works. You can also set headers with w.Header().Set(…) and the status code with w.WriteHeader(…).

If you never call WriteHeader, the status is 200 OK. If you don’t set a Content-Type, Go guesses one from the first bytes you write.

Routing with ServeMux

A ServeMux (short for multiplexer, often just called a router) looks at each request and picks the handler whose pattern matches. http.HandleFunc above registered on a global default mux, used when you pass nil to ListenAndServe. I prefer creating my own with http.NewServeMux(): it’s explicit, and no imported package can sneak handlers onto it.

Since Go 1.22, patterns can include an HTTP method and wildcards in braces. You read a wildcard’s value with r.PathValue:

mux := http.NewServeMux()
mux.HandleFunc("GET /items/{id}", func(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintf(w, "item %s\n", r.PathValue("id"))
})Code language: Go (go)

Here’s how a request flows through it:

A request flows through the mux to a handler The client sends GET /items/42. The ServeMux compares it with its patterns and picks GET /items/{id}. The handler reads r.PathValue(“id”), which is 42, and writes the response, 200 OK with item 42, back to the client. request GET /items/42 servemux GET /items/{id} POST /items GET /{$} handler r.PathValue(“id”) → “42” writes to w response 200 OK item 42
The mux only chooses the handler. The handler reads the request and writes the response.

Test handlers with httptest

You don’t need a running server or curl to try a handler. The net/http/httptest package builds fake requests and records responses in memory. A mux has a ServeHTTP method that takes a response writer and a request, so you can call it directly:

package main

import (
	"fmt"
	"net/http"
	"net/http/httptest"
)

func hello(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintf(w, "Hello, %s!\n", r.PathValue("name"))
}

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /hello/{name}", hello)

	req := httptest.NewRequest("GET", "/hello/Ana", nil)
	rec := httptest.NewRecorder()
	mux.ServeHTTP(rec, req)

	fmt.Println(rec.Code)
	fmt.Println(rec.Header().Get("Content-Type"))
	fmt.Print(rec.Body.String())
}Code language: Go (go)
200
text/plain; charset=utf-8
Hello, Ana!Code language: plaintext (plaintext)

httptest.NewRequest takes a method, a URL path and a body (nil for none). httptest.NewRecorder returns a ResponseWriter that remembers the status code, headers and body. That’s what I’ll use for the rest of this lesson, so every example runs and prints the same output on your machine. At the end you’ll see how to put this in a proper test file.

Patterns: methods and wildcards

Let’s throw a few requests at a mux with three patterns and see which ones match. The send helper does the httptest dance and prints the result:

package main

import (
	"fmt"
	"net/http"
	"net/http/httptest"
)

func send(h http.Handler, method, target string) {
	rec := httptest.NewRecorder()
	h.ServeHTTP(rec, httptest.NewRequest(method, target, nil))
	fmt.Printf("%-6s %-20s %d %s", method, target, rec.Code, rec.Body.String())
}

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /items/{id}", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintf(w, "item %s\n", r.PathValue("id"))
	})
	mux.HandleFunc("GET /files/{path...}", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintf(w, "file %s\n", r.PathValue("path"))
	})
	mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintln(w, "home page")
	})

	send(mux, "GET", "/items/42")
	send(mux, "DELETE", "/items/42")
	send(mux, "GET", "/items/1/2")
	send(mux, "GET", "/files/img/logo.png")
	send(mux, "GET", "/")
	send(mux, "GET", "/about")
}Code language: Go (go)
GET    /items/42            200 item 42
DELETE /items/42            405 Method Not Allowed
GET    /items/1/2           404 404 page not found
GET    /files/img/logo.png  200 file img/logo.png
GET    /                    200 home page
GET    /about               404 404 page not foundCode language: plaintext (plaintext)
  • {id} matches exactly one path segment, so /items/1/2 doesn’t match.
  • A method in the pattern restricts it. For a known path with the wrong method, the mux answers 405 Method Not Allowed on its own (and sets an Allow header listing what’s permitted). A GET pattern also matches HEAD requests.
  • {path...} at the end matches the whole rest of the path, slashes included.
  • A pattern ending in / matches everything below it, so plain "/" catches every request. {$} means “the path ends here”: "GET /{$}" matches only the home page.
  • When several patterns match, the most specific one wins, so the order in which you register them doesn’t matter.

The Go blog post on routing enhancements covers the precedence rules in detail.

Return JSON and the right status code

Most HTTP servers in Go are JSON APIs. Returning JSON is three steps: set the Content-Type header, write the status code, and encode the value into w with a json.Encoder (from the JSON lesson). The order matters: headers first, then WriteHeader, then the body. Once the body starts, the status and headers are already sent and can’t change.

package main

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

func writeJSON(w http.ResponseWriter, status int, v any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	json.NewEncoder(w).Encode(v)
}

func health(w http.ResponseWriter, r *http.Request) {
	writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}

func teapot(w http.ResponseWriter, r *http.Request) {
	http.Error(w, "I only make tea", http.StatusTeapot)
}

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /health", health)
	mux.HandleFunc("GET /coffee", teapot)

	for _, path := range []string{"/health", "/coffee"} {
		rec := httptest.NewRecorder()
		mux.ServeHTTP(rec, httptest.NewRequest("GET", path, nil))
		fmt.Println(rec.Code, rec.Header().Get("Content-Type"))
		fmt.Print(rec.Body.String())
	}
}Code language: Go (go)
200 application/json
{"status":"ok"}
418 text/plain; charset=utf-8
I only make teaCode language: plaintext (plaintext)

http.Error is a shortcut for plain-text error responses: it sets the content type, writes the status and the message. Use the named constants instead of bare numbers; http.StatusNotFound reads better than 404. The ones you’ll use most:

CodeConstantWhen
200http.StatusOKit worked (the default)
201http.StatusCreateda POST created something
204http.StatusNoContentit worked, nothing to return (after a DELETE, say)
400http.StatusBadRequestthe client sent something invalid
404http.StatusNotFoundno such thing
405http.StatusMethodNotAllowedwrong method (the mux handles this for you)
500http.StatusInternalServerErrora bug or failure on the server’s side

The rule of thumb: 4xx means “the client did something wrong”, 5xx means “the server did”.

Read a JSON request body

Now a small but complete API: you can create items with POST /items and fetch them with GET /items/{id}. The items live in a map protected by a mutex, because handlers run in parallel goroutines (see WaitGroups and mutexes). The handlers are methods on the store, and s.getItem is a method value: a function with the receiver already filled in, which is exactly what HandleFunc wants.

package main

import (
	"encoding/json"
	"fmt"
	"net/http"
	"net/http/httptest"
	"strconv"
	"strings"
	"sync"
)

type Item struct {
	ID   int    `json:"id"`
	Name string `json:"name"`
}

type store struct {
	mu     sync.Mutex
	items  map[int]Item
	nextID int
}

func newHandler() http.Handler {
	s := &store{items: map[int]Item{}}
	mux := http.NewServeMux()
	mux.HandleFunc("POST /items", s.createItem)
	mux.HandleFunc("GET /items/{id}", s.getItem)
	return mux
}

func (s *store) createItem(w http.ResponseWriter, r *http.Request) {
	var input struct {
		Name string `json:"name"`
	}
	r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // at most 1 MB
	if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
		http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest)
		return
	}
	if input.Name == "" {
		http.Error(w, "name is required", http.StatusBadRequest)
		return
	}

	s.mu.Lock()
	s.nextID++
	item := Item{ID: s.nextID, Name: input.Name}
	s.items[item.ID] = item
	s.mu.Unlock()

	w.Header().Set("Location", fmt.Sprintf("/items/%d", item.ID))
	writeJSON(w, http.StatusCreated, item)
}

func (s *store) getItem(w http.ResponseWriter, r *http.Request) {
	id, err := strconv.Atoi(r.PathValue("id"))
	if err != nil {
		http.Error(w, "id must be a number", http.StatusBadRequest)
		return
	}

	s.mu.Lock()
	item, ok := s.items[id]
	s.mu.Unlock()

	if !ok {
		http.Error(w, "item not found", http.StatusNotFound)
		return
	}
	writeJSON(w, http.StatusOK, item)
}

func writeJSON(w http.ResponseWriter, status int, v any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	json.NewEncoder(w).Encode(v)
}

func send(h http.Handler, method, target, body string) {
	req := httptest.NewRequest(method, target, strings.NewReader(body))
	rec := httptest.NewRecorder()
	h.ServeHTTP(rec, req)
	fmt.Printf("%s %s -> %d %s", method, target, rec.Code, rec.Body.String())
}

func main() {
	h := newHandler()
	send(h, "POST", "/items", `{"name": "coffee"}`)
	send(h, "POST", "/items", `{"name": "tea"}`)
	send(h, "GET", "/items/2", "")
	send(h, "GET", "/items/7", "")
	send(h, "GET", "/items/abc", "")
	send(h, "POST", "/items", `{"name": ""}`)
	send(h, "POST", "/items", `{"name": "milk"`)
}Code language: Go (go)
POST /items -> 201 {"id":1,"name":"coffee"}
POST /items -> 201 {"id":2,"name":"tea"}
GET /items/2 -> 200 {"id":2,"name":"tea"}
GET /items/7 -> 404 item not found
GET /items/abc -> 400 id must be a number
POST /items -> 400 name is required
POST /items -> 400 invalid JSON: unexpected EOFCode language: plaintext (plaintext)

The pattern in createItem is the one I use in every JSON handler:

  1. Limit the body size. http.MaxBytesReader makes reading fail after 1 MB, so nobody can make your server swallow gigabytes.
  2. Decode straight from r.Body into a struct that describes the expected input. A decode error means the client sent bad JSON: answer 400.
  3. Validate what JSON can’t express (required fields, ranges) and answer 400 with a clear message.
  4. Do the work, then respond with the right status: 201 plus a Location header pointing to the new item.

Each return after http.Error matters. Without it, the handler would carry on and write a second response on top of the error.

You don’t need to close r.Body in a handler; the server does that for you. (A client reading a response body is a different story: there you must close it.)

Set timeouts on http.Server

http.ListenAndServe is fine for experiments, but it has no timeouts at all. A client that opens a connection and then sends one byte per minute holds a goroutine and a connection open forever. Enough of those (by accident or on purpose) and your server runs out of resources. For anything that faces the internet, create an http.Server yourself and set timeouts:

srv := &http.Server{
	Addr:              ":8080",
	Handler:           newHandler(),
	ReadHeaderTimeout: 5 * time.Second,
	ReadTimeout:       10 * time.Second,
	WriteTimeout:      10 * time.Second,
	IdleTimeout:       60 * time.Second,
}
log.Fatal(srv.ListenAndServe())Code language: Go (go)
  • ReadHeaderTimeout: how long a client may take to send the request headers.
  • ReadTimeout: how long reading the whole request, body included, may take.
  • WriteTimeout: how long until the response must be written. For plain HTTP the clock restarts each time a new request’s headers have been read, so it also limits how long your handler can work.
  • IdleTimeout: how long to keep an unused keep-alive connection open, waiting for the next request.

The numbers above are reasonable defaults for a small JSON API. If you serve big uploads or downloads, raise ReadTimeout or WriteTimeout accordingly.

Shut down gracefully

When you stop a server with Ctrl+C (or a deployment system stops it with a SIGTERM signal), the process normally dies on the spot, cutting off requests in progress. srv.Shutdown(ctx) does better: it stops accepting new connections, waits for running requests to finish, and gives up when the context expires. signal.NotifyContext gives you a context that is canceled when such a signal arrives.

Here’s the item API as a real server, with timeouts and graceful shutdown. It’s the complete program, so you can copy it and run it:

package main

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"log"
	"net/http"
	"os"
	"os/signal"
	"strconv"
	"sync"
	"syscall"
	"time"
)

type Item struct {
	ID   int    `json:"id"`
	Name string `json:"name"`
}

type store struct {
	mu     sync.Mutex
	items  map[int]Item
	nextID int
}

func newHandler() http.Handler {
	s := &store{items: map[int]Item{}}
	mux := http.NewServeMux()
	mux.HandleFunc("POST /items", s.createItem)
	mux.HandleFunc("GET /items/{id}", s.getItem)
	return mux
}

func (s *store) createItem(w http.ResponseWriter, r *http.Request) {
	var input struct {
		Name string `json:"name"`
	}
	r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
	if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
		http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest)
		return
	}
	if input.Name == "" {
		http.Error(w, "name is required", http.StatusBadRequest)
		return
	}

	s.mu.Lock()
	s.nextID++
	item := Item{ID: s.nextID, Name: input.Name}
	s.items[item.ID] = item
	s.mu.Unlock()

	w.Header().Set("Location", fmt.Sprintf("/items/%d", item.ID))
	writeJSON(w, http.StatusCreated, item)
}

func (s *store) getItem(w http.ResponseWriter, r *http.Request) {
	id, err := strconv.Atoi(r.PathValue("id"))
	if err != nil {
		http.Error(w, "id must be a number", http.StatusBadRequest)
		return
	}

	s.mu.Lock()
	item, ok := s.items[id]
	s.mu.Unlock()

	if !ok {
		http.Error(w, "item not found", http.StatusNotFound)
		return
	}
	writeJSON(w, http.StatusOK, item)
}

func writeJSON(w http.ResponseWriter, status int, v any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	json.NewEncoder(w).Encode(v)
}

func main() {
	srv := &http.Server{
		Addr:              ":8080",
		Handler:           newHandler(),
		ReadHeaderTimeout: 5 * time.Second,
		ReadTimeout:       10 * time.Second,
		WriteTimeout:      10 * time.Second,
		IdleTimeout:       60 * time.Second,
	}

	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()

	go func() {
		log.Println("listening on", srv.Addr)
		err := srv.ListenAndServe()
		if err != nil && !errors.Is(err, http.ErrServerClosed) {
			log.Fatal(err)
		}
	}()

	<-ctx.Done()
	log.Println("shutting down")

	shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()
	if err := srv.Shutdown(shutdownCtx); err != nil {
		log.Println("shutdown:", err)
	}
	log.Println("bye")
}Code language: Go (go)

ListenAndServe now runs in its own goroutine, because main has to wait for the signal. When Shutdown is called, ListenAndServe returns http.ErrServerClosed, which is expected and not a real error. Start it with go run . and talk to it from another terminal (-i makes curl show the status line and headers):

curl -i -X POST -d '{"name": "coffee"}' http://localhost:8080/itemsCode language: Bash (bash)
HTTP/1.1 201 Created
Content-Type: application/json
Location: /items/1
Date: Sun, 08 Nov 2026 20:37:02 GMT
Content-Length: 25

{"id":1,"name":"coffee"}Code language: plaintext (plaintext)
curl http://localhost:8080/items/1
curl -X DELETE http://localhost:8080/items/1Code language: Bash (bash)
{"id":1,"name":"coffee"}
Method Not AllowedCode language: plaintext (plaintext)

(Your Date header will show the current time, of course.) Press Ctrl+C in the server’s terminal and you’ll see it log shutting down and bye instead of just dying.

Write real tests

The httptest calls you’ve been using in main belong in a test file, as you learned in the testing lesson. Put this next to the server as main_test.go:

package main

import (
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
)

func TestCreateAndGetItem(t *testing.T) {
	h := newHandler()

	req := httptest.NewRequest("POST", "/items", strings.NewReader(`{"name": "tea"}`))
	rec := httptest.NewRecorder()
	h.ServeHTTP(rec, req)
	if rec.Code != http.StatusCreated {
		t.Fatalf("POST status = %d, want %d", rec.Code, http.StatusCreated)
	}

	req = httptest.NewRequest("GET", "/items/1", nil)
	rec = httptest.NewRecorder()
	h.ServeHTTP(rec, req)
	want := `{"id":1,"name":"tea"}` + "\n"
	if rec.Code != http.StatusOK || rec.Body.String() != want {
		t.Errorf("GET = %d %q, want 200 %q", rec.Code, rec.Body.String(), want)
	}
}

func TestBadRequests(t *testing.T) {
	tests := []struct {
		name, method, target, body string
		want                       int
	}{
		{"missing name", "POST", "/items", `{}`, http.StatusBadRequest},
		{"broken JSON", "POST", "/items", `{"name":`, http.StatusBadRequest},
		{"bad id", "GET", "/items/abc", "", http.StatusBadRequest},
		{"unknown id", "GET", "/items/99", "", http.StatusNotFound},
		{"wrong method", "PUT", "/items/1", "", http.StatusMethodNotAllowed},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			req := httptest.NewRequest(tt.method, tt.target, strings.NewReader(tt.body))
			rec := httptest.NewRecorder()
			newHandler().ServeHTTP(rec, req)
			if rec.Code != tt.want {
				t.Errorf("status = %d, want %d", rec.Code, tt.want)
			}
		})
	}
}Code language: Go (go)
go test -vCode language: Bash (bash)
=== RUN   TestCreateAndGetItem
--- PASS: TestCreateAndGetItem (0.00s)
=== RUN   TestBadRequests
=== RUN   TestBadRequests/missing_name
=== RUN   TestBadRequests/broken_JSON
=== RUN   TestBadRequests/bad_id
=== RUN   TestBadRequests/unknown_id
=== RUN   TestBadRequests/wrong_method
--- PASS: TestBadRequests (0.00s)
    --- PASS: TestBadRequests/missing_name (0.00s)
    --- PASS: TestBadRequests/broken_JSON (0.00s)
    --- PASS: TestBadRequests/bad_id (0.00s)
    --- PASS: TestBadRequests/unknown_id (0.00s)
    --- PASS: TestBadRequests/wrong_method (0.00s)
PASS
ok  	example	0.328sCode language: plaintext (plaintext)

Because newHandler returns an http.Handler and doesn’t start a server, tests can create a fresh one for every case, with no ports, no waiting and no leftover state. That’s a good reason to keep building the mux in its own function, separate from main.

Practice

1. A greeting endpoint with a query parameter. Write a handler for GET /greet/{name} that responds with Hello, Ana!, or HELLO, ANA! when the URL has ?loud=true (read it with r.URL.Query().Get("loud")). Try it with httptest for /greet/Ana, /greet/Ana?loud=true and a POST to /greet/Ana.

Show a solution
package main

import (
	"fmt"
	"net/http"
	"net/http/httptest"
	"strings"
)

func greet(w http.ResponseWriter, r *http.Request) {
	msg := fmt.Sprintf("Hello, %s!", r.PathValue("name"))
	if r.URL.Query().Get("loud") == "true" {
		msg = strings.ToUpper(msg)
	}
	fmt.Fprintln(w, msg)
}

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /greet/{name}", greet)

	for _, tc := range []struct{ method, target string }{
		{"GET", "/greet/Ana"},
		{"GET", "/greet/Ana?loud=true"},
		{"POST", "/greet/Ana"},
	} {
		rec := httptest.NewRecorder()
		mux.ServeHTTP(rec, httptest.NewRequest(tc.method, tc.target, nil))
		fmt.Printf("%s %s: %d %s", tc.method, tc.target, rec.Code, rec.Body.String())
	}
}Code language: Go (go)
GET /greet/Ana: 200 Hello, Ana!
GET /greet/Ana?loud=true: 200 HELLO, ANA!
POST /greet/Ana: 405 Method Not AllowedCode language: plaintext (plaintext)

2. A sum endpoint. Write POST /sum that reads {"numbers": [1, 2, 3]} and responds with JSON {"sum":6}. Respond with 400 if the JSON is broken or the list is empty. Test it with a good body, an empty list and broken JSON.

Show a solution
package main

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

func sum(w http.ResponseWriter, r *http.Request) {
	var input struct {
		Numbers []int `json:"numbers"`
	}
	if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
		http.Error(w, "invalid JSON", http.StatusBadRequest)
		return
	}
	if len(input.Numbers) == 0 {
		http.Error(w, "numbers must not be empty", http.StatusBadRequest)
		return
	}
	total := 0
	for _, n := range input.Numbers {
		total += n
	}
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(map[string]int{"sum": total})
}

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("POST /sum", sum)

	for _, body := range []string{`{"numbers": [1, 2, 3]}`, `{"numbers": []}`, `{"numbers": [1, 2`} {
		rec := httptest.NewRecorder()
		mux.ServeHTTP(rec, httptest.NewRequest("POST", "/sum", strings.NewReader(body)))
		fmt.Printf("%d %s", rec.Code, rec.Body.String())
	}
}Code language: Go (go)
200 {"sum":6}
400 numbers must not be empty
400 invalid JSONCode language: plaintext (plaintext)

Key takeaways

  • A handler is a func(w http.ResponseWriter, r *http.Request); a ServeMux picks the handler for each request.
  • Patterns like "GET /items/{id}" match method and path; read wildcards with r.PathValue. The mux answers 404 and 405 for you.
  • For JSON: set Content-Type, call WriteHeader, then encode. Read bodies with a size limit and a json.Decoder, and answer bad input with 400.
  • Use an http.Server with timeouts instead of bare ListenAndServe, and Shutdown to finish running requests before exiting.
  • Test handlers in memory with httptest.NewRequest and httptest.NewRecorder.