Go HTTP Server: Building REST APIs with net/http

Go's standard library `net/http` package is remarkably complete. Unlike many languages where you immediately reach for Express, Flask, or Rails, Go gives you everything needed for production REST...

Key Insights

  • Go’s net/http package is production-ready out of the box—you don’t need a framework for most REST APIs, and the standard library gives you fine-grained control without magic
  • Middleware in Go is just functions that wrap handlers, making it trivial to compose cross-cutting concerns like logging, authentication, and CORS without framework lock-in
  • The httptest package makes testing HTTP handlers straightforward by providing mock request/response infrastructure that doesn’t require spinning up actual servers

Why net/http Is Enough

Go’s standard library net/http package is remarkably complete. Unlike many languages where you immediately reach for Express, Flask, or Rails, Go gives you everything needed for production REST APIs right in the standard library. You get HTTP/2 support, TLS, timeouts, graceful shutdown, and excellent performance.

Frameworks like Gin and Echo add convenience—parameter binding, middleware chains, better routing—but they also add dependencies and abstractions. For many projects, net/http with a few helper functions is cleaner and more maintainable. You understand exactly what’s happening because there’s no framework magic.

Here’s the simplest possible HTTP server:

package main

import (
    "fmt"
    "net/http"
)

func main() {
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, "Hello, World!")
    })
    
    http.ListenAndServe(":8080", nil)
}

This is production-grade code. It handles concurrent requests, supports HTTP/2, and will serve thousands of requests per second on modest hardware.

Handlers and Routing

Go has two ways to define handlers. The simple approach uses http.HandleFunc, which accepts a function with the signature func(http.ResponseWriter, *http.Request). The more flexible approach implements the http.Handler interface with a ServeHTTP method.

The ServeMux (multiplexer) routes requests to handlers based on URL patterns. The default mux is fine for simple cases, but creating your own gives better control:

package main

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

func main() {
    mux := http.NewServeMux()
    
    mux.HandleFunc("/health", healthHandler)
    mux.HandleFunc("/api/users", usersHandler)
    mux.HandleFunc("/api/users/", userHandler) // trailing slash for /api/users/{id}
    
    http.ListenAndServe(":8080", mux)
}

func healthHandler(w http.ResponseWriter, r *http.Request) {
    w.WriteHeader(http.StatusOK)
    json.NewEncoder(w).Encode(map[string]string{"status": "healthy"})
}

func usersHandler(w http.ResponseWriter, r *http.Request) {
    // Handle /api/users
}

func userHandler(w http.ResponseWriter, r *http.Request) {
    // Handle /api/users/{id}
}

The ResponseWriter is how you write the response. The Request contains everything about the incoming request—method, headers, body, URL parameters.

Building RESTful CRUD Operations

A real REST API needs to handle different HTTP methods and parse/return JSON. Here’s a complete user management API:

package main

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

type User struct {
    ID    int    `json:"id"`
    Name  string `json:"name"`
    Email string `json:"email"`
}

var (
    users   = make(map[int]User)
    nextID  = 1
    usersMu sync.RWMutex
)

func usersHandler(w http.ResponseWriter, r *http.Request) {
    switch r.Method {
    case http.MethodGet:
        listUsers(w, r)
    case http.MethodPost:
        createUser(w, r)
    default:
        http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
    }
}

func listUsers(w http.ResponseWriter, r *http.Request) {
    usersMu.RLock()
    defer usersMu.RUnlock()
    
    userList := make([]User, 0, len(users))
    for _, user := range users {
        userList = append(userList, user)
    }
    
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(userList)
}

func createUser(w http.ResponseWriter, r *http.Request) {
    var user User
    if err := json.NewDecoder(r.Body).Decode(&user); err != nil {
        http.Error(w, "Invalid request body", http.StatusBadRequest)
        return
    }
    
    usersMu.Lock()
    user.ID = nextID
    nextID++
    users[user.ID] = user
    usersMu.Unlock()
    
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusCreated)
    json.NewEncoder(w).Encode(user)
}

func userHandler(w http.ResponseWriter, r *http.Request) {
    // Extract ID from path like /api/users/123
    idStr := strings.TrimPrefix(r.URL.Path, "/api/users/")
    id, err := strconv.Atoi(idStr)
    if err != nil {
        http.Error(w, "Invalid user ID", http.StatusBadRequest)
        return
    }
    
    switch r.Method {
    case http.MethodGet:
        getUser(w, r, id)
    case http.MethodPut:
        updateUser(w, r, id)
    case http.MethodDelete:
        deleteUser(w, r, id)
    default:
        http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
    }
}

func getUser(w http.ResponseWriter, r *http.Request, id int) {
    usersMu.RLock()
    user, exists := users[id]
    usersMu.RUnlock()
    
    if !exists {
        http.Error(w, "User not found", http.StatusNotFound)
        return
    }
    
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(user)
}

