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 itemPOST /itemsto 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.