Skip to content
Home
Building a REST API in Go

Building a REST API in Go

Go Go 8 min read 1592 words Beginner David Chen

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 1251 articles in section Report inaccuracy Back to top
DC
David Chen Technology Editor

Technology Editor at ExcellentWiki covering programming, engineering, and consumer technology topics.

View author profile →