How to Build a REST API in Go (Golang) with net/http: CRUD with JSON, No Framework

Go (also called Golang) is a favorite language for building back-end services: it compiles to a single fast binary, it handles many requests at the same time without extra libraries, and its standard library already contains a production-grade web server. Since Go 1.22, the built-in router http.ServeMux also understands HTTP methods and URL wildcards such as GET /api/books/{id}, so for many projects you no longer need a framework like Gin, Echo or Chi.

In this tutorial you will build a complete REST API in Go using only the standard library. It manages a list of books and supports all CRUD operations: list, read, create, update and delete. Along the way you will learn how to read path values, decode and validate JSON, return proper HTTP status codes, add a simple logging middleware, keep shared data safe with a mutex, and write automated tests with httptest. Every command and response below was copied from a real run.

Requirements

  • Go 1.22 or newer (the method and wildcard routing used here was added in 1.22). This tutorial was tested with Go 1.27 and Go 1.22. Check yours with go version.
  • A code editor such as VS Code with the Go extension, or GoLand.
  • curl (included in Windows 10/11, macOS and Linux) or Postman to send requests.

The API We Will Build

MethodURLActionSuccess status
GET/api/booksList all books (optional ?author= filter)200 OK
GET/api/books/{id}Get one book200 OK
POST/api/booksCreate a book201 Created
PUT/api/books/{id}Replace a book200 OK
DELETE/api/books/{id}Delete a book204 No Content

Step 1: Create the Project

Open a terminal, create a folder and initialize a Go module:

mkdir bookapi
cd bookapi
go mod init example.com/bookapi

Then create a file named main.go. All the code in the next steps goes into this one file. We start with the imports and the Book type. The json:"..." tags tell Go which JSON key each field maps to, so the API uses lowercase keys like "title".

  1. package main
  2.  
  3. import (
  4.         "encoding/json"
  5.         "errors"
  6.         "log"
  7.         "net/http"
  8.         "sort"
  9.         "strconv"
  10.         "strings"
  11.         "sync"
  12. )
  13.  
  14. // Book is the resource our API manages.
  15. type Book struct {
  16.         ID     int    `json:"id"`
  17.         Title  string `json:"title"`
  18.         Author string `json:"author"`
  19.         Year   int    `json:"year"`
  20. }

Step 2: A Thread-Safe In-Memory Store

To keep the focus on HTTP, the books are stored in a map in memory. Go's web server runs every request in its own goroutine, so two requests can touch the map at the same time. A plain map is not safe for that, which is why every method locks a sync.Mutex first. Later you can replace this type with one that talks to MySQL, PostgreSQL or SQLite without changing the handlers.

  1. // BookStore keeps books in memory. The mutex makes it safe to use
  2. // from many requests at the same time.
  3. type BookStore struct {
  4.         mu     sync.Mutex
  5.         books  map[int]Book
  6.         nextID int
  7. }
  8.  
  9. func NewBookStore() *BookStore {
  10.         return &BookStore{books: make(map[int]Book), nextID: 1}
  11. }
  12.  
  13. func (s *BookStore) All() []Book {
  14.         s.mu.Lock()
  15.         defer s.mu.Unlock()
  16.         list := make([]Book, 0, len(s.books))
  17.         for _, b := range s.books {
  18.                 list = append(list, b)
  19.         }
  20.         sort.Slice(list, func(i, j int) bool { return list[i].ID < list[j].ID })
  21.         return list
  22. }
  23.  
  24. func (s *BookStore) Get(id int) (Book, bool) {
  25.         s.mu.Lock()
  26.         defer s.mu.Unlock()
  27.         b, ok := s.books[id]
  28.         return b, ok
  29. }
  30.  
  31. func (s *BookStore) Create(b Book) Book {
  32.         s.mu.Lock()
  33.         defer s.mu.Unlock()
  34.         b.ID = s.nextID
  35.         s.nextID++
  36.         s.books[b.ID] = b
  37.         return b
  38. }
  39.  
  40. func (s *BookStore) Update(id int, b Book) (Book, bool) {
  41.         s.mu.Lock()
  42.         defer s.mu.Unlock()
  43.         if _, ok := s.books[id]; !ok {
  44.                 return Book{}, false
  45.         }
  46.         b.ID = id
  47.         s.books[id] = b
  48.         return b, true
  49. }
  50.  
  51. func (s *BookStore) Delete(id int) bool {
  52.         s.mu.Lock()
  53.         defer s.mu.Unlock()
  54.         if _, ok := s.books[id]; !ok {
  55.                 return false
  56.         }
  57.         delete(s.books, id)
  58.         return true
  59. }

