In the previous tutorial, you learned how to read and write files in Go. Now let’s focus on making your APIs production-ready.

Building an API that works is one thing. Building an API that is reliable, secure, and easy to use is another. In this tutorial, you will learn the best practices that separate hobby projects from production services.

Input Validation with validator

Manual validation gets messy fast. The go-playground/validator package lets you validate structs with tags:

package main

import (
    "fmt"

    "github.com/go-playground/validator/v10"
)

type CreateUserRequest struct {
    Name     string `json:"name" validate:"required,min=2,max=50"`
    Email    string `json:"email" validate:"required,email"`
    Age      int    `json:"age" validate:"required,gte=18,lte=120"`
    Role     string `json:"role" validate:"required,oneof=admin user moderator"`
    Password string `json:"password" validate:"required,min=8"`
}

func main() {
    validate := validator.New()

    // Valid request
    validUser := CreateUserRequest{
        Name:     "Alex",
        Email:    "alex@example.com",
        Age:      25,
        Role:     "user",
        Password: "secret123",
    }

    err := validate.Struct(validUser)
    if err != nil {
        fmt.Println("Validation failed:", err)
    } else {
        fmt.Println("Valid user:", validUser.Name)
    }

    // Invalid request
    invalidUser := CreateUserRequest{
        Name:     "A",         // too short
        Email:    "not-email", // invalid email
        Age:      15,          // under 18
        Role:     "superuser", // not in allowed list
        Password: "short",     // too short
    }

    err = validate.Struct(invalidUser)
    if err != nil {
        for _, e := range err.(validator.ValidationErrors) {
            fmt.Printf("Field '%s' failed on '%s' rule\n", e.Field(), e.Tag())
        }
    }
}

Output:

Valid user: Alex
Field 'Name' failed on 'min' rule
Field 'Email' failed on 'email' rule
Field 'Age' failed on 'gte' rule
Field 'Role' failed on 'oneof' rule
Field 'Password' failed on 'min' rule

Common validation tags:

TagMeaning
requiredField must not be zero value
emailValid email format
min=NMinimum length (string) or value (number)
max=NMaximum length or value
gte=NGreater than or equal
lte=NLess than or equal
oneof=a b cMust be one of the listed values
urlValid URL format
uuidValid UUID format

Custom Validators

You can write your own validation rules:

package main

import (
    "fmt"
    "strings"

    "github.com/go-playground/validator/v10"
)

type Article struct {
    Title string `json:"title" validate:"required,no_profanity"`
    Body  string `json:"body" validate:"required,min=10"`
}

func noProfanity(fl validator.FieldLevel) bool {
    value := strings.ToLower(fl.Field().String())
    blocked := []string{"spam", "scam"}
    for _, word := range blocked {
        if strings.Contains(value, word) {
            return false
        }
    }
    return true
}

func main() {
    validate := validator.New()
    validate.RegisterValidation("no_profanity", noProfanity)

    article := Article{
        Title: "This is spam content",
        Body:  "Some body text here",
    }

    err := validate.Struct(article)
    if err != nil {
        for _, e := range err.(validator.ValidationErrors) {
            fmt.Printf("Field '%s' failed on '%s' rule\n", e.Field(), e.Tag())
        }
    }
}

Output:

Field 'Title' failed on 'no_profanity' rule

Validation Error Responses

Return clear error messages to API clients:

package main

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

    "github.com/go-playground/validator/v10"
)

var validate = validator.New()

type CreateTaskRequest struct {
    Title    string `json:"title" validate:"required,min=2,max=100"`
    Priority string `json:"priority" validate:"required,oneof=low medium high"`
}

type ValidationError struct {
    Field   string `json:"field"`
    Message string `json:"message"`
}

func formatValidationErrors(err error) []ValidationError {
    var errors []ValidationError
    for _, e := range err.(validator.ValidationErrors) {
        var msg string
        switch e.Tag() {
        case "required":
            msg = fmt.Sprintf("%s is required", e.Field())
        case "min":
            msg = fmt.Sprintf("%s must be at least %s characters", e.Field(), e.Param())
        case "max":
            msg = fmt.Sprintf("%s must be at most %s characters", e.Field(), e.Param())
        case "oneof":
            msg = fmt.Sprintf("%s must be one of: %s", e.Field(), e.Param())
        default:
            msg = fmt.Sprintf("%s is invalid", e.Field())
        }
        errors = append(errors, ValidationError{Field: e.Field(), Message: msg})
    }
    return errors
}

func writeJSON(w http.ResponseWriter, status int, data any) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    json.NewEncoder(w).Encode(data)
}

