Building a TODO API in Go
You need a backend that accepts JSON, stores it, and sends it back. Maybe you are building a frontend prototype and need a mock server. Or you just want to see how Go handles HTTP without pulling in a heavy framework. The standard library gives you everything you need in a single file. No go get required. Just go run. This approach strips away the noise of web frameworks and shows the mechanics of routing, request parsing, and response writing. You get a working API that handles concurrent requests safely, ready to evolve into a production service.
How the pieces fit together
A CRUD API maps HTTP verbs to actions. GET reads data, POST creates a new record, PUT updates an existing one, and DELETE removes it. Go's net/http package provides a multiplexer, ServeMux, that routes URL patterns to handler functions. Each handler receives an http.ResponseWriter to send data back and an *http.Request containing the incoming details.
The server runs multiple goroutines to handle connections. If two requests try to modify the same data at once, you get a data race. A sync.Mutex acts as a lock. Only one goroutine can hold the lock at a time. Others block until it is released. This keeps the data consistent.
Go conventions shape the code. Struct fields that need to be visible to the JSON encoder must start with a capital letter. Public names are exported. Private names start lowercase. There are no keywords like public or private. The visibility is determined by the first letter. Struct tags map Go field names to JSON keys. The tag json:"id" tells the encoder to use id in the output, even though the field is ID.
Trust gofmt. The community uses a single formatting tool. Most editors run it on save. Don't argue about indentation or brace placement. Let the tool decide. Argue logic, not formatting.
Data model and state
Here is the struct definition and the package-level variables that hold the state. The slice acts as an in-memory database. The mutex protects it from concurrent access.
package main
import (
"encoding/json"
"net/http"
"sync"
)
// Todo defines the shape of a task.
// JSON tags map struct fields to lowercase keys in the payload.
type Todo struct {
ID int `json:"id"`
Title string `json:"title"`
Done bool `json:"done"`
}
// State variables live at package level for simplicity.
// mu ensures only one goroutine modifies the slice at a time.
var (
todos []Todo
nextID int = 1
mu sync.Mutex
)
The Todo struct uses exported fields so the JSON encoder can read them. The tags control the JSON output. Without tags, the keys would be ID, Title, and Done. APIs usually prefer lowercase keys. The sync.Mutex is zero-valued and ready to use immediately. You don't need to initialize it.
Routing and server startup
The main function creates a mux, registers handlers, and starts the server. The mux matches URL patterns to functions.
// main sets up the router and starts the server.
func main() {
mux := http.NewServeMux()
// Handle the collection endpoint.
mux.HandleFunc("/todos", handleTodos)
// Handle individual items with a trailing slash pattern.
mux.HandleFunc("/todos/", handleSingleTodo)
// ListenAndServe blocks the main goroutine.
http.ListenAndServe(":8080", mux)
}
The pattern /todos matches exactly that path. The pattern /todos/ matches any path that starts with /todos/. This distinction is important. If you register /todos without a trailing slash, a request to /todos/1 will not match. The trailing slash pattern catches sub-paths. http.ListenAndServe blocks until the process exits. It spawns a goroutine for each incoming connection.
Handling the collection
The handler for /todos manages GET and POST requests. It locks the mutex before touching the slice.
// handleTodos manages GET and POST requests for the /todos endpoint.
func handleTodos(w http.ResponseWriter, r *http.Request) {
// Lock the mutex before touching shared state.
mu.Lock()
defer mu.Unlock()
switch r.Method {
case http.MethodGet:
// Encode the entire slice to JSON and write to response.
json.NewEncoder(w).Encode(todos)
case http.MethodPost:
handleCreate(w, r)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
The switch on r.Method routes based on the HTTP verb. http.MethodGet and http.MethodPost are string constants. Using constants avoids typos. defer mu.Unlock() ensures the lock releases when the function returns, even if an error occurs. This pattern is standard in Go. Lock early, unlock with defer.
The handleCreate function decodes the request body and appends a new item. It assumes the caller holds the lock.
// handleCreate decodes the request body and appends a new todo.
// The caller must hold the lock.
func handleCreate(w http.ResponseWriter, r *http.Request) {
var todo Todo
// Decode JSON from the request body into the struct.
if err := json.NewDecoder(r.Body).Decode(&todo); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
// Assign a unique ID and append to the slice.
todo.ID = nextID
nextID++
todos = append(todos, todo)
// Return 201 Created with the new item.
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(todo)
}
json.NewDecoder reads directly from the request body stream. It populates the todo struct. If the JSON is malformed, Decode returns an error. The handler checks the error and returns a 400 status. Always check errors from JSON decoding. Ignoring the error can lead to inserting zero-value records. The community accepts the if err != nil boilerplate because it makes the unhappy path visible. Don't hide errors.
The w.WriteHeader call sets the status code to 201. This must happen before writing the body. The first write to the body implicitly sets the status to 200. Calling WriteHeader after the first write does nothing.
Handling individual items
The handler for /todos/ extracts the ID and routes to GET, PUT, or DELETE.
// handleSingleTodo routes requests for /todos/{id}.
func handleSingleTodo(w http.ResponseWriter, r *http.Request) {
// Extract the ID from the path after "/todos/".
idStr := r.URL.Path[len("/todos/"):]
var id int
fmt.Sscanf(idStr, "%d", &id)
mu.Lock()
defer mu.Unlock()
// Linear search for the matching ID.
for i, t := range todos {
if t.ID == id {
switch r.Method {
case http.MethodGet:
json.NewEncoder(w).Encode(t)
case http.MethodPut:
handleUpdate(w, r, i)
case http.MethodDelete:
handleDelete(w, i)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
return
}
}
http.Error(w, "Not found", http.StatusNotFound)
}
The code slices the path to get the ID string. fmt.Sscanf parses the string into an integer. The handler locks the mutex and searches the slice. If it finds a match, it routes based on the method. If the loop finishes without a match, it returns 404.
The update and delete helpers modify the slice. They assume the caller holds the lock.
// handleUpdate replaces the todo at index i with data from the request.
// The caller must hold the lock.
func handleUpdate(w http.ResponseWriter, r *http.Request, i int) {
var updated Todo
if err := json.NewDecoder(r.Body).Decode(&updated); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
// Preserve the original ID.
updated.ID = todos[i].ID
todos[i] = updated
json.NewEncoder(w).Encode(updated)
}
// handleDelete removes the todo at index i from the slice.
// The caller must hold the lock.
func handleDelete(w http.ResponseWriter, i int) {
// Slice the element out by concatenating before and after.
todos = append(todos[:i], todos[i+1:]...)
w.WriteHeader(http.StatusNoContent)
}
handleUpdate decodes the new data and replaces the element at index i. It preserves the original ID to prevent ID changes. handleDelete removes the element by slicing. append(todos[:i], todos[i+1:]...) concatenates the elements before i with the elements after i. This effectively removes the element. The response returns 204 No Content.
What happens at runtime
When you run the server, http.ListenAndServe binds to port 8080. It listens for TCP connections. For each connection, the server creates a new goroutine. The ServeMux inspects the URL path and calls the matching handler.
Inside the handler, mu.Lock() grabs the mutex. If another request arrives, it blocks until the first one calls Unlock. This prevents two goroutines from modifying the slice simultaneously. Without the mutex, you could get a panic or corrupted data. The race detector flags this with WARNING: DATA RACE if you run go run -race.
The json package uses reflection to convert structs to JSON. It writes bytes directly to the ResponseWriter. The ResponseWriter implements the io.Writer interface. Go follows the mantra "accept interfaces, return structs." The encoder accepts an interface and writes to the response. The handler returns a struct. This keeps dependencies loose.
Context is plumbing. Every request carries a context.Context. The context tracks deadlines and cancellation signals. If a client drops the connection, the context cancels. You can check r.Context().Done() to stop processing. This prevents wasting resources on dead connections. Functions that take a context should respect cancellation and deadlines. The context parameter always goes first, conventionally named ctx.
Pitfalls and errors
Writing to the response body before calling w.WriteHeader sets the status code to 200 automatically. Calling WriteHeader after the first write does nothing. The server logs a warning. Always set the status code before writing the body.
Forgetting the mutex leads to data races. The compiler won't catch this. Run go run -race to enable the race detector. It instruments the binary and checks for conflicting accesses. If it finds a race, it prints WARNING: DATA RACE with stack traces. Fix the race by adding the lock.
Ignoring errors from json.Decoder can corrupt your data. If the JSON is malformed, Decode returns an error like invalid character 'x' looking for beginning of object key string. If you proceed with the zero-value struct, you might insert empty records. Always check if err != nil.
Using a slice for storage has performance limits. Linear search is slow for large datasets. Appending is fast, but deleting requires shifting elements. For production, replace the slice with a database. Use database/sql or a driver like sqlx. The locking logic changes too. Database connections are usually pooled, and each query runs in its own transaction.
When to use this approach
Use net/http with a slice and mutex when you need a quick prototype or a mock server with zero dependencies. Use a database driver like database/sql when you need persistence across restarts. Use a routing library like chi or gin when you need middleware chains, path parameters, or nested groups. Use context.Context when you need to cancel long-running operations or pass request-scoped values. Use sync.RWMutex when reads vastly outnumber writes and you want to allow concurrent reads.
Goroutines are cheap. Mutexes are locks. Lock what you must, unlock as soon as you can.