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.
For a comprehensive overview, read our article on Getting Started With Go.
For a comprehensive overview, read our article on Go Concurrency Guide.