This tutorial is for developers who want a working Go HTTP server with clear next steps: handlers, routing with net/http, practical http.Server settings, JSON and static responses, and shutdown without turning into a full deployment guide. If you are comparing stacks, see Go web frameworks after you are comfortable with the standard library.
Go 1.24 on Linux (examples use Go 1.22+
ServeMuxpatterns).
Quick answer: how do you run a Go web server?
Create a *http.ServeMux, register handlers on it, pass that mux to http.ListenAndServe (or to an http.Server as Handler), and block on the listen call. The mux picks the handler; the handler reads *http.Request and writes to http.ResponseWriter.
Minimal shape (run with go run .—it listens until you stop it):
package main
import (
"log"
"net/http"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("Hello"))
})
log.Fatal(http.ListenAndServe(":8080", mux))
}The next section spells out vocabulary; after that you get a fuller walkthrough, tests with httptest, and server configuration.
Terms: web server, HTTP server, and net/http
Web server versus HTTP server
People often say “web server” for anything that speaks HTTP—HTML pages, assets, JSON APIs, or internal tools. In Go you usually implement that as an HTTP server: a listener, the net/http stack, and your handlers (functions or types with ServeHTTP) that contain your application logic.
Why net/http is enough for many programs
For JSON APIs, internal tools, and many public sites, the standard library already gives you routing (since Go 1.22, method-aware patterns and path variables), timeouts, TLS, HTTP/2 where applicable, and reverse-proxy-friendly behavior. You can add a framework later; starting with net/http keeps dependencies small and examples portable.
Create a simple Go web server
Project setup
Install Go if needed (getting started with Go), then create a module (custom Go modules covers naming in more depth):
mkdir my-webserver
cd my-webserver
go mod init example.com/my-webserverAdd main.go as shown below.
A minimal main.go with ListenAndServe
This is the shape you run locally with go run . (it listens until you stop the process):
package main
import (
"log"
"net/http"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
_, _ = w.Write([]byte("Hello from Go"))
})
log.Fatal(http.ListenAndServe(":8080", mux))
}The pattern GET /{$} matches only GET at the root path (the {$} suffix means “end of path” in Go 1.22+ routing).
Run and probe
With the server running, open http://localhost:8080/ or use curl:
curl -i http://127.0.0.1:8080/You should see HTTP/1.1 200 OK and the plain-text body.
Runnable check with httptest
The same handler logic can be exercised without binding a real port (this version is suitable for the in-page Run control):
package main
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
_, _ = w.Write([]byte("Hello from Go"))
})
ts := httptest.NewServer(mux)
defer ts.Close()
resp, err := http.Get(ts.URL + "/")
if err != nil {
panic(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
fmt.Println(resp.Status, string(body))
}After Run, expect a line starting with 200 and the hello text.
Understanding handlers in Go
Handlers sit at the center of net/http: every request is delivered to some http.Handler, which reads the *http.Request and writes through http.ResponseWriter. Below is the usual syntax for each piece, then one small program that wires them together.
http.ResponseWriter
ResponseWriter sends the response status, headers, and body. Set headers before the first Write or WriteHeader; after the body starts, header changes may be ignored or trigger a panic depending on the concrete type.
Common calls:
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusCreated) // optional; omit for 200
fmt.Fprint(w, "body")
// or: _, err := w.Write([]byte("body"))*http.Request
r carries the method, URL, protocol version, headers, and optional body. Use r.Context() for deadlines and client disconnects when you call other APIs that accept a context.Context.
Fields and accessors you reach for often:
r.Method
r.URL.Path
r.URL.RawQuery
r.URL.Query().Get("q")
r.Header.Get("Accept")
r.RemoteAddr
ctx := r.Context()
defer r.Body.Close()
// body, err := io.ReadAll(r.Body)http.Handler and http.HandlerFunc
An http.Handler is any value with ServeHTTP(http.ResponseWriter, *http.Request). http.HandlerFunc is a function type with the same signature, so plain functions can satisfy http.Handler without a wrapper struct:
type Counter struct{ n int }
func (c *Counter) ServeHTTP(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "%d", c.n)
}
var h http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, "ok")
})http.HandleFunc versus http.Handle
http.HandleFunc("/path", fn)registersfnonDefaultServeMux(the global mux used when you passniltoListenAndServe).mux.HandleFunc("GET /path", fn)registers on yourmux.mux.Handle("GET /path", handler)registers an existinghttp.Handler(struct,http.FileServer,http.HandlerFunc(...), etc.).
http.HandleFunc("/legacy", func(w http.ResponseWriter, r *http.Request) {})
mux := http.NewServeMux()
mux.Handle("GET /static/", http.FileServer(http.Dir("static")))
mux.HandleFunc("GET /api/", func(w http.ResponseWriter, r *http.Request) {})This tutorial keeps a local mux := http.NewServeMux() so routes stay explicit and easy to test.
Example: Handler, HandlerFunc, headers, and query strings
The server uses mux.Handle with a small struct type, mux.HandleFunc for a second route, and a http.HandlerFunc value on a third route. The client issues two requests so you can see status and body output when you click Run:
package main
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
)
type greet struct{}
func (greet) ServeHTTP(w http.ResponseWriter, r *http.Request) {
name := r.URL.Query().Get("name")
if name == "" {
name = "world"
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
fmt.Fprintf(w, "hello %s (%s)", name, r.Method)
}
func main() {
mux := http.NewServeMux()
mux.Handle("GET /greet", greet{})
mux.HandleFunc("GET /echo", func(w http.ResponseWriter, r *http.Request) {
msg := r.Header.Get("X-Msg")
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, msg)
})
var ping http.HandlerFunc = func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
mux.Handle("GET /ping", ping)
ts := httptest.NewServer(mux)
defer ts.Close()
resp1, _ := http.Get(ts.URL + "/greet?name=Ada")
b1, _ := io.ReadAll(resp1.Body)
_ = resp1.Body.Close()
fmt.Println("greet:", resp1.Status, string(b1))
req2, _ := http.NewRequest(http.MethodGet, ts.URL+"/echo", nil)
req2.Header.Set("X-Msg", "hi")
resp2, _ := http.DefaultClient.Do(req2)
b2, _ := io.ReadAll(resp2.Body)
_ = resp2.Body.Close()
fmt.Println("echo:", resp2.Status, string(b2))
resp3, _ := http.Get(ts.URL + "/ping")
_ = resp3.Body.Close()
fmt.Println("ping:", resp3.Status)
}After Run you should see greet: with 200 OK and hello Ada (GET), echo: with 200 OK and hi, and ping: with 204 No Content (no response body).
Configure routes with ServeMux
Why a custom mux helps
A dedicated *http.ServeMux makes tests easy (pass the mux to httptest.NewServer), avoids hidden globals, and scales to multiple Handle registrations without import side effects.
Register multiple routes
package main
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /api/health", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, "ok")
})
mux.HandleFunc("GET /hello", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, "hello")
})
ts := httptest.NewServer(mux)
defer ts.Close()
for _, path := range []string{"/api/health", "/hello"} {
resp, _ := http.Get(ts.URL + path)
b, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
fmt.Println(path, string(b))
}
}Each path prints its short body on its own line.
Go 1.22 method-based routes and Request.PathValue
Method-specific patterns look like "GET /users/{id}". Inside the handler, r.PathValue("id") returns the captured segment.
package main
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /users/{id}", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "id=%s", r.PathValue("id"))
})
ts := httptest.NewServer(mux)
defer ts.Close()
resp, _ := http.Get(ts.URL + "/users/42")
b, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
fmt.Print(string(b))
}After Run, the program prints id=42.
Configure the Go HTTP server
After a handler works, prefer an http.Server so timeouts and shutdown live in one struct.
Address and port
Addr is a host:port string. :8080 listens on all interfaces on port 8080; 127.0.0.1:8080 restricts to loopback for local development.
ReadHeaderTimeout, ReadTimeout, WriteTimeout, IdleTimeout
- ReadHeaderTimeout caps how long the server waits for request headers (mitigates slowloris-style header stalls).
- ReadTimeout bounds reading the entire request (including body) from the connection.
- WriteTimeout bounds writing the response.
- IdleTimeout controls keep-alive idle time on the server side.
Tune values to your handlers: long uploads need a higher read budget; streaming responses need careful write limits.
Start the configured server
package main
import (
"log"
"net/http"
"time"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("ok"))
})
srv := &http.Server{
Addr: ":8080",
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 60 * time.Second,
}
log.Fatal(srv.ListenAndServe())
}This is the production-minded baseline the brief targets: explicit mux, explicit server, and timeouts set on purpose instead of left at zero forever.
Return different response types
The only real differences are the Content-Type header, how you build the body (bytes, fmt, html/template, or encoding/json), and when you call WriteHeader (optional for 200; set it before the body when you need another status). User-supplied strings in HTML belong in html/template so they are escaped; JSON fits json.Encoder or json.Marshal. For decoding request JSON, see JSON unmarshaling in Go.
This server exposes four GET routes in one httptest run: plain text, a tiny HTML page, JSON, and a non-200 response with an extra header:
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
)
type msg struct {
Status string `json:"status"`
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /plain", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
fmt.Fprint(w, "just text")
})
mux.HandleFunc("GET /page", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprint(w, "<!doctype html><title>demo</title><p>hello</p>")
})
mux.HandleFunc("GET /api/ping", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(msg{Status: "ok"})
})
mux.HandleFunc("GET /busy", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("Retry-After", "5")
w.WriteHeader(http.StatusServiceUnavailable)
fmt.Fprint(w, "try later")
})
ts := httptest.NewServer(mux)
defer ts.Close()
for _, path := range []string{"/plain", "/page", "/api/ping", "/busy"} {
resp, _ := http.Get(ts.URL + path)
b, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
fmt.Println(path, resp.Status, string(b))
}
}After Run you should see four lines: plain text for /plain, HTML for /page, JSON for /api/ping (the encoder may add a trailing newline inside the body), and 503 Service Unavailable with try later for /busy (that response also includes a Retry-After header).
Serve static files
Create a directory such as static/ next to main.go, place assets inside, then mount a file server. This example uses StripPrefix so URLs look like /static/...:
package main
import (
"log"
"net/http"
)
func main() {
mux := http.NewServeMux()
fs := http.FileServer(http.Dir("static"))
mux.Handle("/static/", http.StripPrefix("/static/", fs))
log.Fatal(http.ListenAndServe(":8080", mux))
}A request for GET /static/app.css maps to the file static/app.css on disk.
Read request data
Query strings live on r.URL (Query().Get for a single value, or Query()["key"] when a key repeats). HTML forms posted as application/x-www-form-urlencoded use r.ParseForm() (or ParseMultipartForm for uploads), then read r.PostForm / r.Form. JSON APIs read the raw body with json.NewDecoder(r.Body).Decode. For large or untrusted bodies, cap reads with http.MaxBytesReader or io.LimitReader so memory stays bounded. For client-side posting patterns, see POST JSON requests in Go.
One test server and three requests show all three paths:
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
)
type createUser struct {
Name string `json:"name"`
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /hello", func(w http.ResponseWriter, r *http.Request) {
name := r.URL.Query().Get("name")
if name == "" {
name = "world"
}
fmt.Fprintf(w, "query name=%s", name)
})
mux.HandleFunc("POST /login", func(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad form", http.StatusBadRequest)
return
}
user := r.PostForm.Get("user")
fmt.Fprintf(w, "form user=%s", user)
})
mux.HandleFunc("POST /users", func(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
var u createUser
if err := json.NewDecoder(r.Body).Decode(&u); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
fmt.Fprintf(w, "json hello %s", u.Name)
})
ts := httptest.NewServer(mux)
defer ts.Close()
resp1, _ := http.Get(ts.URL + "/hello?name=Ada")
b1, _ := io.ReadAll(resp1.Body)
_ = resp1.Body.Close()
fmt.Println("hello:", string(b1))
form := strings.NewReader("user=bob&pass=secret")
resp2, _ := http.Post(ts.URL+"/login", "application/x-www-form-urlencoded", form)
b2, _ := io.ReadAll(resp2.Body)
_ = resp2.Body.Close()
fmt.Println("login:", string(b2))
resp3, _ := http.Post(ts.URL+"/users", "application/json", strings.NewReader(`{"name":"Cleo"}`))
b3, _ := io.ReadAll(resp3.Body)
_ = resp3.Body.Close()
fmt.Println("users:", string(b3))
}After Run you should see three lines: hello: query name=Ada, login: form user=bob, and users: json hello Cleo.
Add simple middleware
Middleware is just a function that wraps an http.Handler (or HandlerFunc) and runs code before or after the inner handler.
Logging and panic recovery
package main
import (
"log"
"net/http"
"time"
)
func withLogging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start))
})
}
func withRecover(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
log.Println("panic:", err)
http.Error(w, "internal error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("ok"))
})
srv := &http.Server{
Addr: ":8080",
Handler: withRecover(withLogging(mux)),
ReadHeaderTimeout: 5 * time.Second,
}
log.Fatal(srv.ListenAndServe())
}Attach the chain to http.Server.Handler (as above) or wrap a single sub-handler—either way the outer wrapper runs first on the way in and last on the way out.
Graceful shutdown
Listen in a goroutine, wait for SIGINT or SIGTERM, then call Shutdown with a deadline so active requests finish:
package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("ok"))
})
srv := &http.Server{
Addr: ":8080",
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
}
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatal(err)
}
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Printf("shutdown: %v", err)
}
}Shutdown stops accepting new connections and waits for handlers tied to r.Context() to unwind; choose the timeout to match your longest legitimate request.
HTTPS and TLS
For local experiments, point ListenAndServeTLS (or srv.ListenAndServeTLS) at a certificate and key file:
package main
import (
"log"
"net/http"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("hello tls"))
})
log.Fatal(http.ListenAndServeTLS(":8443", "cert.pem", "key.pem", mux))
}In production, many teams terminate TLS at a load balancer or reverse proxy, or use managed certificates, instead of binding raw cert files in the app. Keeping that as an infrastructure choice avoids duplicating long proxy tutorials here.
Best practices for Go web servers
Use a custom ServeMux (or another router you can inject) instead of scattering http.Handle calls on the default mux. Set timeouts on http.Server, keep handlers small (parse, validate, delegate), avoid unsynchronized shared maps for per-request data, and carry cancellation with r.Context(). Log ListenAndServe and Shutdown errors distinctly so operators can tell a bind failure from a clean shutdown.
Mistakes to avoid
Registering everything on DefaultServeMux via nil makes tests and reuse harder. Skipping server timeouts invites hung connections. Ignoring errors from ListenAndServe hides bind failures. Writing the body before WriteHeader can lock the status to 200 unexpectedly. Reading unlimited bodies can exhaust memory—wrap with http.MaxBytesReader. Shipping debug routes on public listeners is a common leak; gate them behind build tags or separate binaries.
Go web server cheat sheet
| Task | Go API |
|---|---|
| Start a simple server | http.ListenAndServe(addr, handler) |
| Create a router | http.NewServeMux() |
| Register a function | mux.HandleFunc(pattern, fn) |
| Configure timeouts and addr | http.Server{...} |
| Serve files | http.FileServer(http.Dir("static")) |
| Emit JSON | json.NewEncoder(w).Encode(v) |
| Set a header | w.Header().Set(name, value) |
| Set status | w.WriteHeader(code) |
| Graceful stop | srv.Shutdown(ctx) |
| TLS listen | srv.ListenAndServeTLS(certFile, keyFile) |
Summary
You can build a capable Go HTTP server with net/http: define handlers, register patterns on a ServeMux (including Go 1.22 method routes and PathValue), wrap the mux in an http.Server with read, write, header, and idle timeouts, return JSON or static files, and stop cleanly with Shutdown. That combination hits beginner examples and intermediate “how do I configure this?” intent without leaving the standard library.
References
- Package net/http
- Go 1.22 release notes — enhanced routing
- Graceful shutdown of servers with
signal.NotifyContext(Go blog)