func createTaskHandler(w http.ResponseWriter, r *http.Request) {
    var req CreateTaskRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
        return
    }

    if err := validate.Struct(req); err != nil {
        writeJSON(w, http.StatusBadRequest, map[string]any{
            "error":  "validation failed",
            "fields": formatValidationErrors(err),
        })
        return
    }

    writeJSON(w, http.StatusCreated, map[string]string{"message": "task created"})
}

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("POST /tasks", createTaskHandler)

    fmt.Println("Server starting on :8080")
    http.ListenAndServe(":8080", mux)
}

Test it:

curl -X POST http://localhost:8080/tasks \
  -H "Content-Type: application/json" \
  -d '{"title":"A","priority":"urgent"}'
# {"error":"validation failed","fields":[{"field":"Title","message":"Title must be at least 2 characters"},{"field":"Priority","message":"Priority must be one of: low medium high"}]}

Pagination

Every list endpoint should support pagination. Here are two common patterns:

Offset-Based Pagination

Simple and familiar. Use page and limit query parameters:

func listHandler(w http.ResponseWriter, r *http.Request) {
    page, _ := strconv.Atoi(r.URL.Query().Get("page"))
    limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))

    // Set defaults
    if page < 1 {
        page = 1
    }
    if limit < 1 || limit > 100 {
        limit = 20
    }

    offset := (page - 1) * limit

    // In a real app, use offset and limit in your SQL query:
    // SELECT * FROM tasks ORDER BY id LIMIT $1 OFFSET $2

    writeJSON(w, http.StatusOK, map[string]any{
        "page":  page,
        "limit": limit,
        "offset": offset,
    })
}

Cursor-Based Pagination

Better for large datasets. Use the last item’s ID as a cursor:

func listHandler(w http.ResponseWriter, r *http.Request) {
    cursor := r.URL.Query().Get("cursor")
    limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))

    if limit < 1 || limit > 100 {
        limit = 20
    }

    // In a real app:
    // SELECT * FROM tasks WHERE id > $1 ORDER BY id LIMIT $2
    _ = cursor

    // Return next cursor in response
    writeJSON(w, http.StatusOK, map[string]any{
        "data":        []string{"item1", "item2"},
        "next_cursor": "abc123",
        "has_more":    true,
    })
}

Cursor-based pagination does not skip items when data changes. Use it for feeds, timelines, and infinite scroll.

Rate Limiting

Protect your API from abuse with golang.org/x/time/rate:

package main

import (
    "encoding/json"
    "fmt"
    "net/http"
    "sync"

    "golang.org/x/time/rate"
)

// Per-client rate limiter
type RateLimiter struct {
    mu       sync.Mutex
    limiters map[string]*rate.Limiter
}

func NewRateLimiter() *RateLimiter {
    return &RateLimiter{
        limiters: make(map[string]*rate.Limiter),
    }
}

func (rl *RateLimiter) GetLimiter(ip string) *rate.Limiter {
    rl.mu.Lock()
    defer rl.mu.Unlock()

    limiter, exists := rl.limiters[ip]
    if !exists {
        // 10 requests per second, burst of 20
        limiter = rate.NewLimiter(10, 20)
        rl.limiters[ip] = limiter
    }
    return limiter
}

func writeJSON(w http.ResponseWriter, status int, data any) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    json.NewEncoder(w).Encode(data)
}

func rateLimitMiddleware(rl *RateLimiter, next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        ip := r.RemoteAddr

        limiter := rl.GetLimiter(ip)
        if !limiter.Allow() {
            writeJSON(w, http.StatusTooManyRequests, map[string]string{
                "error": "rate limit exceeded, try again later",
            })
            return
        }

        next.ServeHTTP(w, r)
    })
}

func main() {
    rl := NewRateLimiter()

    mux := http.NewServeMux()
    mux.HandleFunc("GET /api/data", func(w http.ResponseWriter, r *http.Request) {
        writeJSON(w, http.StatusOK, map[string]string{"message": "success"})
    })

    handler := rateLimitMiddleware(rl, mux)

    fmt.Println("Server starting on :8080")
    http.ListenAndServe(":8080", handler)
}

The rate.NewLimiter(10, 20) creates a limiter that allows 10 requests per second with a burst of 20. Each client (identified by IP) gets their own limiter.

Graceful Shutdown

When your server stops, finish handling current requests before exiting:

package main

