net/http Basics
Go's standard library net/http package is a powerful tool for building production-grade HTTP servers without any external frameworks. Frameworks like Gin and Echo are themselves built on top of net/http internally. The standard library is often sufficient on its own, and is especially useful when you want to minimize dependencies.
ServeMux and Handlers
package main
import (
"fmt"
"log"
"net/http"
)
func main() {
// Using DefaultServeMux (package-level global mux)
http.HandleFunc("/hello", helloHandler)
// Using a custom ServeMux (recommended - easier to test, no global state)
mux := http.NewServeMux()
// Go 1.22+ method+path pattern routing
mux.HandleFunc("GET /users", listUsers)
mux.HandleFunc("POST /users", createUser)
mux.HandleFunc("GET /users/{id}", getUser) // path parameter
mux.HandleFunc("DELETE /users/{id}", deleteUser)
// Register an http.Handler interface implementation
mux.Handle("/metrics", metricsHandler{})
log.Println("Server started: http://localhost:8080")
log.Fatal(http.ListenAndServe(":8080", mux))
}
func helloHandler(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "Hello, World!")
}
func listUsers(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "User list")
}
func createUser(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "Create user")
}
func getUser(w http.ResponseWriter, r *http.Request) {
// Extract path parameter (Go 1.22+)
id := r.PathValue("id")
fmt.Fprintf(w, "User ID: %s\n", id)
}
func deleteUser(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
fmt.Fprintf(w, "Delete user %s\n", id)
}
// Implementing the http.Handler interface
type metricsHandler struct{}
func (h metricsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "# metrics")
}
http.Server Struct Configuration
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,
// Maximum time for the client to send request headers
ReadHeaderTimeout: 5 * time.Second,
// Maximum time to read the entire request (including body)
ReadTimeout: 10 * time.Second,
// Maximum time to write the response
WriteTimeout: 30 * time.Second,
// Keep-Alive idle connection timeout
IdleTimeout: 120 * time.Second,
// Maximum size of request headers (default 1MB)
MaxHeaderBytes: 1 << 20,
}
log.Fatal(srv.ListenAndServe())
}
Request Handling
package main
import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
)
func requestInfoHandler(w http.ResponseWriter, r *http.Request) {
// HTTP method
fmt.Println("Method:", r.Method)
// URL information
fmt.Println("Path:", r.URL.Path)
fmt.Println("RawQuery:", r.URL.RawQuery)
// Query parameters
name := r.URL.Query().Get("name") // single value
tags := r.URL.Query()["tag"] // multiple values (tag=go&tag=api)
fmt.Println("name:", name, "tags:", tags)
// Headers
contentType := r.Header.Get("Content-Type")
userAgent := r.Header.Get("User-Agent")
fmt.Println("Content-Type:", contentType)
fmt.Println("User-Agent:", userAgent)
// Reading the body (size limit is mandatory)
r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1MB limit
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Failed to read request body", http.StatusBadRequest)
return
}
defer r.Body.Close()
fmt.Println("Body:", string(body))
// Client IP
fmt.Println("RemoteAddr:", r.RemoteAddr)
}
// JSON request parsing
type CreateUserRequest struct {
Name string `json:"name"`
Email string `json:"email"`
}
func createUserHandler(w http.ResponseWriter, r *http.Request) {
var req CreateUserRequest
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))
decoder.DisallowUnknownFields() // reject unknown fields
if err := decoder.Decode(&req); err != nil {
http.Error(w, `{"error":"invalid JSON format"}`, http.StatusBadRequest)
return
}
if req.Name == "" || req.Email == "" {
http.Error(w, `{"error":"name and email are required"}`, http.StatusUnprocessableEntity)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]any{
"id": 1,
"name": req.Name,
"email": req.Email,
})
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/request-info", requestInfoHandler)
mux.HandleFunc("POST /users", createUserHandler)
log.Fatal(http.ListenAndServe(":8080", mux))
}
Writing Responses
package main
import (
"encoding/json"
"net/http"
)
// JSON response helper
func jsonResponse(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
// Error response helper
func errorResponse(w http.ResponseWriter, status int, code, message string) {
jsonResponse(w, status, map[string]any{
"error": map[string]string{
"code": code,
"message": message,
},
})
}
func exampleHandler(w http.ResponseWriter, r *http.Request) {
// Headers must be set before WriteHeader/Write
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Add("X-Custom-Header", "value")
// Set status code (can only be called once)
w.WriteHeader(http.StatusOK)
// Write body
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
// File download response
func downloadHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Disposition", `attachment; filename="data.json"`)
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"data": "example"}`))
}
// Redirect
func redirectHandler(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/new-path", http.StatusMovedPermanently)
}
Static File Serving
package main
import (
"log"
"net/http"
)
func main() {
mux := http.NewServeMux()
// Map /static/ path to ./public directory
// StripPrefix removes the /static prefix before passing to FileServer
fileServer := http.FileServer(http.Dir("./public"))
mux.Handle("/static/", http.StripPrefix("/static/", fileServer))
// Static files using embed.FS (embedded in binary)
// mux.Handle("/assets/", http.FileServer(http.FS(staticFiles)))
// SPA (Single Page Application) support
mux.HandleFunc("/app/", spaHandler)
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "./public/index.html")
})
log.Fatal(http.ListenAndServe(":8080", mux))
}
// SPA handler: serves index.html for all paths
func spaHandler(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "./public/index.html")
}
Middleware Chain (Function Wrapping Pattern)
package main
import (
"log"
"net/http"
"time"
)
// Middleware type definition
type Middleware func(http.Handler) http.Handler
// Chain applies middlewares in order
func Chain(h http.Handler, middlewares ...Middleware) http.Handler {
for i := len(middlewares) - 1; i >= 0; i-- {
h = middlewares[i](h)
}
return h
}
// responseWriter captures the response status code
type responseWriter struct {
http.ResponseWriter
status int
wroteHeader bool
}
func newResponseWriter(w http.ResponseWriter) *responseWriter {
return &responseWriter{ResponseWriter: w, status: http.StatusOK}
}
func (rw *responseWriter) WriteHeader(status int) {
if !rw.wroteHeader {
rw.status = status
rw.wroteHeader = true
rw.ResponseWriter.WriteHeader(status)
}
}
// LoggingMiddleware logs incoming requests
func LoggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rw := newResponseWriter(w)
next.ServeHTTP(rw, r)
log.Printf("[%d] %s %s (%v)", rw.status, r.Method, r.URL.Path, time.Since(start))
})
}
// RecoveryMiddleware recovers from panics
func RecoveryMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
log.Printf("panic: %v", err)
http.Error(w, `{"error":{"code":"INTERNAL_ERROR","message":"Internal server error"}}`,
http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
// CORSMiddleware adds CORS headers
func CORSMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET,POST,PUT,PATCH,DELETE,OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type,Authorization")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /users", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"users":[]}`))
})
// Apply middleware chain: Recovery -> CORS -> Logging -> Handler
handler := Chain(mux, RecoveryMiddleware, CORSMiddleware, LoggingMiddleware)
log.Fatal(http.ListenAndServe(":8080", handler))
}
Practical Example: Todo REST API
package main
import (
"encoding/json"
"log"
"net/http"
"strconv"
"sync"
"time"
)
type Todo struct {
ID int `json:"id"`
Title string `json:"title"`
Done bool `json:"done"`
CreatedAt time.Time `json:"created_at"`
}
type Store struct {
mu sync.RWMutex
todos map[int]*Todo
nextID int
}
func NewStore() *Store {
return &Store{todos: make(map[int]*Todo), nextID: 1}
}
func (s *Store) List() []*Todo {
s.mu.RLock()
defer s.mu.RUnlock()
list := make([]*Todo, 0, len(s.todos))
for _, t := range s.todos {
list = append(list, t)
}
return list
}
func (s *Store) Create(title string) *Todo {
s.mu.Lock()
defer s.mu.Unlock()
t := &Todo{ID: s.nextID, Title: title, CreatedAt: time.Now()}
s.todos[s.nextID] = t
s.nextID++
return t
}
func (s *Store) Get(id int) (*Todo, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
t, ok := s.todos[id]
return t, ok
}
func (s *Store) Update(id int, done bool) (*Todo, bool) {
s.mu.Lock()
defer s.mu.Unlock()
t, ok := s.todos[id]
if !ok {
return nil, false
}
t.Done = done
return t, true
}
func (s *Store) Delete(id int) bool {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.todos[id]; !ok {
return false
}
delete(s.todos, id)
return true
}
type Handler struct{ store *Store }
func (h *Handler) list(w http.ResponseWriter, r *http.Request) {
todos := h.store.List()
respond(w, http.StatusOK, map[string]any{"data": todos})
}
func (h *Handler) create(w http.ResponseWriter, r *http.Request) {
var body struct {
Title string `json:"title"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Title == "" {
respond(w, http.StatusBadRequest, map[string]any{
"error": map[string]string{"code": "BAD_REQUEST", "message": "title is required"},
})
return
}
respond(w, http.StatusCreated, map[string]any{"data": h.store.Create(body.Title)})
}
func (h *Handler) get(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
respond(w, http.StatusBadRequest, errResp("INVALID_ID", "invalid ID"))
return
}
todo, ok := h.store.Get(id)
if !ok {
respond(w, http.StatusNotFound, errResp("NOT_FOUND", "todo not found"))
return
}
respond(w, http.StatusOK, map[string]any{"data": todo})
}
func (h *Handler) update(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
respond(w, http.StatusBadRequest, errResp("INVALID_ID", "invalid ID"))
return
}
var body struct {
Done bool `json:"done"`
}
json.NewDecoder(r.Body).Decode(&body)
todo, ok := h.store.Update(id, body.Done)
if !ok {
respond(w, http.StatusNotFound, errResp("NOT_FOUND", "todo not found"))
return
}
respond(w, http.StatusOK, map[string]any{"data": todo})
}
func (h *Handler) delete(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
respond(w, http.StatusBadRequest, errResp("INVALID_ID", "invalid ID"))
return
}
if !h.store.Delete(id) {
respond(w, http.StatusNotFound, errResp("NOT_FOUND", "todo not found"))
return
}
w.WriteHeader(http.StatusNoContent)
}
func respond(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
func errResp(code, message string) map[string]any {
return map[string]any{"error": map[string]string{"code": code, "message": message}}
}
func main() {
h := &Handler{store: NewStore()}
mux := http.NewServeMux()
mux.HandleFunc("GET /api/todos", h.list)
mux.HandleFunc("POST /api/todos", h.create)
mux.HandleFunc("GET /api/todos/{id}", h.get)
mux.HandleFunc("PATCH /api/todos/{id}", h.update)
mux.HandleFunc("DELETE /api/todos/{id}", h.delete)
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
respond(w, http.StatusOK, map[string]string{"status": "ok"})
})
handler := Chain(mux, RecoveryMiddleware, CORSMiddleware, LoggingMiddleware)
log.Println("Todo API server: http://localhost:8080")
log.Fatal(http.ListenAndServe(":8080", handler))
}
Expert Tips
1. Use Go 1.22+ pattern routing: The "GET /users/{id}" syntax lets you specify both method and path together, eliminating the need for a separate router.
2. Use a custom ServeMux: The global http.DefaultServeMux is hard to test and can be polluted by third-party packages.
3. Set headers before WriteHeader: w.Header().Set() must be called before w.WriteHeader() or the first w.Write().
4. Always close the body: Don't forget defer r.Body.Close(). Also use MaxBytesReader to limit body size.