Search
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
| Method | URL | Action | Success status |
|---|---|---|---|
| GET | /api/books | List all books (optional ?author= filter) | 200 OK |
| GET | /api/books/{id} | Get one book | 200 OK |
| POST | /api/books | Create a book | 201 Created |
| PUT | /api/books/{id} | Replace a book | 200 OK |
| DELETE | /api/books/{id} | Delete a book | 204 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".
- package main
- import (
- "encoding/json"
- "errors"
- "log"
- "net/http"
- "sort"
- "strconv"
- "strings"
- "sync"
- )
- // Book is the resource our API manages.
- type Book struct {
- ID int `json:"id"`
- Title string `json:"title"`
- Author string `json:"author"`
- Year int `json:"year"`
- }
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.
- // BookStore keeps books in memory. The mutex makes it safe to use
- // from many requests at the same time.
- type BookStore struct {
- mu sync.Mutex
- books map[int]Book
- nextID int
- }
- func NewBookStore() *BookStore {
- return &BookStore{books: make(map[int]Book), nextID: 1}
- }
- func (s *BookStore) All() []Book {
- s.mu.Lock()
- defer s.mu.Unlock()
- list := make([]Book, 0, len(s.books))
- for _, b := range s.books {
- list = append(list, b)
- }
- sort.Slice(list, func(i, j int) bool { return list[i].ID < list[j].ID })
- return list
- }
- func (s *BookStore) Get(id int) (Book, bool) {
- s.mu.Lock()
- defer s.mu.Unlock()
- b, ok := s.books[id]
- return b, ok
- }
- func (s *BookStore) Create(b Book) Book {
- s.mu.Lock()
- defer s.mu.Unlock()
- b.ID = s.nextID
- s.nextID++
- s.books[b.ID] = b
- return b
- }
- func (s *BookStore) Update(id int, b Book) (Book, bool) {
- s.mu.Lock()
- defer s.mu.Unlock()
- if _, ok := s.books[id]; !ok {
- return Book{}, false
- }
- b.ID = id
- s.books[id] = b
- return b, true
- }
- func (s *BookStore) Delete(id int) bool {
- s.mu.Lock()
- defer s.mu.Unlock()
- if _, ok := s.books[id]; !ok {
- return false
- }
- delete(s.books, id)
- return true
- }
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".
- // writeJSON sends any value as JSON with the given status code.
- func writeJSON(w http.ResponseWriter, status int, v any) {
- w.Header().Set("Content-Type", "application/json")
- w.WriteHeader(status)
- json.NewEncoder(w).Encode(v)
- }
- func writeError(w http.ResponseWriter, status int, msg string) {
- writeJSON(w, status, map[string]string{"error": msg})
- }
- // readBook decodes and validates the JSON body of a POST or PUT request.
- var b Book
- r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // limit body to 1 MB
- dec := json.NewDecoder(r.Body)
- dec.DisallowUnknownFields()
- if err := dec.Decode(&b); err != nil {
- return b, errors.New("invalid JSON: " + err.Error())
- }
- b.Title = strings.TrimSpace(b.Title)
- b.Author = strings.TrimSpace(b.Author)
- if b.Title == "" || b.Author == "" {
- return b, errors.New("title and author are required")
- }
- if b.Year < 0 || b.Year > 2100 {
- return b, errors.New("year must be between 0 and 2100")
- }
- return b, nil
- }
- // pathID reads the {id} wildcard from the URL, e.g. /api/books/3.
- id, err := strconv.Atoi(r.PathValue("id"))
- if err != nil || id < 1 {
- return 0, errors.New("id must be a positive number")
- }
- return id, nil
- }
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.
- type API struct {
- store *BookStore
- }
- books := a.store.All()
- // Optional filter: /api/books?author=orwell
- if q := strings.ToLower(r.URL.Query().Get("author")); q != "" {
- filtered := []Book{}
- for _, b := range books {
- if strings.Contains(strings.ToLower(b.Author), q) {
- filtered = append(filtered, b)
- }
- }
- books = filtered
- }
- writeJSON(w, http.StatusOK, books)
- }
- id, err := pathID(r)
- if err != nil {
- writeError(w, http.StatusBadRequest, err.Error())
- return
- }
- b, ok := a.store.Get(id)
- if !ok {
- writeError(w, http.StatusNotFound, "book not found")
- return
- }
- writeJSON(w, http.StatusOK, b)
- }
- b, err := readBook(w, r)
- if err != nil {
- writeError(w, http.StatusBadRequest, err.Error())
- return
- }
- b = a.store.Create(b)
- w.Header().Set("Location", "/api/books/"+strconv.Itoa(b.ID))
- writeJSON(w, http.StatusCreated, b)
- }
- id, err := pathID(r)
- if err != nil {
- writeError(w, http.StatusBadRequest, err.Error())
- return
- }
- b, err := readBook(w, r)
- if err != nil {
- writeError(w, http.StatusBadRequest, err.Error())
- return
- }
- b, ok := a.store.Update(id, b)
- if !ok {
- writeError(w, http.StatusNotFound, "book not found")
- return
- }
- writeJSON(w, http.StatusOK, b)
- }
- id, err := pathID(r)
- if err != nil {
- writeError(w, http.StatusBadRequest, err.Error())
- return
- }
- if !a.store.Delete(id) {
- writeError(w, http.StatusNotFound, "book not found")
- return
- }
- w.WriteHeader(http.StatusNoContent)
- }
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.
- // logRequests is a small middleware that prints every request.
- log.Printf("%s %s", r.Method, r.URL.Path)
- next.ServeHTTP(w, r)
- })
- }
- api := &API{store: store}
- mux := http.NewServeMux()
- mux.HandleFunc("GET /api/books", api.listBooks)
- mux.HandleFunc("GET /api/books/{id}", api.getBook)
- mux.HandleFunc("POST /api/books", api.createBook)
- mux.HandleFunc("PUT /api/books/{id}", api.updateBook)
- mux.HandleFunc("DELETE /api/books/{id}", api.deleteBook)
- return logRequests(mux)
- }
- func main() {
- store := NewBookStore()
- store.Create(Book{Title: "Noli Me Tangere", Author: "Jose Rizal", Year: 1887})
- store.Create(Book{Title: "Nineteen Eighty-Four", Author: "George Orwell", Year: 1949})
- log.Println("Book API listening on http://localhost:8080")
- log.Fatal(http.ListenAndServe(":8080", newRouter(store)))
- }
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:
- package main
- import (
- "net/http"
- "net/http/httptest"
- "strings"
- "testing"
- )
- func TestCreateAndGetBook(t *testing.T) {
- router := newRouter(NewBookStore())
- body := `{"title":"El Filibusterismo","author":"Jose Rizal","year":1891}`
- req := httptest.NewRequest("POST", "/api/books", strings.NewReader(body))
- rec := httptest.NewRecorder()
- router.ServeHTTP(rec, req)
- if rec.Code != http.StatusCreated {
- t.Fatalf("POST status = %d, want 201", rec.Code)
- }
- req = httptest.NewRequest("GET", "/api/books/1", nil)
- rec = httptest.NewRecorder()
- router.ServeHTTP(rec, req)
- if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "El Filibusterismo") {
- t.Fatalf("GET got %d %s", rec.Code, rec.Body.String())
- }
- }
- func TestValidationAndNotFound(t *testing.T) {
- router := newRouter(NewBookStore())
- tests := []struct {
- method, path, body string
- want int
- }{
- {"POST", "/api/books", `{"title":""}`, http.StatusBadRequest},
- {"POST", "/api/books", `{not json}`, http.StatusBadRequest},
- {"GET", "/api/books/99", "", http.StatusNotFound},
- {"GET", "/api/books/abc", "", http.StatusBadRequest},
- {"DELETE", "/api/books/1", "", http.StatusNotFound},
- {"PATCH", "/api/books/1", "", http.StatusMethodNotAllowed},
- }
- for _, tc := range tests {
- req := httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body))
- rec := httptest.NewRecorder()
- router.ServeHTTP(rec, req)
- if rec.Code != tc.want {
- t.Errorf("%s %s = %d, want %d", tc.method, tc.path, rec.Code, tc.want)
- }
- }
- }
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 ago 1.22(or newer) line ingo.mod. With an oldergoline, the old routing rules apply. - Forgetting
returnafter 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 whywriteJSONsetsContent-Typefirst. - Sharing a map without a mutex: it may work in testing and then crash under load with "concurrent map writes". Run
go test -raceto catch it.
Next Steps
- Replace
BookStorewith a database version usingdatabase/sqland a driver such asgithub.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.ServerwithReadTimeoutandWriteTimeoutbefore 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!