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:
| Tag | Meaning |
|---|---|
required | Field must not be zero value |
email | Valid email format |
min=N | Minimum length (string) or value (number) |
max=N | Maximum length or value |
gte=N | Greater than or equal |
lte=N | Less than or equal |
oneof=a b c | Must be one of the listed values |
url | Valid URL format |
uuid | Valid 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:
Related Articles
- Go Tutorial #20: File I/O — Reading and writing files
- Go Tutorial #15: Building HTTP Servers with net/http — HTTP server basics
- Go Tutorial #16: Building REST APIs with Gin — Gin framework
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.