Skip to content
Home
Building a REST API in Go

Building a REST API in Go

Go Go 8 min read 1592 words Beginner ExcellentWiki Editorial Team

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-api

Create the basic structure:

rest-api/
├── main.go
├── handler/
│   └── handler.go
├── model/
│   └── item.go
├── store/
│   └── store.go
└── middleware/
    └── logging.go

Each 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-api

The 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 limit

Build 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=30

FAQ

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.

Section: Go 1592 words 8 min read Beginner 756 articles in section Report inaccuracy Back to top