A server in a few lines
A handler is a function that receives the request and writes the response. ServeMux routes requests to handlers.
The editor cannot accept connections from your browser, so the examples on this page start the server with httptest.NewServer and call it from the same program. In a real program the last part is replaced by one line that blocks and serves forever:
log.Fatal(http.ListenAndServe(":8080", mux))
Then curl localhost:8080/hello/gopher prints Hello, gopher!. ListenAndServe only returns on an error (the port is taken, for example), which is why it is wrapped in log.Fatal.
Each request runs in its own goroutine. Anything your handlers share, such as a map or a counter, needs a mutex.
Handlers
Anything with a ServeHTTP(http.ResponseWriter, *http.Request) method is an http.Handler. http.HandlerFunc adapts a plain function to that interface, and mux.HandleFunc does the conversion for you. A struct handler is handy when handlers need dependencies:
type API struct {
db *sql.DB
}
func (a *API) listItems(w http.ResponseWriter, r *http.Request) { /* uses a.db */ }
mux.HandleFunc("GET /items", api.listItems)
The *http.Request gives you:
| Field or method | Contains |
|---|---|
r.Method | GET, POST, ... |
r.URL.Path | the path, /items/42 |
r.PathValue("id") | a wildcard from the route pattern (Go 1.22) |
r.URL.Query().Get("q") | a query string parameter |
r.Header.Get("Authorization") | a request header |
r.Body | the request body, an io.ReadCloser (the server closes it) |
r.FormValue("name") | a form field or query parameter |
r.Context() | a context cancelled when the client disconnects |
Routing patterns (Go 1.22)
Since Go 1.22, ServeMux patterns have the form [METHOD ][HOST]/[PATH], and paths can contain wildcards.
| Pattern | Matches |
|---|---|
"/items/" | /items/ and everything under it (trailing slash = prefix) |
"/items" | only /items |
"GET /items/{id}" | GET (and HEAD) on /items/42; r.PathValue("id") == "42" |
"POST /items" | only POST on /items |
"/files/{path...}" | /files/a/b/c; path is "a/b/c" |
"/{$}" | only /, not every path |
"/" | every path that no other pattern matches |
When two patterns match, the more specific one wins, so /items/new beats /items/{id}. If neither is more specific, for example /items/{id} and /{kind}/new (both match /items/new), registering the second one panics with a message naming both patterns. If a path matches but the method does not, the mux answers 405 Method Not Allowed with an Allow header, without any code from you.
The "/" pattern is the catch-all. That surprises people who register a home page with "/" and find it answering every unknown URL with 200. Use "GET /{$}" for the home page.
These patterns need go 1.22 or later in go.mod. With an older version line, the mux falls back to the old behavior: it reads "GET /items" as a host name followed by a path, so the route never matches and every request to /items gets a 404.
A small JSON API
The last request uses DELETE, which no route accepts, and the mux answers 405 with Allow: GET, HEAD on its own: those are the methods registered for that path (a GET route also accepts HEAD).
Status codes and the order of writes
A response has three parts, and they must be written in order: headers, status, body.
w.Header().Set(...)changes headers. It has an effect only until the status is sent.w.WriteHeader(code)sends the status line and the headers.w.Write(...)(orfmt.Fprint(w, ...), or an encoder) sends the body. IfWriteHeaderhas not been called, the firstWritesends200 OKfirst.
Consequences:
- Setting a header after the body has started does nothing.
- Calling
WriteHeadertwice logshttp: superfluous response.WriteHeader calland keeps the first status. - After an error response,
return.http.Errordoes not stop your handler, and code after it keeps writing into the same response.
Use the named constants (http.StatusOK, http.StatusCreated, http.StatusBadRequest, http.StatusUnauthorized, http.StatusNotFound, http.StatusInternalServerError) rather than bare numbers. http.StatusText(404) returns "Not Found".
Middleware
Middleware is a function that takes a handler and returns a handler, doing something before or after calling the next one:
The log: line always appears before client got: for the same request. The handler goroutine prints it before it returns, and the server does not finish the response until the handler returns.
The statusRecorder embeds the real ResponseWriter and overrides only WriteHeader, which is the standard way to observe the status code from middleware.
Production settings
http.ListenAndServe uses a server with no timeouts, so a slow client can hold a connection open indefinitely. Configure an http.Server explicitly for anything exposed to the internet, and shut it down gracefully:
srv := &http.Server{
Addr: ":8080",
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 10 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 120 * time.Second,
}
go func() {
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatal(err)
}
}()
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
<-ctx.Done() // wait for Ctrl+C or a SIGTERM from the orchestrator
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil { // finish in-flight requests
log.Println("shutdown:", err)
}
Shutdown stops accepting new connections and waits for active requests to finish, up to the context's deadline. ListenAndServe returns http.ErrServerClosed as soon as Shutdown starts, which is why that error is not treated as a failure. For long-running handlers, pass r.Context() down so they stop when the client goes away.
Serving static files is one line: mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.Dir("public")))).
Common mistakes
- Not returning after
http.Error. The handler keeps running and writes more into the response. - Setting headers after writing the body. They are silently dropped.
- Registering the home page on
"/". It becomes the catch-all for every unknown path. Use"/{$}". - Sharing state between handlers without a lock. Requests run concurrently.
- Using the default server in production. It has no timeouts; set them on an
http.Server. - Routing patterns ignored. If
"GET /x/{id}"never matches, check thatgo.modsaysgo 1.22or later.
Frequently Asked Questions
How do I create a simple web server in Go?
Register a handler and start listening: http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "hello") }) then log.Fatal(http.ListenAndServe(":8080", nil)). The standard library server is production-grade; it handles HTTP/1.1, HTTP/2 over TLS, keep-alive and one goroutine per connection.
How do I get a path parameter in Go net/http?
Since Go 1.22, ServeMux patterns can contain wildcards: register mux.HandleFunc("GET /items/{id}", h) and read the value inside the handler with r.PathValue("id"). A trailing {path...} matches the rest of the path. Before 1.22 you had to split r.URL.Path yourself or use a router such as chi.
Do I need a framework like Gin to build a REST API in Go?
No. Since Go 1.22, net/http routes by method and path parameters, which covered the main reason people used routers. With encoding/json for bodies and small middleware functions for logging and auth, the standard library is enough for most APIs. Frameworks add conveniences such as request binding and validation.
How do I set the status code in a Go HTTP handler?
Call w.WriteHeader(http.StatusCreated) before writing the body. If you write the body first, Go sends 200 OK automatically and a later WriteHeader is ignored with a superfluous response.WriteHeader call log message. Set headers with w.Header().Set before WriteHeader too; for errors, http.Error(w, msg, code) does all of it.