Building a REST API in Go
Go is an outstanding choice for building REST APIs. The standard library includes a production-grade HTTP server, and the language’s simplicity makes APIs easy to understand, test, and deploy. This guide walks through building a complete REST API from scratch.
Setting Up the Project
mkdir rest-api
cd rest-api
go mod init github.com/username/rest-apiCreate the basic structure:
rest-api/
├── main.go
├── handler/
│ └── handler.go
├── model/
│ └── item.go
├── store/
│ └── store.go
└── middleware/
└── logging.goEach directory is a package. The main.go file wires everything together, while business logic lives in its own packages for testability and separation of concerns.
A Minimal HTTP Server
// main.go
package main
import (
"encoding/json"
"log"
"net/http"
"time"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /health", healthHandler)
mux.HandleFunc("GET /api/items", listItemsHandler)
mux.HandleFunc("POST /api/items", createItemHandler)
server := &http.Server{
Addr: ":8080",
Handler: mux,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 30 * time.Second,
}
log.Println("Server starting on :8080")
if err := server.ListenAndServe(); err != nil {
log.Fatal(err)
}
}
func healthHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "healthy"})
}This example uses Go 1.22’s enhanced ServeMux which supports method-based routing ("GET /api/items"). In earlier versions, you would need a third-party router like gorilla/mux or chi for this pattern.
Handling Requests and Responses
A well-structured handler separates concerns: parse the request, call business logic, and write the response.
// handler/handler.go
package handler
import (
"encoding/json"
"net/http"
"strconv"
"github.com/username/rest-api/store"
)
type ItemHandler struct {
store *store.ItemStore
}
func NewItemHandler(s *store.ItemStore) *ItemHandler {
return &ItemHandler{store: s}
}
func (h *ItemHandler) List(w http.ResponseWriter, r *http.Request) {
items := h.store.GetAll()
writeJSON(w, http.StatusOK, items)
}
func (h *ItemHandler) GetByID(w http.ResponseWriter, r *http.Request) {
idStr := r.PathValue("id")
id, err := strconv.Atoi(idStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
item, ok := h.store.GetByID(id)
if !ok {
writeError(w, http.StatusNotFound, "item not found")
return
}
writeJSON(w, http.StatusOK, item)
}
func (h *ItemHandler) Create(w http.ResponseWriter, r *http.Request) {
var input struct {
Name string `json:"name"`
Price float64 `json:"price"`
}
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
defer r.Body.Close()
if input.Name == "" {
writeError(w, http.StatusBadRequest, "name is required")
return
}
item := h.store.Create(input.Name, input.Price)
writeJSON(w, http.StatusCreated, item)
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}Data Layer
// store/store.go
package store
import (
"sync"
"time"
"github.com/username/rest-api/model"
)
type ItemStore struct {
mu sync.RWMutex
items map[int]model.Item
nextID int
}
func NewItemStore() *ItemStore {
return &ItemStore{
items: make(map[int]model.Item),
nextID: 1,
}
}
func (s *ItemStore) GetAll() []model.Item {
s.mu.RLock()
defer s.mu.RUnlock()
result := make([]model.Item, 0, len(s.items))
for _, item := range s.items {
result = append(result, item)
}
return result
}
func (s *ItemStore) GetByID(id int) (model.Item, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
item, ok := s.items[id]
return item, ok
}
func (s *ItemStore) Create(name string, price float64) model.Item {
s.mu.Lock()
defer s.mu.Unlock()
item := model.Item{
ID: s.nextID,
Name: name,
Price: price,
CreatedAt: time.Now(),
}
s.items[s.nextID] = item
s.nextID++
return item
}The sync.RWMutex allows concurrent reads without blocking, while writes still get exclusive access — a pattern used in many Go production services.
Middleware
Middleware wraps an http.Handler to add cross-cutting behavior.
// middleware/logging.go
package middleware
import (
"log"
"net/http"
"time"
)
type responseWriter struct {
http.ResponseWriter
status int
}
func (rw *responseWriter) WriteHeader(code int) {
rw.status = code
rw.ResponseWriter.WriteHeader(code)
}
func Logging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rw := &responseWriter{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rw, r)
log.Printf("%s %s %d %s", r.Method, r.URL.Path, rw.status, time.Since(start))
})
}
func Recovery(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
log.Printf("panic recovered: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
func CORS(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")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}Middleware is composed in main.go:
func main() {
mux := http.NewServeMux()
// ... register routes
handler := middleware.Logging(middleware.Recovery(mux))
// "Apply to specific routes" pattern:
handler = middleware.CORS(handler)
log.Println("Server starting on :8080")
http.ListenAndServe(":8080", handler)
}Error Handling Strategy
Go REST APIs should use consistent error responses. Define standard error types:
// model/errors.go
package model
type APIError struct {
Code int `json:"code"`
Message string `json:"message"`
}
func (e APIError) Error() string {
return e.Message
}
var (
ErrNotFound = APIError{Code: 404, Message: "not found"}
ErrBadRequest = APIError{Code: 400, Message: "bad request"}
ErrConflict = APIError{Code: 409, Message: "resource conflict"}
)Connecting a Database
For real applications, replace the in-memory store with a database. Here is a PostgreSQL example using pgx:
// store/pg_store.go
package store
import (
"context"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/username/rest-api/model"
)
type PGStore struct {
pool *pgxpool.Pool
}
func NewPGStore(databaseURL string) (*PGStore, error) {
pool, err := pgxpool.New(context.Background(), databaseURL)
if err != nil {
return nil, err
}
return &PGStore{pool: pool}, nil
}
func (s *PGStore) GetAll(ctx context.Context) ([]model.Item, error) {
rows, err := s.pool.Query(ctx, "SELECT id, name, price, created_at FROM items")
if err != nil {
return nil, err
}
defer rows.Close()
var items []model.Item
for rows.Next() {
var item model.Item
if err := rows.Scan(&item.ID, &item.Name, &item.Price, &item.CreatedAt); err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}Testing the API
Table-driven tests with subtests provide excellent coverage:
// handler/handler_test.go
package handler
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/username/rest-api/store"
)
func TestCreateItem(t *testing.T) {
s := store.NewItemStore()
h := NewItemHandler(s)
t.Run("valid item", func(t *testing.T) {
body := `{"name":"Widget","price":9.99}`
req := httptest.NewRequest("POST", "/api/items", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
h.Create(rec, req)
if rec.Code != http.StatusCreated {
t.Errorf("expected status 201, got %d", rec.Code)
}
var item map[string]any
json.NewDecoder(rec.Body).Decode(&item)
if item["name"] != "Widget" {
t.Errorf("expected name Widget, got %v", item["name"])
}
})
t.Run("missing name", func(t *testing.T) {
body := `{"price":9.99}`
req := httptest.NewRequest("POST", "/api/items", strings.NewReader(body))
rec := httptest.NewRecorder()
h.Create(rec, req)
if rec.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d", rec.Code)
}
})
}Deployment
# Dockerfile
FROM golang:1.22 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o server .
FROM scratch
COPY --from=builder /app/server /server
EXPOSE 8080
ENTRYPOINT ["/server"]Build for deployment:
GOOS=linux GOARCH=amd64 go build -o server .
docker build -t rest-api .
docker run -p 8080:8080 rest-apiThe scratch base image produces an image under 10 MB with zero vulnerabilities — a significant operational advantage over Node.js or Python deployments.
API Versioning Strategies
Version your API to maintain backward compatibility:
// URL-based versioning
mux.HandleFunc("GET /v1/api/items", v1Handler)
mux.HandleFunc("GET /v2/api/items", v2Handler)
// Header-based versioning
func versionMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
version := r.Header.Get("Accept-Version")
ctx := context.WithValue(r.Context(), "version", version)
next.ServeHTTP(w, r.WithContext(ctx))
})
}Rate Limiting
type RateLimiter struct {
mu sync.Mutex
visitors map[string]*visitor
rate int
burst int
}
type visitor struct {
limiter *rate.Limiter
lastSeen time.Time
}
func (rl *RateLimiter) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ip := r.RemoteAddr
rl.mu.Lock()
v, exists := rl.visitors[ip]
if !exists {
v = &visitor{limiter: rate.NewLimiter(rate.Limit(rl.rate), rl.burst)}
rl.visitors[ip] = v
}
v.lastSeen = time.Now()
rl.mu.Unlock()
if !v.limiter.Allow() {
http.Error(w, "Too Many Requests", http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, r)
})
}Advanced Patterns in Go
Context Propagation
Go’s context package is essential for managing timeouts, cancellations, and request-scoped values across API boundaries and goroutines:
func handler(ctx context.Context) error {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
result, err := queryDatabase(ctx)
if err != nil {
return fmt.Errorf("query failed: %w", err)
}
return result
}Memory Management
Go’s garbage collector has been optimized significantly since version 1.5. The GC is concurrent and generational, with typical pause times under 1ms. For latency-critical applications, you can tune GC behavior with GOGC (target heap growth percentage) and GOMEMLIMIT (soft memory cap).
# Aggressive GC for latency-sensitive apps
export GOGC=50 # trigger GC when heap grows 50%
export GOMEMLIMIT=2GiB # soft memory limitBuild Tags and Conditional Compilation
Build tags let you compile platform-specific or feature-flag code:
//go:build linux
// +build linux
package main
func getOS() string {
return "linux"
}go build -tags=debug,linux .Pprof Profiling
import _ "net/http/pprof"
// Access profiles at /debug/pprof/
go tool pprof http://localhost:8080/debug/pprof/heap
go tool pprof http://localhost:8080/debug/pprof/profile?seconds=30FAQ
Q: Should I use a framework for Go REST APIs? A: The standard library is sufficient for most APIs. Frameworks like Gin or Chi add convenience (param routing, middleware chaining) but also add dependencies and abstraction.
Q: How do I handle authentication?
A: Common approaches include JWT tokens (validated via middleware), API keys in headers, and OAuth2 flows. The golang-jwt/jwt package is widely used for JWT.
Q: How do I version my API?
A: Use URL prefix versioning (/v1/api/items) or content negotiation. Go handles both cleanly through ServeMux or middleware.
Q: What about rate limiting? A: Implement token bucket or sliding window rate limiting in middleware. For distributed rate limiting, use Redis as a central counter store.
Q: How do I handle database migrations?
A: Use tools like golang-migrate/migrate or pressly/goose. Run migrations at startup or as a separate init container in Kubernetes.
Q: How do I configure different environments?
A: Use environment variables with os.Getenv and reasonable defaults. Libraries like spf13/viper add file-based configuration support.
Q: How do I structure a larger API? A: Group related endpoints into handler files, use dependency injection for store/database access, and keep business logic separate from transport concerns.
Related Articles
Go
Working with JSON in Go
Comprehensive guide to JSON encoding and decoding in Go. Covers marshal, unmarshal, custom types, streaming, and best practices for production APIs.
Go
Go Error Handling: Best Practices and Patterns
Master error handling in Go — errors.Is, errors.As, custom error types, wrapping, and idiomatic error handling patterns.
Go
Go Interfaces: A Comprehensive Guide
Learn how to use Go interfaces effectively. Understand interface types, satisfaction rules, type assertions, empty interfaces, and common patterns.
Go
Go Packages and Modules: A Complete Guide
Learn how to organize Go code into packages, manage dependencies with modules, and publish reusable libraries. Covers go.mod, importing, versioning, and best practices.
Go
Go Programming: Getting Started with Go
A beginner-friendly guide to the Go programming language. Learn installation, project structure, basic syntax, and write your first Go program.