func updateUser(w http.ResponseWriter, r *http.Request, id int) {
    var user User
    if err := json.NewDecoder(r.Body).Decode(&user); err != nil {
        http.Error(w, "Invalid request body", http.StatusBadRequest)
        return
    }
    
    usersMu.Lock()
    if _, exists := users[id]; !exists {
        usersMu.Unlock()
        http.Error(w, "User not found", http.StatusNotFound)
        return
    }
    user.ID = id
    users[id] = user
    usersMu.Unlock()
    
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(user)
}

func deleteUser(w http.ResponseWriter, r *http.Request, id int) {
    usersMu.Lock()
    if _, exists := users[id]; !exists {
        usersMu.Unlock()
        http.Error(w, "User not found", http.StatusNotFound)
        return
    }
    delete(users, id)
    usersMu.Unlock()
    
    w.WriteHeader(http.StatusNoContent)
}

This implements proper REST semantics: GET for retrieval, POST for creation, PUT for updates, DELETE for removal. Status codes match the operation: 200 for success, 201 for creation, 204 for deletion, 404 for not found.

Middleware Patterns

Middleware wraps handlers to add cross-cutting functionality. In Go, middleware is just a function that takes a handler and returns a handler:

func loggingMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        log.Printf("%s %s", r.Method, r.URL.Path)
        next.ServeHTTP(w, r)
    })
}

func authMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        token := r.Header.Get("Authorization")
        if token != "Bearer valid-token" {
            http.Error(w, "Unauthorized", http.StatusUnauthorized)
            return
        }
        next.ServeHTTP(w, r)
    })
}

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, DELETE, OPTIONS")
        w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
        
        if r.Method == http.MethodOptions {
            w.WriteHeader(http.StatusOK)
            return
        }
        
        next.ServeHTTP(w, r)
    })
}

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("/api/users", usersHandler)
    
    // Chain middleware
    handler := loggingMiddleware(corsMiddleware(authMiddleware(mux)))
    
    http.ListenAndServe(":8080", handler)
}

Middleware executes in order: logging runs first, then CORS, then auth. Each can short-circuit by not calling next.ServeHTTP.

Error Handling and Validation

Structured error responses make APIs easier to consume. Create a consistent error format:

type ErrorResponse struct {
    Error   string `json:"error"`
    Message string `json:"message"`
    Code    int    `json:"code"`
}

func respondError(w http.ResponseWriter, code int, message string) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(code)
    json.NewEncoder(w).Encode(ErrorResponse{
        Error:   http.StatusText(code),
        Message: message,
        Code:    code,
    })
}

func validateUser(user *User) error {
    if user.Name == "" {
        return fmt.Errorf("name is required")
    }
    if user.Email == "" {
        return fmt.Errorf("email is required")
    }
    if !strings.Contains(user.Email, "@") {
        return fmt.Errorf("invalid email format")
    }
    return nil
}

func createUser(w http.ResponseWriter, r *http.Request) {
    var user User
    if err := json.NewDecoder(r.Body).Decode(&user); err != nil {
        respondError(w, http.StatusBadRequest, "Invalid JSON")
        return
    }
    
    if err := validateUser(&user); err != nil {
        respondError(w, http.StatusBadRequest, err.Error())
        return
    }
    
    // Create user...
}

Testing and Production Setup

The httptest package makes testing handlers simple:

func TestGetUser(t *testing.T) {
    users = map[int]User{1: {ID: 1, Name: "Test", Email: "test@example.com"}}
    
    req := httptest.NewRequest(http.MethodGet, "/api/users/1", nil)
    w := httptest.NewRecorder()
    
    userHandler(w, req)
    
    if w.Code != http.StatusOK {
        t.Errorf("expected status 200, got %d", w.Code)
    }
    
    var user User
    json.NewDecoder(w.Body).Decode(&user)
    if user.Name != "Test" {
        t.Errorf("expected name Test, got %s", user.Name)
    }
}

For production, implement graceful shutdown and proper timeouts:

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("/api/users", usersHandler)
    
    srv := &http.Server{
        Addr:         ":8080",
        Handler:      mux,
        ReadTimeout:  15 * time.Second,
        WriteTimeout: 15 * time.Second,
        IdleTimeout:  60 * time.Second,
    }
    
    go func() {
        if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            log.Fatalf("Server failed: %v", err)
        }
    }()
    
    quit := make(chan os.Signal, 1)
    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
    <-quit
    
    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()
    
    if err := srv.Shutdown(ctx); err != nil {
        log.Fatalf("Shutdown failed: %v", err)
    }
}

This waits for in-flight requests to complete before shutting down, preventing dropped connections during deployments.

Go’s net/http gives you everything needed for production REST APIs without framework overhead. You get full control, excellent performance, and code that’s easy to understand and maintain.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.