import (
    "context"
    "fmt"
    "net/http"
    "os"
    "os/signal"
    "syscall"
    "time"
)

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
        // Simulate slow request
        time.Sleep(2 * time.Second)
        fmt.Fprintf(w, "Hello!")
    })

    server := &http.Server{
        Addr:    ":8080",
        Handler: mux,
    }

    // Start server in a goroutine
    go func() {
        fmt.Println("Server starting on :8080")
        if err := server.ListenAndServe(); err != http.ErrServerClosed {
            fmt.Println("Server error:", err)
            os.Exit(1)
        }
    }()

    // Wait for interrupt signal
    quit := make(chan os.Signal, 1)
    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
    <-quit

    fmt.Println("Shutting down server...")

    // Give active requests 10 seconds to finish
    ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()

    if err := server.Shutdown(ctx); err != nil {
        fmt.Println("Forced shutdown:", err)
    }

    fmt.Println("Server stopped")
}

Press Ctrl+C to stop. The server waits up to 10 seconds for active requests to complete. This prevents dropped connections during deployments.

Health Check Endpoints

Every production API needs health checks. Load balancers and orchestrators (like Kubernetes) use them to know if your service is running:

func healthHandler(w http.ResponseWriter, r *http.Request) {
    writeJSON(w, http.StatusOK, map[string]any{
        "status":  "ok",
        "version": "1.0.0",
        "time":    time.Now().UTC().Format(time.RFC3339),
    })
}

func readinessHandler(w http.ResponseWriter, r *http.Request) {
    // Check database, cache, external services
    // Return 503 if not ready

    writeJSON(w, http.StatusOK, map[string]string{
        "status": "ready",
    })
}

// Register in your mux:
// mux.HandleFunc("GET /health", healthHandler)
// mux.HandleFunc("GET /ready", readinessHandler)

Two endpoints serve different purposes:

  • /health — is the process alive? (liveness check)
  • /ready — can it handle requests? (readiness check)

Structured Logging with slog

Go 1.21 added slog to the standard library. It produces structured, machine-readable logs:

package main

import (
    "log/slog"
    "net/http"
    "os"
    "time"
)

func main() {
    // JSON logger for production
    logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
        Level: slog.LevelInfo,
    }))
    slog.SetDefault(logger)

    mux := http.NewServeMux()
    mux.HandleFunc("GET /users/{id}", func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        id := r.PathValue("id")

        slog.Info("handling request",
            "method", r.Method,
            "path", r.URL.Path,
            "user_id", id,
        )

        // Handle request...
        w.Write([]byte(`{"name":"Alex"}`))

        slog.Info("request completed",
            "method", r.Method,
            "path", r.URL.Path,
            "duration_ms", time.Since(start).Milliseconds(),
            "status", 200,
        )
    })

    slog.Info("server starting", "addr", ":8080")
    http.ListenAndServe(":8080", mux)
}

Output (each line is a JSON object):

{"time":"2026-05-05T10:00:00Z","level":"INFO","msg":"server starting","addr":":8080"}
{"time":"2026-05-05T10:00:01Z","level":"INFO","msg":"handling request","method":"GET","path":"/users/42","user_id":"42"}
{"time":"2026-05-05T10:00:01Z","level":"INFO","msg":"request completed","method":"GET","path":"/users/42","duration_ms":1,"status":200}

Structured logs are easy to search and filter in tools like Grafana, Datadog, or ELK. Use slog instead of fmt.Println or log.Println in production code.

Logging Middleware

Combine logging with middleware to log every request:

func loggingMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()

        // Wrap response writer to capture status code
        wrapped := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}

        next.ServeHTTP(wrapped, r)

        slog.Info("request",
            "method", r.Method,
            "path", r.URL.Path,
            "status", wrapped.statusCode,
            "duration_ms", time.Since(start).Milliseconds(),
            "ip", r.RemoteAddr,
        )
    })
}

type responseWriter struct {
    http.ResponseWriter
    statusCode int
}

func (rw *responseWriter) WriteHeader(code int) {
    rw.statusCode = code
    rw.ResponseWriter.WriteHeader(code)
}

Common Mistakes

1. Not validating input at all.

Never trust client data. Always validate before processing. One missing check can crash your server or corrupt your database.

2. Using fmt.Println for logging in production.

Use slog or a structured logging library. Plain text logs are hard to search and filter at scale.

3. No graceful shutdown.

Without graceful shutdown, active requests get dropped during deploys. Always handle SIGINT and SIGTERM signals.

Source Code

You can find the complete source code for this tutorial on GitHub:

GO-21 Source Code on GitHub

What’s Next?

In the next tutorial, Go Tutorial #22: Generics, you will learn:

  • What generics are and why Go added them
  • Type parameters and constraints
  • Real-world generic patterns like Map, Filter, and Result types
  • When to use generics vs interfaces

This is part 21 of the Go Tutorial series. Follow along to learn Go from scratch.