Build a JSON CRUD API in Go with Gorilla Mux: routing by method, path variables, decoding request bodies, real status codes, and middleware.
This tutorial builds a JSON API for a task list: create, read, update, and delete, each on the method HTTP gives it, each answering with a real status code. You will finish with a service you can curl.
Routing is Gorilla Mux, which is what most Go services use. Type every line yourself. Each step ends with something you can run.
- Go installed.
go versionshould print something. - A terminal, and
curlfor checking each step. - The HTTP note if you want the protocol first. You can also pick it up as you go.
1 Set up the project
Make a folder, turn it into a module, and add the router.
$ mkdir go-task-api
$ cd go-task-api
$ go mod init taskapi
$ go get github.com/gorilla/mux
go: creating new go.mod: module taskapi
go: added github.com/gorilla/mux v1.8.1Create main.go next to go.mod.
2 Serve one route
A mux router is an http.Handler, so it goes straight into ListenAndServe.
package main
import (
"fmt"
"log"
"net/http"
"github.com/gorilla/mux"
)
func main() {
r := mux.NewRouter()
r.HandleFunc("/tasks", func(w http.ResponseWriter, req *http.Request) {
fmt.Fprintln(w, "the task list will live here")
}).Methods(http.MethodGet)
log.Println("listening on :8090")
log.Fatal(http.ListenAndServe(":8090", r))
}$ go run .
2026/08/29 14:22:03 listening on :8090That terminal belongs to the server now. Leave it running and send the requests from a second one.
$ curl localhost:8090/tasks
$ curl -o /dev/null -w '%{http_code}\n' -X POST localhost:8090/tasks
the task list will live here
405.Methods is what produced that 405. The path matched and the method did not, so mux refused it without your handler running. Every step after this one changes the code, so stop the server with Ctrl-C and start it again before the next request.
3 Give a task a type and somewhere to live
The struct tags decide the JSON field names. Every request runs in its own goroutine, so the map needs a mutex.
// Task is one item in the list.
type Task struct {
ID int `json:"id"`
Title string `json:"title"`
Done bool `json:"done"`
}
// store holds the tasks in memory.
type store struct {
mu sync.Mutex
tasks map[int]Task
nextID int
}
func newStore() *store {
return &store{
tasks: map[int]Task{
1: {ID: 1, Title: "Write the handler", Done: true},
2: {ID: 2, Title: "Add the router", Done: false},
},
nextID: 3,
}
}Add one helper that every handler will use, and a type to hang the handlers off.
// writeJSON sends v with the right content type and status.
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
type api struct{ store *store }WriteHeader has to come after the headers and before the body. Once you write a byte, the status is already sent.
4 List every task
Map order is random, so sort by ID before answering. An API that shuffles its own list is hard to trust.
func (s *store) list() []Task {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]Task, 0, len(s.tasks))
for _, t := range s.tasks {
out = append(out, t)
}
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
return out
}
func (a *api) listTasks(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, a.store.list())
}Wire it up in main, replacing the placeholder handler.
a := &api{store: newStore()}
r := mux.NewRouter()
r.HandleFunc("/tasks", a.listTasks).Methods(http.MethodGet)$ curl localhost:8090/tasks
[{"id":1,"title":"Write the handler","done":true},{"id":2,"title":"Add the router","done":false}]5 Read one task
The {id:[0-9]+} pattern captures a segment and refuses anything that is not digits, so a junk id never reaches the handler.
func (a *api) getTask(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(mux.Vars(r)["id"])
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "id must be a number"})
return
}
t, ok := a.store.get(id)
if !ok {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "no such task"})
return
}
writeJSON(w, http.StatusOK, t)
}r.HandleFunc("/tasks/{id:[0-9]+}", a.getTask).Methods(http.MethodGet)$ curl localhost:8090/tasks/1
$ curl -w ' %{http_code}\n' localhost:8090/tasks/99
{"id":1,"title":"Write the handler","done":true}
{"error":"no such task"} 404mux.Vars hands back map[string]string, so every captured value arrives as text and you convert it yourself.
6 Create a task
Decode the body into a Task, check what matters, then answer 201 with a Location header pointing at the new thing.
func (a *api) createTask(w http.ResponseWriter, r *http.Request) {
var in Task
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "body must be JSON"})
return
}
if in.Title == "" {
writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": "title is required"})
return
}
t := a.store.add(in.Title)
w.Header().Set("Location", "/tasks/"+strconv.Itoa(t.ID))
writeJSON(w, http.StatusCreated, t)
}r.HandleFunc("/tasks", a.createTask).Methods(http.MethodPost)$ curl -w ' %{http_code}\n' -X POST localhost:8090/tasks -d '{"title":"Ship it"}'
$ curl -w ' %{http_code}\n' -X POST localhost:8090/tasks -d '{}'
{"id":3,"title":"Ship it","done":false} 201
{"error":"title is required"} 422Two routes now share /tasks and split on the method. 400 means the server could not parse it; 422 means it parsed and the content is still wrong.
7 Update a task
PUT replaces the whole task, so the body carries every field. Anything left out is cleared.
func (a *api) updateTask(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(mux.Vars(r)["id"])
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "id must be a number"})
return
}
var in Task
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "body must be JSON"})
return
}
t, ok := a.store.replace(id, in.Title, in.Done)
if !ok {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "no such task"})
return
}
writeJSON(w, http.StatusOK, t)
}r.HandleFunc("/tasks/{id:[0-9]+}", a.updateTask).Methods(http.MethodPut)$ curl -X PUT localhost:8090/tasks/2 -d '{"title":"Add the router","done":true}'
{"id":2,"title":"Add the router","done":true}8 Delete a task
There is nothing to send back, so send 204 and write no body.
func (a *api) deleteTask(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(mux.Vars(r)["id"])
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "id must be a number"})
return
}
if !a.store.remove(id) {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "no such task"})
return
}
w.WriteHeader(http.StatusNoContent)
}r.HandleFunc("/tasks/{id:[0-9]+}", a.deleteTask).Methods(http.MethodDelete)$ curl -o /dev/null -w '%{http_code}\n' -X DELETE localhost:8090/tasks/1
$ curl localhost:8090/tasks
204
[{"id":2,"title":"Add the router","done":true},{"id":3,"title":"Ship it","done":false}]All four CRUD operations are live, and the list reflects every one of them.
9 Log every request
Middleware takes a handler and returns one. r.Use wraps every route on the router with it.
// logging wraps every handler, so one line is printed per request.
func logging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
log.Printf("%s %s", r.Method, r.URL.Path)
next.ServeHTTP(w, r)
})
}r.Use(logging)$ curl -s -o /dev/null localhost:8090/tasks
$ curl -s -o /dev/null -X DELETE localhost:8090/tasks/2
2026/08/31 14:02:11 GET /tasks
2026/08/31 14:02:11 DELETE /tasks/2Those two lines appear in the terminal running the server, with your own timestamps. Auth, request IDs, and panic recovery all go in the same place.
The whole program main.go Show
package main
import (
"encoding/json"
"log"
"net/http"
"sort"
"strconv"
"sync"
"github.com/gorilla/mux"
)
// Task is one item in the list.
type Task struct {
ID int `json:"id"`
Title string `json:"title"`
Done bool `json:"done"`
}
// store holds the tasks in memory. Every request runs in its own goroutine,
// so the map is guarded by a mutex.
type store struct {
mu sync.Mutex
tasks map[int]Task
nextID int
}
func newStore() *store {
return &store{
tasks: map[int]Task{
1: {ID: 1, Title: "Write the handler", Done: true},
2: {ID: 2, Title: "Add the router", Done: false},
},
nextID: 3,
}
}
func (s *store) list() []Task {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]Task, 0, len(s.tasks))
for _, t := range s.tasks {
out = append(out, t)
}
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
return out
}
func (s *store) get(id int) (Task, bool) {
s.mu.Lock()
defer s.mu.Unlock()
t, ok := s.tasks[id]
return t, ok
}
func (s *store) add(title string) Task {
s.mu.Lock()
defer s.mu.Unlock()
t := Task{ID: s.nextID, Title: title}
s.tasks[t.ID] = t
s.nextID++
return t
}
func (s *store) replace(id int, title string, done bool) (Task, bool) {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.tasks[id]; !ok {
return Task{}, false
}
t := Task{ID: id, Title: title, Done: done}
s.tasks[id] = t
return t, true
}
func (s *store) remove(id int) bool {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.tasks[id]; !ok {
return false
}
delete(s.tasks, id)
return true
}
// writeJSON sends v with the right content type and status.
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
// idFrom reads the {id} placeholder mux captured from the path.
func idFrom(r *http.Request) (int, error) {
return strconv.Atoi(mux.Vars(r)["id"])
}
type api struct{ store *store }
func (a *api) listTasks(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, a.store.list())
}
func (a *api) getTask(w http.ResponseWriter, r *http.Request) {
id, err := idFrom(r)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "id must be a number"})
return
}
t, ok := a.store.get(id)
if !ok {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "no such task"})
return
}
writeJSON(w, http.StatusOK, t)
}
func (a *api) createTask(w http.ResponseWriter, r *http.Request) {
var in Task
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "body must be JSON"})
return
}
if in.Title == "" {
writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": "title is required"})
return
}
t := a.store.add(in.Title)
w.Header().Set("Location", "/tasks/"+strconv.Itoa(t.ID))
writeJSON(w, http.StatusCreated, t)
}
func (a *api) updateTask(w http.ResponseWriter, r *http.Request) {
id, err := idFrom(r)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "id must be a number"})
return
}
var in Task
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "body must be JSON"})
return
}
t, ok := a.store.replace(id, in.Title, in.Done)
if !ok {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "no such task"})
return
}
writeJSON(w, http.StatusOK, t)
}
func (a *api) deleteTask(w http.ResponseWriter, r *http.Request) {
id, err := idFrom(r)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "id must be a number"})
return
}
if !a.store.remove(id) {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "no such task"})
return
}
w.WriteHeader(http.StatusNoContent)
}
// logging wraps every handler, so one line is printed per request.
func logging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
log.Printf("%s %s", r.Method, r.URL.Path)
next.ServeHTTP(w, r)
})
}
func main() {
a := &api{store: newStore()}
r := mux.NewRouter()
r.Use(logging)
r.HandleFunc("/tasks", a.listTasks).Methods(http.MethodGet)
r.HandleFunc("/tasks", a.createTask).Methods(http.MethodPost)
r.HandleFunc("/tasks/{id:[0-9]+}", a.getTask).Methods(http.MethodGet)
r.HandleFunc("/tasks/{id:[0-9]+}", a.updateTask).Methods(http.MethodPut)
r.HandleFunc("/tasks/{id:[0-9]+}", a.deleteTask).Methods(http.MethodDelete)
log.Println("listening on :8090")
log.Fatal(http.ListenAndServe(":8090", r))
}What you built
You have a JSON API with the four CRUD operations, each on its own method, each answering with the status code that fits. A read gets 200, a create gets 201 plus a Location header, a delete gets 204, a caller in the wrong gets 404 or 422, and the router itself sends 405.
Four tools did that work. mux.NewRouter matched paths and methods, mux.Vars read the captured id, encoding/json carried the bodies both ways, and a sync.Mutex kept the map safe across goroutines.
Swapping the map for a database changes only the store methods. The handlers stay as they are.