Skip to main content

Part 1: CRUD in memory with nothing but net/http

Create, read, update, delete: the smallest useful HTTP API in Go, with no framework and no database.

Updated 8 min readMarkdown
Part of seriesBuild a production-grade REST API in Go

Series: Build a production-grade REST API in Go · Part 1 of 17 Where we are: an empty folder. By the end you will have a working API.

We begin deliberately small. A first version teaches you the shape of the problem, and it gives us something to improve in every later post. This one is going to have flaws, and at the end of the post we will list them on purpose. That list becomes our roadmap.

What we are building

A users resource with four operations:

Method & path Does
GET /users list all users
POST /users create a user
PUT /users/{id} rename a user
DELETE /users/{id} delete a user

A user is just an id and a name: {"id": 1, "name": "Ann"}.

Step 1: create the project

mkdir rest-go && cd rest-go
git init
go mod init go-rest
mkdir -p cmd/rest internal/storage internal/handlers internal/routes

Two folders deserve an explanation:

  • cmd/rest/ holds main.go. Anything under cmd/ is a program you can run. A repo can have several (cmd/api, cmd/worker, ...).
  • internal/ is special to the Go toolchain: packages inside it cannot be imported by other modules. It is how you say "this is my application's private code, not a library". Use it by default; make things public only on purpose.

Notice we split by responsibility (storage, handlers, routes), not by file type. This is the first step toward the layered structure we will keep growing.

Step 2: the data layer

We start with the "database": a map guarded by a mutex.

Create internal/storage/storage.go

package storage

import (
	"errors"
	"log"
	"sync"
)

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

type UserStore struct {
	mu     sync.RWMutex
	users  map[int]*User
	nextID int
}

func NewUserStore() *UserStore {
	return &UserStore{
		users:  make(map[int]*User),
		nextID: 1,
	}
}

func (s *UserStore) GetAll() []*User {
	// Your code here
	users := []*User{}
	s.mu.RLock()
	defer s.mu.RUnlock()
	for _, user := range s.users {
		users = append(users, user)
	}

	return users
}

func (s *UserStore) Create(user *User) *User {
	// Your code here

	s.mu.Lock()
	defer s.mu.Unlock()

	user.ID = s.nextID
	s.users[user.ID] = user
	s.nextID++

	return user
}

func (s *UserStore) Update(id int, updatedUser *User) (*User, error) {
	s.mu.Lock()
	defer s.mu.Unlock()

	user, ok := s.users[id]
	if !ok {
		return nil, errors.New("user not found")
	}

	user.Name = updatedUser.Name
	return user, nil
}

func (s *UserStore) Delete(id int) (bool, error) {
	s.mu.Lock()
	defer s.mu.Unlock()

	user, ok := s.users[id]
	if !ok {
		return false, errors.New("user not found")
	}

	log.Printf("deleted user with userId: %d, name: %s ", user.ID, user.Name)
	delete(s.users, id)
	return true, nil
}

Three things to notice:

  1. sync.RWMutex. Go's HTTP server handles every request in its own goroutine, so two requests can touch the map at the same time. Concurrent map access is a data race and can crash the program. RLock lets many readers in at once; Lock is exclusive for writers.
  2. defer s.mu.Unlock() releases the lock on every exit path, including early returns, so you cannot forget it.
  3. The store knows nothing about HTTP. It takes and returns plain Go values. That separation pays off in every later post.

Step 3: the handlers

A handler is any function with the signature func(http.ResponseWriter, *http.Request). It reads the request, calls the store, and writes the response.

Create internal/handlers/handlers.go

package handlers

import (
	"encoding/json"
	"go-rest/internal/storage"
	"net/http"
	"strconv"
)

var store = storage.NewUserStore()

func HandleGetUsers(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(store.GetAll())
}

func HandleCreateUsers(w http.ResponseWriter, r *http.Request) {
	var user storage.User

	if err := json.NewDecoder(r.Body).Decode(&user); err != nil {
		http.Error(w, "Invalid JSON", http.StatusBadRequest)
		return
	}

	created := store.Create(&user)
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusCreated)
	json.NewEncoder(w).Encode(created)
}

func HandleUpdateUsers(w http.ResponseWriter, r *http.Request) {

	id, err := strconv.Atoi(r.PathValue("id"))
	if err != nil {
		http.Error(w, "Invalid user ID", http.StatusBadRequest)
		return
	}

	var updates storage.User
	if err := json.NewDecoder(r.Body).Decode(&updates); err != nil {
		http.Error(w, "Invalid JSON", http.StatusBadRequest)
		return
	}

	updated, err := store.Update(id, &updates)
	if err != nil {
		http.Error(w, "User not found", http.StatusNotFound)
		return
	}

	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(updated)
}

func HandleDeleteUsers(w http.ResponseWriter, r *http.Request) {
	id, err := strconv.Atoi(r.PathValue("id"))
	if err != nil {
		http.Error(w, "Invalid user ID", http.StatusBadRequest)
		return
	}
	del, err := store.Delete(id)
	if err != nil {
		http.Error(w, "User not found", http.StatusNotFound)
		return
	}

	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(struct {
		Deleted bool `json:"deleted"`
	}{
		Deleted: del,
	})
}

Walk through HandleCreateUsers:

  1. json.NewDecoder(r.Body).Decode(&user) parses the JSON body into a struct. If the JSON is malformed we answer 400 Bad Request: the client made a mistake, so it is a 4xx, not a 5xx.
  2. We store the user and answer 201 Created (not 200) with the created object. Correct status codes are part of your API's contract: clients, proxies and monitoring tools all act on them.
  3. The json:"id" tags on the User struct decide the JSON field names.

r.PathValue("id") is new in Go 1.22: it reads the {id} wildcard from the route pattern, which we define next.

Step 4: routes

Create internal/routes/routes.go

package routes

import (
	"go-rest/internal/handlers"
	"net/http"
)

func SetupRoutes(mux *http.ServeMux) {
	mux.HandleFunc("GET /users", handlers.HandleGetUsers)
	mux.HandleFunc("POST /users", handlers.HandleCreateUsers)
	mux.HandleFunc("PUT /users/{id}", handlers.HandleUpdateUsers)
	mux.HandleFunc("DELETE /users/{id}", handlers.HandleDeleteUsers)
}

Since Go 1.22, http.ServeMux understands the HTTP method and path wildcards in the pattern itself: "PUT /users/{id}". Before this, people reached for a router library just to get that. Now the standard library is enough for most APIs. Bonus: a request with the wrong method (say PATCH /users/1) automatically gets 405 Method Not Allowed.

Step 5: main

Create cmd/rest/main.go

package main

import (
	"go-rest/internal/routes"
	"log"
	"net/http"
)

func main() {
	mux := http.NewServeMux()
	routes.SetupRoutes(mux)

	log.Println("server running on :7800")

	if err := http.ListenAndServe(":7800", mux); err != nil {
		log.Fatal(err)
	}
}

ListenAndServe blocks forever serving requests; it only returns if something goes wrong, which is why we pass its error to log.Fatal.

Finally, a tiny makefile so you do not have to remember commands. (Recipe lines must start with a tab, not spaces. If your editor or the page you copied from turned them into spaces, make will complain about a "missing separator".)

Create makefile

.PHONY: run build

run:
	go run cmd/rest/main.go

build:
	go build -o bin/server cmd/rest/main.go

Try it

Start the server in one terminal:

make run
# 2026/10/18 12:12:37 server running on :7800

And talk to it from another:

$ curl -i -XPOST localhost:7800/users -d '{"name":"Ann"}'
HTTP/1.1 201 Created
Content-Type: application/json
Content-Length: 22

{"id":1,"name":"Ann"}

$ curl -XPOST localhost:7800/users -d '{"name":"Bob"}'
{"id":2,"name":"Bob"}

$ curl localhost:7800/users
[{"id":1,"name":"Ann"},{"id":2,"name":"Bob"}]

$ curl -XPUT localhost:7800/users/1 -d '{"name":"Anna"}'
{"id":1,"name":"Anna"}

$ curl -XDELETE localhost:7800/users/2
{"deleted":true}

$ curl -i -XDELETE localhost:7800/users/2        # already gone
HTTP/1.1 404 Not Found
User not found

$ curl -i -XPOST localhost:7800/users -d '{'      # broken JSON
HTTP/1.1 400 Bad Request
Invalid JSON

It works. In about 200 lines you have a real HTTP API.

Now the important part: what is wrong with it?

Try this:

$ curl -XPOST localhost:7800/users -d '{}'
{"id":3,"name":""}

We just created a user with no name. That is one of many problems. Be your own code reviewer:

# Problem Why it matters
1 No validation. Empty names, and any extra JSON is silently accepted. Garbage in, garbage forever
2 Data lives in memory. Restart the server and everything is gone. It is not a database
3 A global variable (var store = ...) inside the handlers package. Impossible to test or swap; hidden dependency
4 GetAll iterates a map, whose order is random in Go. The list can come back in a different order each time
5 Plain-text error bodies, inconsistent with the JSON success bodies. Clients can't parse errors reliably
6 Unbounded request body. A 5 GB upload is read into memory. Trivial denial of service
7 No timeouts on the server. A slow client holds a connection forever
8 No graceful shutdown; Ctrl-C kills in-flight requests. Lost writes during every deploy
9 The store hands out pointers to its own records, and handlers encode them after the lock is released. Another request can modify the record at that moment: a data race
10 Errors from Encode are ignored. Silent failures

We will fix every one of these in the next posts, and then keep going: real database, observability, security. Keep this table; we will cross things off.

Recap

  • net/http in Go 1.22+ gives you method and wildcard routing without a framework.
  • Handlers translate between HTTP and your domain; the store should not know HTTP exists.
  • Shared mutable state needs synchronization, because every request is a goroutine.
  • Pick status codes deliberately: 201 for create, 400 for client mistakes, 404 for missing things.

Exercises

  1. Add GET /users/{id} returning a single user (404 if missing).
  2. Make GetAll return users sorted by id. (Hint: collect, then slices.SortFunc.)
  3. Write a test for the store that creates 100 users from 100 goroutines and run it with go test -race. Then comment out the mutex and watch the race detector complain.

No comments yet. Be the first to comment.

More from this blog