A common reflex when starting a Go API is to reach for Gin, Echo, or Chi. They’re all great frameworks, but Go’s standard library is surprisingly capable on its own — especially since Go 1.22, when the routing patterns got real upgrades.

This post walks through building a complete REST API using only net/http: routing, middleware, JSON, validation, error handling, and graceful shutdown. By the end you’ll have a clear picture of what frameworks add — and what they don’t.

If you’re brand new to Go, start with Getting Started with Go for Backend Developers .

The API we’re building

A small “tasks” API:

  • GET /tasks — list tasks
  • POST /tasks — create a task
  • GET /tasks/{id} — get one
  • PATCH /tasks/{id} — update
  • DELETE /tasks/{id} — delete

We’ll use an in-memory store; swap it for SQL when you’re ready.

Project setup

mkdir tasks-api && cd tasks-api
go mod init github.com/AlzyWelzy/tasks-api
mkdir -p cmd/server internal/api internal/store internal/domain

The domain type

// internal/domain/task.go
package domain

import "time"

type Task struct {
    ID          int64     `json:"id"`
    Title       string    `json:"title"`
    Description string    `json:"description,omitempty"`
    Completed   bool      `json:"completed"`
    CreatedAt   time.Time `json:"created_at"`
    UpdatedAt   time.Time `json:"updated_at"`
}

The struct tags (json:"id") tell encoding/json how to marshal and unmarshal. omitempty drops the field if it’s the zero value — useful for optional fields.

The store (in-memory for now)

// internal/store/memory.go
package store

import (
    "errors"
    "sync"
    "time"

    "github.com/AlzyWelzy/tasks-api/internal/domain"
)

var ErrNotFound = errors.New("task not found")

type Memory struct {
    mu     sync.RWMutex
    tasks  map[int64]*domain.Task
    nextID int64
}

func NewMemory() *Memory {
    return &Memory{tasks: make(map[int64]*domain.Task)}
}

func (m *Memory) List() []*domain.Task {
    m.mu.RLock()
    defer m.mu.RUnlock()
    out := make([]*domain.Task, 0, len(m.tasks))
    for _, t := range m.tasks {
        out = append(out, t)
    }
    return out
}

func (m *Memory) Get(id int64) (*domain.Task, error) {
    m.mu.RLock()
    defer m.mu.RUnlock()
    t, ok := m.tasks[id]
    if !ok {
        return nil, ErrNotFound
    }
    return t, nil
}

func (m *Memory) Create(title, desc string) *domain.Task {
    m.mu.Lock()
    defer m.mu.Unlock()
    m.nextID++
    now := time.Now()
    t := &domain.Task{
        ID: m.nextID, Title: title, Description: desc,
        CreatedAt: now, UpdatedAt: now,
    }
    m.tasks[t.ID] = t
    return t
}

func (m *Memory) Update(id int64, title *string, completed *bool) (*domain.Task, error) {
    m.mu.Lock()
    defer m.mu.Unlock()
    t, ok := m.tasks[id]
    if !ok {
        return nil, ErrNotFound
    }
    if title != nil {
        t.Title = *title
    }
    if completed != nil {
        t.Completed = *completed
    }
    t.UpdatedAt = time.Now()
    return t, nil
}

func (m *Memory) Delete(id int64) error {
    m.mu.Lock()
    defer m.mu.Unlock()
    if _, ok := m.tasks[id]; !ok {
        return ErrNotFound
    }
    delete(m.tasks, id)
    return nil
}

sync.RWMutex lets many readers in at once but only one writer at a time. For a real DB-backed store, this whole file becomes much simpler — the database does the locking.

Helpers: JSON I/O and structured errors

These two helpers will keep your handlers tidy:

// internal/api/json.go
package api

import (
    "encoding/json"
    "net/http"
)

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})
}

func decodeJSON(r *http.Request, v any) error {
    dec := json.NewDecoder(r.Body)
    dec.DisallowUnknownFields()
    return dec.Decode(v)
}

DisallowUnknownFields rejects payloads with extra keys — catches client bugs early.

The handlers

