Building REST APIs in Go: Complete Guide
Build production-ready REST APIs with Go's robust ecosystem
Building high-performance REST APIs with Go has become a standard approach for powering systems at Google, Uber, Dropbox, and countless startups.
Go’s simplicity, strong concurrency support, and fast compilation make it ideal for microservices and backend development.
This awesome image is generated by FLUX.1-Kontext-dev: Image Augmentation AI Model.
Why Go for API Development?
Go brings several compelling advantages to API development:
Performance and Efficiency: Go compiles to native machine code, delivering near-C performance without the complexity. Its efficient memory management and small binary sizes make it perfect for containerized deployments.
Built-in Concurrency: Goroutines and channels make handling thousands of concurrent requests straightforward. You can process multiple API calls simultaneously without complex threading code.
Strong Standard Library: The net/http package provides a production-ready HTTP server out of the box. You can build complete APIs without any external dependencies.
Fast Compilation: Go’s compilation speed enables rapid iteration during development. Large projects compile in seconds, not minutes.
Static Typing with Simplicity: Go’s type system catches errors at compile time while maintaining code clarity. The language has a small feature set that’s quick to learn.
Approaches to Building APIs in Go
Using the Standard Library
Go’s standard library provides everything needed for basic API development. Here’s a minimal example:
package main
import (
"encoding/json"
"log"
"net/http"
)
type Response struct {
Message string `json:"message"`
Status int `json:"status"`
}
func healthHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(Response{
Message: "API is healthy",
Status: 200,
})
}
func main() {
http.HandleFunc("/health", healthHandler)
log.Println("Server starting on :8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}
This approach offers complete control and zero dependencies. It’s ideal for simple APIs or when you want to understand HTTP handling at a fundamental level.
Popular Go Web Frameworks
While the standard library is powerful, frameworks can accelerate development:
Gin: The most popular Go web framework, known for its performance and ease of use. It provides convenient routing, middleware support, and request validation.
package main
import (
"github.com/gin-gonic/gin"
"net/http"
)
func main() {
r := gin.Default()
r.GET("/users/:id", func(c *gin.Context) {
id := c.Param("id")
c.JSON(http.StatusOK, gin.H{
"user_id": id,
"name": "John Doe",
})
})
r.Run(":8080")
}
Chi: A lightweight, idiomatic router that feels like an extension of the standard library. It’s particularly good for building RESTful services with nested routing.
Echo: High-performance framework with extensive middleware and excellent documentation. It’s optimized for speed while remaining developer-friendly.
Fiber: Inspired by Express.js, built on top of Fasthttp. It’s the fastest option but uses a different HTTP implementation than the standard library.
Architectural Patterns
When working with database operations in Go, you’ll need to consider your ORM strategy. Different projects have compared approaches like GORM, Ent, Bun, and sqlc, each offering different trade-offs between developer productivity and performance.
Layered Architecture
Structure your API with clear separation of concerns:
// Handler Layer - HTTP concerns
type UserHandler struct {
service *UserService
}
func (h *UserHandler) GetUser(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
user, err := h.service.GetByID(r.Context(), id)
if err != nil {
respondError(w, err)
return
}
respondJSON(w, user)
}
// Service Layer - Business logic
type UserService struct {
repo *UserRepository
}
func (s *UserService) GetByID(ctx context.Context, id string) (*User, error) {
// Validate, transform, apply business rules
return s.repo.FindByID(ctx, id)
}
// Repository Layer - Data access
type UserRepository struct {
db *sql.DB
}
func (r *UserRepository) FindByID(ctx context.Context, id string) (*User, error) {
// Database query implementation
}
This separation makes testing easier and keeps your code maintainable as the project grows.
Domain-Driven Design
For complex applications, consider organizing code by domain rather than technical layers. Each domain package contains its own models, services, and repositories.
If you’re building multi-tenant applications, understanding database patterns for multi-tenancy becomes crucial for your API architecture.
Request Handling and Validation
Input Validation
Always validate incoming data before processing:
type CreateUserRequest struct {
Email string `json:"email" validate:"required,email"`
Username string `json:"username" validate:"required,min=3,max=50"`
Age int `json:"age" validate:"gte=0,lte=150"`
}
func (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request) {
var req CreateUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, NewBadRequestError("Invalid JSON"))
return
}
validate := validator.New()
if err := validate.Struct(req); err != nil {
respondError(w, NewValidationError(err))
return
}
// Process valid request
}
The go-playground/validator package provides extensive validation rules and custom validators.
Request Context
Use context for request-scoped values and cancellation:
func authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := r.Header.Get("Authorization")
userID, err := validateToken(token)
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
ctx := context.WithValue(r.Context(), "userID", userID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
Authentication and Security
JWT-Based Authentication
JSON Web Tokens provide stateless authentication:
import "github.com/golang-jwt/jwt/v5"
func generateToken(userID string) (string, error) {
claims := jwt.MapClaims{
"user_id": userID,
"exp": time.Now().Add(time.Hour * 24).Unix(),
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(os.Getenv("JWT_SECRET")))
}
func validateToken(tokenString string) (string, error) {
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
return []byte(os.Getenv("JWT_SECRET")), nil
})
if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
return claims["user_id"].(string), nil
}
return "", err
}
Middleware Patterns
Implement cross-cutting concerns as middleware:
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
log.Printf("Started %s %s", r.Method, r.URL.Path)
next.ServeHTTP(w, r)
log.Printf("Completed in %v", time.Since(start))
})
}
func rateLimitMiddleware(next http.Handler) http.Handler {
limiter := rate.NewLimiter(10, 20) // 10 requests/sec, burst of 20
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !limiter.Allow() {
http.Error(w, "Rate limit exceeded", http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, r)
})
}
Error Handling
Implement consistent error responses:
type APIError struct {
Code int `json:"code"`
Message string `json:"message"`
Details string `json:"details,omitempty"`
}
func (e *APIError) Error() string {
return e.Message
}
func NewBadRequestError(message string) *APIError {
return &APIError{
Code: http.StatusBadRequest,
Message: message,
}
}
func NewNotFoundError(resource string) *APIError {
return &APIError{
Code: http.StatusNotFound,
Message: fmt.Sprintf("%s not found", resource),
}
}
func respondError(w http.ResponseWriter, err error) {
apiErr, ok := err.(*APIError)
if !ok {
apiErr = &APIError{
Code: http.StatusInternalServerError,
Message: "Internal server error",
}
// Log the actual error for debugging
log.Printf("Unexpected error: %v", err)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(apiErr.Code)
json.NewEncoder(w).Encode(apiErr)
}
Database Integration
Connection Management
Use connection pooling for efficient database access:
func initDB() (*sql.DB, error) {
db, err := sql.Open("postgres", os.Getenv("DATABASE_URL"))
if err != nil {
return nil, err
}
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(5)
db.SetConnMaxLifetime(5 * time.Minute)
return db, db.Ping()
}
Query Patterns
Use prepared statements and context for safe database operations:
func (r *UserRepository) FindByEmail(ctx context.Context, email string) (*User, error) {
query := `SELECT id, email, username, created_at FROM users WHERE email = $1`
var user User
err := r.db.QueryRowContext(ctx, query, email).Scan(
&user.ID,
&user.Email,
&user.Username,
&user.CreatedAt,
)
if err == sql.ErrNoRows {
return nil, ErrUserNotFound
}
return &user, err
}
Testing Strategies
Handler Testing
Test HTTP handlers using httptest:
func TestGetUserHandler(t *testing.T) {
// Setup
mockService := &MockUserService{
GetByIDFunc: func(ctx context.Context, id string) (*User, error) {
return &User{ID: "1", Username: "testuser"}, nil
},
}
handler := &UserHandler{service: mockService}
// Execute
req := httptest.NewRequest("GET", "/users/1", nil)
w := httptest.NewRecorder()
handler.GetUser(w, req)
// Assert
assert.Equal(t, http.StatusOK, w.Code)
var response User
json.Unmarshal(w.Body.Bytes(), &response)
assert.Equal(t, "testuser", response.Username)
}
Integration Testing
Test complete workflows with a test database:
func TestCreateUserEndToEnd(t *testing.T) {
// Setup test database
db := setupTestDB(t)
defer db.Close()
// Start test server
server := setupTestServer(db)
defer server.Close()
// Make request
body := strings.NewReader(`{"email":"test@example.com","username":"testuser"}`)
resp, err := http.Post(server.URL+"/users", "application/json", body)
require.NoError(t, err)
defer resp.Body.Close()
// Verify response
assert.Equal(t, http.StatusCreated, resp.StatusCode)
// Verify database state
var count int
db.QueryRow("SELECT COUNT(*) FROM users WHERE email = $1", "test@example.com").Scan(&count)
assert.Equal(t, 1, count)
}
API Documentation
OpenAPI/Swagger
Document your API using OpenAPI specifications:
// @title User API
// @version 1.0
// @description API for managing users
// @host localhost:8080
// @BasePath /api/v1
// @Summary Get user by ID
// @Description Retrieves a user's information by their ID
// @Tags users
// @Accept json
// @Produce json
// @Param id path string true "User ID"
// @Success 200 {object} User
// @Failure 404 {object} APIError
// @Router /users/{id} [get]
func (h *UserHandler) GetUser(w http.ResponseWriter, r *http.Request) {
// Implementation
}
Use swaggo/swag to generate interactive API documentation from these comments.
Performance Optimization
Response Compression
Enable gzip compression for responses:
import "github.com/NYTimes/gziphandler"
func main() {
r := chi.NewRouter()
r.Use(gziphandler.GzipHandler)
// Rest of setup
}
Caching
Implement caching for frequently accessed data:
import "github.com/go-redis/redis/v8"
type CachedUserRepository struct {
repo *UserRepository
cache *redis.Client
}
func (r *CachedUserRepository) GetByID(ctx context.Context, id string) (*User, error) {
// Try cache first
cached, err := r.cache.Get(ctx, "user:"+id).Result()
if err == nil {
var user User
json.Unmarshal([]byte(cached), &user)
return &user, nil
}
// Cache miss - fetch from database
user, err := r.repo.FindByID(ctx, id)
if err != nil {
return nil, err
}
// Store in cache
data, _ := json.Marshal(user)
r.cache.Set(ctx, "user:"+id, data, 10*time.Minute)
return user, nil
}
Connection Pooling
Reuse HTTP connections for external API calls:
var httpClient = &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
},
}
Deployment Considerations
Docker Containerization
Create efficient Docker images using multi-stage builds:
# Build stage
FROM golang:1.21-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o api ./cmd/api
# Production stage
FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /root/
COPY --from=builder /app/api .
EXPOSE 8080
CMD ["./api"]
This produces a minimal image (typically under 20MB) with just your binary and essential certificates.
Configuration Management
Use environment variables and configuration files:
type Config struct {
Port string
DatabaseURL string
JWTSecret string
LogLevel string
}
func LoadConfig() (*Config, error) {
return &Config{
Port: getEnv("PORT", "8080"),
DatabaseURL: getEnv("DATABASE_URL", ""),
JWTSecret: getEnv("JWT_SECRET", ""),
LogLevel: getEnv("LOG_LEVEL", "info"),
}, nil
}
func getEnv(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}
Graceful Shutdown
Handle shutdown signals properly:
func main() {
server := &http.Server{
Addr: ":8080",
Handler: setupRouter(),
}
// Start server in goroutine
go func() {
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("Server error: %v", err)
}
}()
// Wait for interrupt signal
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("Shutting down server...")
// Give outstanding requests 30 seconds to complete
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := server.Shutdown(ctx); err != nil {
log.Fatalf("Server forced to shutdown: %v", err)
}
log.Println("Server exited")
}
Monitoring and Observability
Structured Logging
Use structured logging for better searchability:
import "go.uber.org/zap"
func setupLogger() (*zap.Logger, error) {
config := zap.NewProductionConfig()
config.OutputPaths = []string{"stdout"}
return config.Build()
}
func (h *UserHandler) GetUser(w http.ResponseWriter, r *http.Request) {
logger := h.logger.With(
zap.String("method", r.Method),
zap.String("path", r.URL.Path),
zap.String("user_id", r.Context().Value("userID").(string)),
)
logger.Info("Processing request")
// Handler logic
}
Metrics Collection
Expose Prometheus metrics:
import "github.com/prometheus/client_golang/prometheus"
var (
requestDuration = prometheus.NewHistogramVec(
prometheus.HistogramOpts{
Name: "http_request_duration_seconds",
Help: "Duration of HTTP requests",
},
[]string{"method", "path", "status"},
)
)
func init() {
prometheus.MustRegister(requestDuration)
}
func metricsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
recorder := &statusRecorder{ResponseWriter: w, status: 200}
next.ServeHTTP(recorder, r)
duration := time.Since(start).Seconds()
requestDuration.WithLabelValues(
r.Method,
r.URL.Path,
strconv.Itoa(recorder.status),
).Observe(duration)
})
}
Advanced Patterns
Working with Structured Output
When building APIs that integrate with LLMs, you might need to constrain responses with structured output. This is particularly useful for AI-powered features in your API.
Web Scraping for API Data Sources
If your API needs to aggregate data from other websites, understanding alternatives to Beautiful Soup in Go can help you implement robust web scraping functionality.
Document Generation
Many APIs need to generate documents. For PDF generation in Go, there are several libraries and approaches you can integrate into your API endpoints.
Semantic Search and Reranking
For APIs that deal with text search and retrieval, implementing reranking with embedding models can significantly improve search result relevance.
Building MCP Servers
If you’re implementing APIs that follow the Model Context Protocol, check out this guide on implementing MCP servers in Go, which covers protocol specifications and practical implementations.
Common Pitfalls and Solutions
Not Using Contexts Properly
Always pass and respect context throughout your call chain. This enables proper cancellation and timeout handling.
Ignoring Goroutine Leaks
Ensure all goroutines can terminate. Use contexts with deadlines and always have a way to signal completion.
Poor Error Handling
Don’t return raw database errors to clients. Wrap errors with context and return sanitized messages in API responses.
Missing Input Validation
Validate all inputs at the entry point. Never trust client data, even from authenticated users.
Inadequate Testing
Don’t just test the happy path. Cover error cases, edge conditions, and concurrent access scenarios.
Best Practices Summary
-
Start Simple: Begin with the standard library. Add frameworks when complexity demands it.
-
Layer Your Application: Separate HTTP handlers, business logic, and data access for maintainability.
-
Validate Everything: Check inputs at boundaries. Use strong typing and validation libraries.
-
Handle Errors Consistently: Return structured error responses. Log internal errors but don’t expose them.
-
Use Middleware: Implement cross-cutting concerns (auth, logging, metrics) as middleware.
-
Test Thoroughly: Write unit tests for logic, integration tests for data access, and end-to-end tests for workflows.
-
Document Your API: Use OpenAPI/Swagger for interactive documentation.
-
Monitor Production: Implement structured logging, metrics collection, and health checks.
-
Optimize Carefully: Profile before optimizing. Use caching, connection pooling, and compression where beneficial.
-
Design for Graceful Shutdown: Handle termination signals and drain connections properly.
Getting Started Checklist
For reference when working on Go projects, having a comprehensive Go cheatsheet at hand can speed up development and serve as a quick reference for syntax and common patterns.
Ready to build your first Go API? Start with these steps:
- ✅ Set up your Go environment and project structure
- ✅ Choose between standard library or a framework
- ✅ Implement basic CRUD endpoints
- ✅ Add request validation and error handling
- ✅ Implement authentication middleware
- ✅ Add database integration with connection pooling
- ✅ Write unit and integration tests
- ✅ Add API documentation
- ✅ Implement logging and metrics
- ✅ Containerize with Docker
- ✅ Set up CI/CD pipeline
- ✅ Deploy to production with monitoring
Conclusion
Go provides an excellent foundation for building REST APIs, combining performance, simplicity, and robust tooling. Whether you’re building microservices, internal tools, or public APIs, Go’s ecosystem has mature solutions for every requirement.
The key to success is starting with solid architectural patterns, implementing proper error handling and validation from the beginning, and building comprehensive test coverage. As your API grows, Go’s performance characteristics and strong concurrency support will serve you well.
Remember that API development is iterative. Start with a minimal viable implementation, gather feedback, and refine your approach based on real-world usage patterns. Go’s fast compilation and straightforward refactoring make this iteration cycle smooth and productive.
Useful links
- Go Cheat Sheet
- Comparing Go ORMs for PostgreSQL: GORM vs Ent vs Bun vs sqlc
- Multi-Tenancy Database Patterns with examples in Go
- Beautiful Soup Alternatives for Go
- Generating PDF in GO - Libraries and examples
- Constraining LLMs with Structured Output: Ollama, Qwen3 & Python or Go
- Reranking text documents with Ollama and Qwen3 Embedding model - in Go
- Model Context Protocol (MCP), and notes on implementing MCP server in Go
External Resources
Official Documentation
- Go Official Documentation - The official Go documentation and tutorials
- Go net/http Package - Standard library HTTP package documentation
- Effective Go - Best practices for writing clear, idiomatic Go code
Popular Frameworks and Libraries
- Gin Web Framework - Fast HTTP web framework with extensive features
- Chi Router - Lightweight, idiomatic router for building Go HTTP services
- Echo Framework - High performance, extensible, minimalist web framework
- Fiber Framework - Express-inspired web framework built on Fasthttp
- GORM - The fantastic ORM library for Golang
- golang-jwt - JWT implementation for Go
Testing and Development Tools
- Testify - A toolkit with common assertions and mocks
- httptest Package - Standard library utilities for HTTP testing
- Swaggo - Automatically generate RESTful API documentation
- Air - Live reload for Go apps during development
Best Practices and Guides
- Go Project Layout - Standard Go project layouts
- Uber Go Style Guide - Comprehensive Go style guide from Uber
- Go Code Review Comments - Common comments made during Go code reviews
- REST API Design Best Practices - General REST API design principles
Security and Authentication
- OWASP Go Secure Coding Practices - Security guidelines for Go applications
- OAuth2 for Go - OAuth 2.0 implementation
- bcrypt Package - Password hashing implementation
Performance and Monitoring
- pprof - Built-in profiling tool for Go programs
- Prometheus Client - Prometheus instrumentation library for Go
- Zap Logger - Fast, structured, leveled logging