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/httppackage 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
httptestpackage 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.