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 tasksPOST /tasks— create a taskGET /tasks/{id}— get onePATCH /tasks/{id}— updateDELETE /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:
- Timeouts. Without
ReadTimeoutandWriteTimeout, a slow client can tie up a goroutine forever. - Context-aware shutdown.
Shutdown(ctx)stops accepting new connections and waits for in-flight requests to finish — within the timeout. - 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 .