Go 1.22 added typed path parameters: GET /tasks/{id} lets you read r.PathValue("id"). Before 1.22 you needed a third-party router for this.

// internal/api/handlers.go
package api

import (
    "errors"
    "net/http"
    "strconv"
    "strings"

    "github.com/AlzyWelzy/tasks-api/internal/store"
)

type Server struct {
    Store *store.Memory
}

func (s *Server) listTasks(w http.ResponseWriter, r *http.Request) {
    writeJSON(w, http.StatusOK, s.Store.List())
}

type createReq struct {
    Title       string `json:"title"`
    Description string `json:"description"`
}

func (s *Server) createTask(w http.ResponseWriter, r *http.Request) {
    var req createReq
    if err := decodeJSON(r, &req); err != nil {
        writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
        return
    }
    if strings.TrimSpace(req.Title) == "" {
        writeError(w, http.StatusUnprocessableEntity, "title is required")
        return
    }
    task := s.Store.Create(req.Title, req.Description)
    writeJSON(w, http.StatusCreated, task)
}

func (s *Server) getTask(w http.ResponseWriter, r *http.Request) {
    id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
    if err != nil {
        writeError(w, http.StatusBadRequest, "invalid id")
        return
    }
    task, err := s.Store.Get(id)
    if errors.Is(err, store.ErrNotFound) {
        writeError(w, http.StatusNotFound, "task not found")
        return
    }
    writeJSON(w, http.StatusOK, task)
}

type updateReq struct {
    Title     *string `json:"title"`
    Completed *bool   `json:"completed"`
}

func (s *Server) updateTask(w http.ResponseWriter, r *http.Request) {
    id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
    if err != nil {
        writeError(w, http.StatusBadRequest, "invalid id")
        return
    }
    var req updateReq
    if err := decodeJSON(r, &req); err != nil {
        writeError(w, http.StatusBadRequest, "invalid JSON")
        return
    }
    task, err := s.Store.Update(id, req.Title, req.Completed)
    if errors.Is(err, store.ErrNotFound) {
        writeError(w, http.StatusNotFound, "task not found")
        return
    }
    writeJSON(w, http.StatusOK, task)
}

func (s *Server) deleteTask(w http.ResponseWriter, r *http.Request) {
    id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
    if err != nil {
        writeError(w, http.StatusBadRequest, "invalid id")
        return
    }
    if err := s.Store.Delete(id); errors.Is(err, store.ErrNotFound) {
        writeError(w, http.StatusNotFound, "task not found")
        return
    }
    w.WriteHeader(http.StatusNoContent)
}

func (s *Server) health(w http.ResponseWriter, r *http.Request) {
    writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}

Notice *string and *bool in updateReq. They’re pointers so we can tell the difference between “not provided” (nil) and “provided but empty/false”. This is the Go-idiomatic way to do PATCH semantics.

Routing (Go 1.22+)

// internal/api/router.go
package api

import "net/http"

func (s *Server) Routes() http.Handler {
    mux := http.NewServeMux()

    mux.HandleFunc("GET /health", s.health)
    mux.HandleFunc("GET /tasks", s.listTasks)
    mux.HandleFunc("POST /tasks", s.createTask)
    mux.HandleFunc("GET /tasks/{id}", s.getTask)
    mux.HandleFunc("PATCH /tasks/{id}", s.updateTask)
    mux.HandleFunc("DELETE /tasks/{id}", s.deleteTask)

    return chain(mux, requestLogger, recoverer)
}

Note the route patterns: "GET /tasks/{id}". Method + path in a single string. This is Go 1.22’s built-in routing.

Middleware

In Go, middleware is just func(http.Handler) http.Handler. Composable, simple:

// internal/api/middleware.go
package api

import (
    "log"
    "net/http"
    "time"
)

type Middleware func(http.Handler) http.Handler

func chain(h http.Handler, mws ...Middleware) http.Handler {
    for i := len(mws) - 1; i >= 0; i-- {
        h = mws[i](h)
    }
    return h
}

