Build a small production-minded Go REST API with method-aware routes, JSON validation, server timeouts, tests, and graceful shutdown.
Last updated on

Build a REST API in Go with net/http


Go’s standard library is enough to build a useful REST API. The difficult parts are not starting a server; they are defining clear resource routes, validating JSON, returning consistent errors, setting timeouts, and shutting down without dropping requests.

This guide builds those pieces with net/http and the method-aware routing patterns supported by the standard ServeMux.

Start with a small resource API

Create a module and a main.go file:

mkdir items-api
cd items-api
go mod init example.com/items-api

The API will expose:

  • GET /items/{id} to read an item
  • POST /items to create an item
package main

import (
    "encoding/json"
    "errors"
    "fmt"
    "io"
    "log"
    "net/http"
    "sync"
    "time"
)

type Item struct {
    ID   string `json:"id"`
    Name string `json:"name"`
}

type API struct {
    mu    sync.RWMutex
    items map[string]Item
}

func main() {
    api := &API{items: make(map[string]Item)}
    mux := http.NewServeMux()
    mux.HandleFunc("GET /items/{id}", api.getItem)
    mux.HandleFunc("POST /items", api.createItem)

    server := &http.Server{
        Addr:              ":8080",
        Handler:           mux,
        ReadHeaderTimeout: 5 * time.Second,
        ReadTimeout:       10 * time.Second,
        WriteTimeout:      15 * time.Second,
        IdleTimeout:       60 * time.Second,
    }

    log.Printf("listening on %s", server.Addr)
    if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
        log.Fatal(err)
    }
}

Method-aware patterns keep GET and POST behavior separate without installing a router. r.PathValue("id") reads the {id} wildcard.

Return JSON consistently

Small helpers prevent every handler from implementing headers and error shapes differently.

func writeJSON(w http.ResponseWriter, status int, value any) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    _ = json.NewEncoder(w).Encode(value)
}

func writeError(w http.ResponseWriter, status int, message string) {
    writeJSON(w, status, map[string]string{"error": message})
}

Production code should decide what to do if encoding fails after headers have been sent. For ordinary JSON response structs, that failure is rare; logging it at the server boundary is usually the only remaining option.

Implement the GET handler

func (api *API) getItem(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("id")

    api.mu.RLock()
    item, ok := api.items[id]
    api.mu.RUnlock()

    if !ok {
        writeError(w, http.StatusNotFound, "item not found")
        return
    }
    writeJSON(w, http.StatusOK, item)
}

The mutex matters because net/http runs handlers concurrently. An unprotected map can race or panic when one request writes while another reads.

Decode and validate POST requests

type createItemRequest struct {
    ID   string `json:"id"`
    Name string `json:"name"`
}

func (api *API) createItem(w http.ResponseWriter, r *http.Request) {
    r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
    decoder := json.NewDecoder(r.Body)
    decoder.DisallowUnknownFields()

    var input createItemRequest
    if err := decoder.Decode(&input); err != nil {
        writeError(w, http.StatusBadRequest, "invalid JSON body")
        return
    }
    if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
        writeError(w, http.StatusBadRequest, "body must contain one JSON object")
        return
    }
    if input.ID == "" || input.Name == "" {
        writeError(w, http.StatusBadRequest, "id and name are required")
        return
    }

    item := Item{ID: input.ID, Name: input.Name}
    api.mu.Lock()
    if _, exists := api.items[item.ID]; exists {
        api.mu.Unlock()
        writeError(w, http.StatusConflict, "item already exists")
        return
    }
    api.items[item.ID] = item
    api.mu.Unlock()

    w.Header().Set("Location", fmt.Sprintf("/items/%s", item.ID))
    writeJSON(w, http.StatusCreated, item)
}

The body limit prevents unbounded input. Unknown-field checks catch client typos, and the second decode rejects multiple JSON values. JSON decoding in Go explains these boundary checks in more detail.

Test handlers with httptest

Handlers can be tested without opening a TCP port.

func TestGetItem(t *testing.T) {
    api := &API{items: map[string]Item{
        "42": {ID: "42", Name: "keyboard"},
    }}
    mux := http.NewServeMux()
    mux.HandleFunc("GET /items/{id}", api.getItem)

    request := httptest.NewRequest(http.MethodGet, "/items/42", nil)
    response := httptest.NewRecorder()
    mux.ServeHTTP(response, request)

    if response.Code != http.StatusOK {
        t.Fatalf("expected 200, got %d", response.Code)
    }

    var item Item
    if err := json.NewDecoder(response.Body).Decode(&item); err != nil {
        t.Fatal(err)
    }
    if item.ID != "42" {
        t.Fatalf("expected item 42, got %#v", item)
    }
}

Add table-driven cases for missing items, malformed JSON, unknown fields, duplicate IDs, and unsupported methods. Tests should assert response status, body shape, and important headers such as Content-Type and Location.

Shut the server down gracefully

Real services need to stop accepting new work and give in-flight requests time to finish.

go func() {
    log.Printf("listening on %s", server.Addr)
    if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
        log.Fatal(err)
    }
}()

signals := make(chan os.Signal, 1)
signal.Notify(signals, os.Interrupt, syscall.SIGTERM)
<-signals

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

In a larger application, pass request contexts into database and network operations so shutdown and client cancellation propagate. Read context cancellation in Go and error handling best practices before adding persistence.

What to add next

The in-memory map is deliberately small. A production API will likely add persistence, authentication, request IDs, structured logging, metrics, and rate limiting. Keep those concerns around the handler rather than burying them inside JSON models.

Start with the standard library until a concrete requirement makes a framework useful. net/http already gives you routing, middleware composition through http.Handler, request contexts, robust servers, and excellent testing tools. The net/http package documentation is the authoritative reference.