Notice that All() sorts the books by ID. Go deliberately returns map items in random order, so without sorting the list would come back shuffled on every request.

Step 3: JSON Helpers, Validation and Path Values

Every handler needs to send JSON and errors, so we write small helpers once. readBook decodes the request body and validates it. Two details make it safer than a plain json.Unmarshal: http.MaxBytesReader rejects huge bodies, and DisallowUnknownFields() returns an error when the client sends a field the API does not know, which catches typos like "tittle".

  1. // writeJSON sends any value as JSON with the given status code.
  2. func writeJSON(w http.ResponseWriter, status int, v any) {
  3.         w.Header().Set("Content-Type", "application/json")
  4.         w.WriteHeader(status)
  5.         json.NewEncoder(w).Encode(v)
  6. }
  7.  
  8. func writeError(w http.ResponseWriter, status int, msg string) {
  9.         writeJSON(w, status, map[string]string{"error": msg})
  10. }
  11.  
  12. // readBook decodes and validates the JSON body of a POST or PUT request.
  13. func readBook(w http.ResponseWriter, r *http.Request) (Book, error) {
  14.         var b Book
  15.         r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // limit body to 1 MB
  16.         dec := json.NewDecoder(r.Body)
  17.         dec.DisallowUnknownFields()
  18.         if err := dec.Decode(&b); err != nil {
  19.                 return b, errors.New("invalid JSON: " + err.Error())
  20.         }
  21.         b.Title = strings.TrimSpace(b.Title)
  22.         b.Author = strings.TrimSpace(b.Author)
  23.         if b.Title == "" || b.Author == "" {
  24.                 return b, errors.New("title and author are required")
  25.         }
  26.         if b.Year < 0 || b.Year > 2100 {
  27.                 return b, errors.New("year must be between 0 and 2100")
  28.         }
  29.         return b, nil
  30. }
  31.  
  32. // pathID reads the {id} wildcard from the URL, e.g. /api/books/3.
  33. func pathID(r *http.Request) (int, error) {
  34.         id, err := strconv.Atoi(r.PathValue("id"))
  35.         if err != nil || id < 1 {
  36.                 return 0, errors.New("id must be a positive number")
  37.         }
  38.         return id, nil
  39. }

r.PathValue("id") is new in Go 1.22. It returns the part of the URL that matched the {id} wildcard in the route pattern, as a string, so we convert it with strconv.Atoi.

Step 4: The CRUD Handlers

A handler is a function with the signature func(w http.ResponseWriter, r *http.Request). We attach them to an API struct so they can share the store without global variables.

  1. type API struct {
  2.         store *BookStore
  3. }
  4.  
  5. func (a *API) listBooks(w http.ResponseWriter, r *http.Request) {
  6.         books := a.store.All()
  7.         // Optional filter: /api/books?author=orwell
  8.         if q := strings.ToLower(r.URL.Query().Get("author")); q != "" {
  9.                 filtered := []Book{}
  10.                 for _, b := range books {
  11.                         if strings.Contains(strings.ToLower(b.Author), q) {
  12.                                 filtered = append(filtered, b)
  13.                         }
  14.                 }
  15.                 books = filtered
  16.         }
  17.         writeJSON(w, http.StatusOK, books)
  18. }
  19.  
  20. func (a *API) getBook(w http.ResponseWriter, r *http.Request) {
  21.         id, err := pathID(r)
  22.         if err != nil {
  23.                 writeError(w, http.StatusBadRequest, err.Error())
  24.                 return
  25.         }
  26.         b, ok := a.store.Get(id)
  27.         if !ok {
  28.                 writeError(w, http.StatusNotFound, "book not found")
  29.                 return
  30.         }
  31.         writeJSON(w, http.StatusOK, b)
  32. }
  33.  
  34. func (a *API) createBook(w http.ResponseWriter, r *http.Request) {
  35.         b, err := readBook(w, r)
  36.         if err != nil {
  37.                 writeError(w, http.StatusBadRequest, err.Error())
  38.                 return
  39.         }
  40.         b = a.store.Create(b)
  41.         w.Header().Set("Location", "/api/books/"+strconv.Itoa(b.ID))
  42.         writeJSON(w, http.StatusCreated, b)
  43. }
  44.  
  45. func (a *API) updateBook(w http.ResponseWriter, r *http.Request) {
  46.         id, err := pathID(r)
  47.         if err != nil {
  48.                 writeError(w, http.StatusBadRequest, err.Error())
  49.                 return
  50.         }
  51.         b, err := readBook(w, r)
  52.         if err != nil {
  53.                 writeError(w, http.StatusBadRequest, err.Error())
  54.                 return
  55.         }
  56.         b, ok := a.store.Update(id, b)
  57.         if !ok {
  58.                 writeError(w, http.StatusNotFound, "book not found")
  59.                 return
  60.         }
  61.         writeJSON(w, http.StatusOK, b)
  62. }
  63.  
  64. func (a *API) deleteBook(w http.ResponseWriter, r *http.Request) {
  65.         id, err := pathID(r)
  66.         if err != nil {
  67.                 writeError(w, http.StatusBadRequest, err.Error())
  68.                 return
  69.         }
  70.         if !a.store.Delete(id) {
  71.                 writeError(w, http.StatusNotFound, "book not found")
  72.                 return
  73.         }
  74.         w.WriteHeader(http.StatusNoContent)
  75. }

A few REST conventions are applied here: a successful POST returns 201 Created with a Location header pointing to the new book, a successful DELETE returns 204 No Content with an empty body, bad input returns 400 Bad Request, and a missing book returns 404 Not Found. Always return right after writing an error, otherwise the handler keeps running and tries to write a second response.

Step 5: Routing, Middleware and main()

This is where Go 1.22 routing shines. The method is written at the start of the pattern, and {id} is a wildcard. If a client uses a method you did not register, for example PATCH, the mux automatically answers 405 Method Not Allowed with an Allow header.

  1. // logRequests is a small middleware that prints every request.
  2. func logRequests(next http.Handler) http.Handler {
  3.         return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  4.                 log.Printf("%s %s", r.Method, r.URL.Path)
  5.                 next.ServeHTTP(w, r)
  6.         })
  7. }
  8.  
  9. func newRouter(store *BookStore) http.Handler {
  10.         api := &API{store: store}
  11.         mux := http.NewServeMux()
  12.         mux.HandleFunc("GET /api/books", api.listBooks)
  13.         mux.HandleFunc("GET /api/books/{id}", api.getBook)
  14.         mux.HandleFunc("POST /api/books", api.createBook)
  15.         mux.HandleFunc("PUT /api/books/{id}", api.updateBook)
  16.         mux.HandleFunc("DELETE /api/books/{id}", api.deleteBook)
  17.         return logRequests(mux)
  18. }
  19.  
  20. func main() {
  21.         store := NewBookStore()
  22.         store.Create(Book{Title: "Noli Me Tangere", Author: "Jose Rizal", Year: 1887})
  23.         store.Create(Book{Title: "Nineteen Eighty-Four", Author: "George Orwell", Year: 1949})
  24.  
  25.         log.Println("Book API listening on http://localhost:8080")
  26.         log.Fatal(http.ListenAndServe(":8080", newRouter(store)))
  27. }

The middleware logRequests wraps the whole router: it prints the method and path, then passes the request on. You can use the same pattern for authentication, CORS headers or timing.

Step 6: Run and Test with curl

Start the server:

go run .

Leave it running and open a second terminal. First, list the two sample books:

$ curl -s http://localhost:8080/api/books
[{"id":1,"title":"Noli Me Tangere","author":"Jose Rizal","year":1887},{"id":2,"title":"Nineteen Eighty-Four","author":"George Orwell","year":1949}]

Create a new book. The -i flag shows the status line and headers:

$ curl -s -i -X POST http://localhost:8080/api/books -H 'Content-Type: application/json' -d '{"title":"El Filibusterismo","author":"Jose Rizal","year":1891}'
HTTP/1.1 201 Created
Content-Type: application/json
Location: /api/books/3
Content-Length: 71

{"id":3,"title":"El Filibusterismo","author":"Jose Rizal","year":1891}

Get it back by ID, then filter by author:

$ curl -s http://localhost:8080/api/books/3
{"id":3,"title":"El Filibusterismo","author":"Jose Rizal","year":1891}
$ curl -s 'http://localhost:8080/api/books?author=rizal'
[{"id":1,"title":"Noli Me Tangere","author":"Jose Rizal","year":1887},{"id":3,"title":"El Filibusterismo","author":"Jose Rizal","year":1891}]

Update book 2 with PUT, delete book 1, and list again:

$ curl -s -X PUT http://localhost:8080/api/books/2 -H 'Content-Type: application/json' -d '{"title":"Animal Farm","author":"George Orwell","year":1945}'
{"id":2,"title":"Animal Farm","author":"George Orwell","year":1945}
$ curl -s -i -X DELETE http://localhost:8080/api/books/1
HTTP/1.1 204 No Content
$ curl -s http://localhost:8080/api/books
[{"id":2,"title":"Animal Farm","author":"George Orwell","year":1945},{"id":3,"title":"El Filibusterismo","author":"Jose Rizal","year":1891}]

Now try the error cases. A deleted book, a missing title, an unknown field, an invalid ID and an unsupported method:

$ curl -s -i http://localhost:8080/api/books/1
HTTP/1.1 404 Not Found
Content-Type: application/json
Content-Length: 27

{"error":"book not found"}
$ curl -s -X POST http://localhost:8080/api/books -d '{"title":"","author":"Nobody"}'
{"error":"title and author are required"}
$ curl -s -X POST http://localhost:8080/api/books -d '{"title":"X","author":"Y","pages":100}'
{"error":"invalid JSON: json: unknown field \"pages\""}
$ curl -s http://localhost:8080/api/books/abc
{"error":"id must be a positive number"}
$ curl -s -i -X PATCH http://localhost:8080/api/books/2
HTTP/1.1 405 Method Not Allowed
Allow: DELETE, GET, HEAD, PUT
Content-Type: text/plain; charset=utf-8
X-Content-Type-Options: nosniff
Content-Length: 19

Method Not Allowed

Meanwhile the server terminal shows one log line per request, printed by the middleware (first lines shown):

2026/11/04 09:08:51 Book API listening on http://localhost:8080
2026/11/04 09:08:52 GET /api/books
2026/11/04 09:08:52 POST /api/books
2026/11/04 09:08:52 GET /api/books/3
2026/11/04 09:08:52 GET /api/books

Windows tip: in Command Prompt, single quotes do not work around JSON. Use double quotes and escape the inner ones, for example -d "{\"title\":\"Go\",\"author\":\"Me\"}", or use PowerShell's Invoke-RestMethod or Postman instead.

Step 7: Automated Tests with httptest

Clicking around with curl is good for learning, but tests let you check everything in one second after every change. The net/http/httptest package lets you call the router directly without starting a real server. Save this as main_test.go:

  1. package main
  2.  
  3. import (
  4.         "net/http"
  5.         "net/http/httptest"
  6.         "strings"
  7.         "testing"
  8. )
  9.  
  10. func TestCreateAndGetBook(t *testing.T) {
  11.         router := newRouter(NewBookStore())
  12.  
  13.         body := `{"title":"El Filibusterismo","author":"Jose Rizal","year":1891}`
  14.         req := httptest.NewRequest("POST", "/api/books", strings.NewReader(body))
  15.         rec := httptest.NewRecorder()
  16.         router.ServeHTTP(rec, req)
  17.         if rec.Code != http.StatusCreated {
  18.                 t.Fatalf("POST status = %d, want 201", rec.Code)
  19.         }
  20.  
  21.         req = httptest.NewRequest("GET", "/api/books/1", nil)
  22.         rec = httptest.NewRecorder()
  23.         router.ServeHTTP(rec, req)
  24.         if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "El Filibusterismo") {
  25.                 t.Fatalf("GET got %d %s", rec.Code, rec.Body.String())
  26.         }
  27. }
  28.  
  29. func TestValidationAndNotFound(t *testing.T) {
  30.         router := newRouter(NewBookStore())
  31.         tests := []struct {
  32.                 method, path, body string
  33.                 want               int
  34.         }{
  35.                 {"POST", "/api/books", `{"title":""}`, http.StatusBadRequest},
  36.                 {"POST", "/api/books", `{not json}`, http.StatusBadRequest},
  37.                 {"GET", "/api/books/99", "", http.StatusNotFound},
  38.                 {"GET", "/api/books/abc", "", http.StatusBadRequest},
  39.                 {"DELETE", "/api/books/1", "", http.StatusNotFound},
  40.                 {"PATCH", "/api/books/1", "", http.StatusMethodNotAllowed},
  41.         }
  42.         for _, tc := range tests {
  43.                 req := httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body))
  44.                 rec := httptest.NewRecorder()
  45.                 router.ServeHTTP(rec, req)
  46.                 if rec.Code != tc.want {
  47.                         t.Errorf("%s %s = %d, want %d", tc.method, tc.path, rec.Code, tc.want)
  48.                 }
  49.         }
  50. }

Run the tests:

$ go test -v
=== RUN   TestCreateAndGetBook
2026/11/04 09:09:50 POST /api/books
2026/11/04 09:09:50 GET /api/books/1
--- PASS: TestCreateAndGetBook (0.00s)
=== RUN   TestValidationAndNotFound
2026/11/04 09:09:50 POST /api/books
2026/11/04 09:09:50 POST /api/books
2026/11/04 09:09:50 GET /api/books/99
2026/11/04 09:09:50 GET /api/books/abc
2026/11/04 09:09:50 DELETE /api/books/1
2026/11/04 09:09:50 PATCH /api/books/1
--- PASS: TestValidationAndNotFound (0.00s)
PASS
ok  	example.com/bookapi	0.003s

The timestamped lines come from the logging middleware, since the tests go through the full router.

The table-driven style used in the second test (a slice of cases and one loop) is the most common way to write tests in Go. Adding a new case is just one more line.

Common Mistakes

  • "pattern ... conflicts" or routes never matching: method patterns like "GET /api/books" require Go 1.22+ and a go 1.22 (or newer) line in go.mod. With an older go line, the old routing rules apply.
  • Forgetting return after an error: you get "superfluous response.WriteHeader call" in the log.
  • Setting headers after WriteHeader: headers must be set before the status is written, which is why writeJSON sets Content-Type first.
  • Sharing a map without a mutex: it may work in testing and then crash under load with "concurrent map writes". Run go test -race to catch it.

Next Steps

  • Replace BookStore with a database version using database/sql and a driver such as github.com/go-sql-driver/mysql.
  • Add pagination with ?page= and ?limit= query parameters.
  • Protect the write routes with an API key or JWT middleware.
  • Use http.Server with ReadTimeout and WriteTimeout before deploying to the internet.

Conclusion

With Go 1.22 and newer, the standard library is enough to build a clean REST API: http.ServeMux handles methods and URL parameters, encoding/json handles the data, and httptest lets you test it all without a running server. You now have a working CRUD API in about 220 lines with no third-party dependencies, a great starting point for a mobile app back end or your next capstone project. Download the complete source code (main.go, main_test.go, go.mod and the curl script) below.

Happy coding!