func requestLogger(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        rw := &statusRecorder{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 recoverer(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if rec := recover(); rec != nil {
                log.Printf("panic: %v", rec)
                writeError(w, http.StatusInternalServerError, "internal error")
            }
        }()
        next.ServeHTTP(w, r)
    })
}

type statusRecorder struct {
    http.ResponseWriter
    status int
}

func (r *statusRecorder) WriteHeader(s int) {
    r.status = s
    r.ResponseWriter.WriteHeader(s)
}

requestLogger logs every request. recoverer turns panics into proper 500s instead of crashing the server. These two are essentially mandatory for any production server.

The main entry point

// cmd/server/main.go
package main

import (
    "context"
    "log"
    "net/http"
    "os"
    "os/signal"
    "syscall"
    "time"

    "github.com/AlzyWelzy/tasks-api/internal/api"
    "github.com/AlzyWelzy/tasks-api/internal/store"
)

func main() {
    addr := os.Getenv("ADDR")
    if addr == "" {
        addr = ":8080"
    }

    server := &api.Server{Store: store.NewMemory()}
    httpServer := &http.Server{
        Addr:              addr,
        Handler:           server.Routes(),
        ReadTimeout:       5 * time.Second,
        WriteTimeout:      10 * time.Second,
        IdleTimeout:       120 * time.Second,
        ReadHeaderTimeout: 2 * time.Second,
    }

    // Start server
    go func() {
        log.Printf("listening on %s", addr)
        if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            log.Fatalf("server error: %v", err)
        }
    }()

    // Graceful shutdown
    stop := make(chan os.Signal, 1)
    signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
    <-stop
    log.Println("shutting down…")

    ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()
    if err := httpServer.Shutdown(ctx); err != nil {
        log.Printf("shutdown error: %v", err)
    }
    log.Println("bye")
}

Three things every production HTTP server needs:

  1. Timeouts. Without ReadTimeout and WriteTimeout, a slow client can tie up a goroutine forever.
  2. Context-aware shutdown. Shutdown(ctx) stops accepting new connections and waits for in-flight requests to finish — within the timeout.
  3. Signal handling. Without it, SIGTERM kills the process mid-request and any in-flight work is lost.

Run it

go run ./cmd/server
# 2026/04/28 14:30:01 listening on :8080

# Another terminal
curl -X POST localhost:8080/tasks -d '{"title":"Buy milk"}'
# {"id":1,"title":"Buy milk","completed":false,"created_at":"...","updated_at":"..."}

curl localhost:8080/tasks
# [{"id":1,"title":"Buy milk", ...}]

curl -X PATCH localhost:8080/tasks/1 -d '{"completed":true}'
# {"id":1,"title":"Buy milk","completed":true, ...}

Three files, ~200 lines, zero dependencies. That’s a working API.

What about validation?

For more than if title == "", use go-playground/validator :

import "github.com/go-playground/validator/v10"

type createReq struct {
    Title       string `json:"title" validate:"required,min=1,max=200"`
    Description string `json:"description" validate:"max=2000"`
}

var validate = validator.New()

func (s *Server) createTask(w http.ResponseWriter, r *http.Request) {
    var req createReq
    if err := decodeJSON(r, &req); err != nil { ... }
    if err := validate.Struct(req); err != nil {
        writeError(w, http.StatusUnprocessableEntity, err.Error())
        return
    }
    // ...
}

That covers 90% of API validation needs.

When to reach for a framework

The stdlib carries you a long way. Reach for Gin, Echo, or Chi when you want:

  • A more powerful routing DSL (groups, prefixes, route-level middleware in one expression).
  • Built-in helpers for common things (binding, validation, content negotiation).
  • A larger ecosystem of community middleware.

We compare them in Building APIs with Gin, Echo, and Chi .

But the stdlib is enough for many production services. Plenty of teams ship net/http directly — the lack of magic is a feature.

Conclusion

Go’s net/http package is one of the best stdlib HTTP libraries in any language. With a few small helpers (JSON I/O, error formatting, middleware), you have a real, production-ready API. No framework, no magic, very little to learn.

Build a small API end-to-end with this, ship it somewhere, and you’ll understand exactly what frameworks add — and what you may not need them for.

Happy serving!


Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .