Golang REST API with Gin: Full Tutorial for Beginners
A full Go and Gin tutorial for beginners: we build a REST API for a plant nursery from an empty folder, on a real Linux laptop. Routes and handlers, structs that turn into JSON (and why every plant first came back as {}), path parameters, query strings, POST with input checks, PUT and DELETE, the crash 300 requests at once cause and the mutex that fixes it, middleware with an API key (and the version anyone could skip), tests with httptest, and one file to ship, for Linux or Windows.
Every plant came back empty
Your first Go API runs, it answers, and every plant comes back as an empty pair of braces. The names and the stock are right there in the code. So where did they go?
curl localhost:8080/plants[{},{},{}]
sed -n '9,20p' main.gotype Plant struct { id int name string light string stock int } var plants = []Plant{ {1, "Fern", "shade", 12}, {2, "Basil", "sun", 40}, {3, "Olive tree", "sun", 2}, }
In this tutorial, we build a REST API in Go with Gin, from an empty folder, for Ana's plant nursery: five routes to list, read, add, change and remove plants, checked input, tests, and one file to ship. On the way, three hundred requests take the server down, and a key check lets anyone in.

What a REST API is
An API is a way for one program to ask another for data. A web API does it over HTTP, the same rules your browser follows. The program that asks is the client. It sends a request with a method, a verb such as GET, and a path, such as /plants. The server answers with a status code, a number that says how it went: 200 means OK. And a body, here in JSON: plain text that programs in any language can read.
![One request, one answer: diagram of Client (curl), Ana's API, GET /plants, 200 OK [{"id":1,"name":"Fern",...}], HTTP: the same rules your browser follows](/img/golang-rest-api-with-gin-full-tutorial-for-beginners/rest_what.jpg)
JSON is worth one close look, because every answer in this tutorial is JSON. Curly braces hold one object. Inside, pairs of a key and a value, the key always in double quotes, then a colon. A value in quotes is text; a value without quotes is a number. Square brackets around several objects make a list.
![One plant in JSON (three of its keys): diagram of {"id":2,"name":"Basil","stock":40}, "name" = a key, in quotes, "Basil" = text value, 40 = number value, { = one object, [ {...}, {...} ] is a list of objects](/img/golang-rest-api-with-gin-full-tutorial-for-beginners/json_what.jpg)
REST is a habit for laying out those paths. Each kind of thing gets one path, /plants, one plant gets its number after it, and the verb says what to do: GET reads, POST adds, PUT replaces, and DELETE removes. Four verbs and two paths are the whole map of Ana's API.

Inside the server, think of a mail room. Every request is an envelope with two things written on it: the verb, and the address. The router reads both, and drops the envelope into the right pigeonhole. In each pigeonhole waits one function, called a handler, which writes the answer. An envelope with an address nobody set up gets 404: not found. Gin is that router, and a little more.

Go can already serve web pages on its own: its standard library has a package for HTTP. So why Gin? Gin is a framework built on top of that package, and it adds the parts every API needs. A router that understands paths with parameters in them. Reading JSON in, with checks. Middleware, small functions that run before your handlers. And a test mode. You'll meet each of these, one chapter at a time.

Go, a module and Gin
You need Go itself first. The download page on go.dev has an installer for Windows, one for Macs, and an archive for Linux, with the install steps one click away. Ana's laptop runs Ubuntu, and has Go 1.27.4. Every command and the lab files are linked in the description.

go version confirms it. A Go project is called a module: a folder with a file named go.mod, which gives the project a name and lists the code it uses from others. Ana makes a folder called nursery, steps into it, and go mod init nursery writes that file.
go versiongo version go1.27.4 linux/arm64
mkdir nursery && cd nursery
go mod init nurserygo: creating new go.mod: module nursery
Then she types her first program into main.go. package main and func main mark the program's starting point. The import line brings in Gin. gin.Default() makes a new router, called r. r.GET says: for a GET request to /ping, run this function. The function gets a context, called c, holding the request and a way to answer. c.JSON answers with status 200 and a small JSON object; gin.H is a quick way to write one. And r.Run starts listening on port 8080.
cat main.gopackage main import "github.com/gin-gonic/gin" func main() { r := gin.Default() r.GET("/ping", func(c *gin.Context) { c.JSON(200, gin.H{"message": "pong"}) }) r.Run(":8080") }
Go doesn't have Gin yet. go mod tidy reads the import lines, finds Gin, and downloads it, version 1.12.0, with the 37 packages Gin itself uses. go.mod now lists Gin by name and version, and a second file, go.sum, holds a checksum for every download, so the same build gets the same code on any machine.
go mod tidygo: finding module for package github.com/gin-gonic/gin go: downloading github.com/gin-gonic/gin v1.12.0 go: found github.com/gin-gonic/gin in github.com/gin-gonic/gin v1.12.0 go: downloading github.com/gin-contrib/sse v1.1.0 go: downloading github.com/mattn/go-isatty v0.0.20 go: downloading github.com/quic-go/quic-go v0.59.0 go: downloading golang.org/x/net v0.51.0 go: downloading github.com/stretchr/testify v1.11.1 go: downloading go.mongodb.org/mongo-driver/v2 v2.5.0 go: downloading google.golang.org/protobuf v1.36.10 go: downloading github.com/go-playground/validator/v10 v10.30.1 go: downloading github.com/goccy/go-yaml v1.19.2 go: downloading github.com/pelletier/go-toml/v2 v2.2.4 go: downloading github.com/ugorji/go/codec v1.3.1 go: downloading github.com/json-iterator/go v1.1.12 go: downloading github.com/modern-go/reflect2 v1.0.2 go: downloading github.com/bytedance/sonic v1.15.0 go: downloading github.com/goccy/go-json v0.10.5 go: downloading golang.org/x/sys v0.41.0 go: downloading github.com/davecgh/go-spew v1.1.1 go: downloading github.com/pmezard/go-difflib v1.0.0 go: downloading github.com/gabriel-vasile/mimetype v1.4.12 go: downloading github.com/go-playground/universal-translator v0.18.1 go: downloading github.com/leodido/go-urn v1.4.0 go: downloading golang.org/x/crypto v0.48.0 go: downloading golang.org/x/text v0.34.0 go: downloading github.com/go-playground/locales v0.14.1 go: downloading github.com/quic-go/qpack v0.6.0 go: downloading go.uber.org/mock v0.6.0 go: downloading github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd go: downloading gopkg.in/yaml.v3 v3.0.1 go: downloading github.com/bytedance/gopkg v0.1.3 go: downloading github.com/cloudwego/base64x v0.1.6 go: downloading golang.org/x/arch v0.22.0 go: downloading github.com/klauspost/cpuid/v2 v2.3.0 go: downloading github.com/bytedance/sonic/loader v0.5.0 go: downloading github.com/twitchyliquid64/golang-asm v0.15.1 go: downloading github.com/go-playground/assert/v2 v2.2.0 go: downloading github.com/google/go-cmp v0.7.0
head -n 5 go.modmodule nursery go 1.27.4 require github.com/gin-gonic/gin v1.12.0
lsgo.mod go.sum main.go
Your first route
Time to run it. In a second window, go run. compiles the folder and starts the program. Gin prints the routes it knows: GET /ping, three handlers; we'll see why in a moment. Then: listening on port 8080. The warnings about debug mode and proxies are for later, when we ship.
go run .[GIN-debug] [WARNING] Creating an Engine instance with the Logger and Recovery middleware already attached. [GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production. - using env: export GIN_MODE=release - using code: gin.SetMode(gin.ReleaseMode) [GIN-debug] GET /ping --> main.main.func1 (3 handlers) [GIN-debug] [WARNING] You trusted all proxies, this is NOT safe. We recommend you to set a value. Please check https://github.com/gin-gonic/gin/blob/master/docs/doc.md#dont-trust-all-proxies for details. [GIN-debug] Listening and serving HTTP on :8080
Back in Ana's terminal, curl, a program that sends HTTP requests, asks for /ping. The answer: message, pong. With -i, curl also prints the status line, 200 OK, and the headers, lines of information about the answer, like its content type: JSON. A path nobody set up, /plants, gets 404, page not found: the envelope with no pigeonhole.
curl localhost:8080/ping{"message":"pong"}
curl -i localhost:8080/pingHTTP/1.1 200 OK Content-Type: application/json; charset=utf-8 Date: Fri, 13 Nov 2026 02:24:16 GMT Content-Length: 18 {"message":"pong"}
curl -i localhost:8080/plantsHTTP/1.1 404 Not Found Content-Type: text/plain Date: Fri, 13 Nov 2026 02:24:16 GMT Content-Length: 18 404 page not found
Why three handlers, when Ana wrote one? gin.Default() puts two of its own in front of hers. Logger writes a line for every request. Recovery catches a panic, Go's word for a crash inside a handler, and answers 500, internal server error, instead of letting it take the server down. Each request passes through all three in order, and the answer goes back the same way.

Logger's lines are in the server window: the time, the status, how long the answer took, in millionths of a second, and the verb and path. When something looks wrong later, this window is the first place to look.
[GIN] 2026/11/13 - 02:24:16 | 200 | 55.083µs | ::1 | GET "/ping" [GIN] 2026/11/13 - 02:24:16 | 200 | 60.625µs | ::1 | GET "/ping" [GIN] 2026/11/13 - 02:24:16 | 404 | 917ns | ::1 | GET "/plants"
From a struct to JSON
One habit first: go run doesn't watch your files. After every change, stop the server with Control C, which Go reports as signal: interrupt, and run it again, so it prints the routes it now knows.
signal: interrupt
go run .[GIN-debug] [WARNING] Creating an Engine instance with the Logger and Recovery middleware already attached. [GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production. - using env: export GIN_MODE=release - using code: gin.SetMode(gin.ReleaseMode) [GIN-debug] GET /plants --> main.main.func1 (3 handlers) [GIN-debug] [WARNING] You trusted all proxies, this is NOT safe. We recommend you to set a value. Please check https://github.com/gin-gonic/gin/blob/master/docs/doc.md#dont-trust-all-proxies for details. [GIN-debug] Listening and serving HTTP on :8080
Now the plants. sed -n prints just the lines asked for, here the struct and the list. A struct groups values under one name: each plant has an id, a name, the light it needs, and how many are in stock. Below it, a list of three plants, what Go calls a slice. A new route, GET /plants, hands the whole slice to c.JSON. Ana stops the server, starts it again, and asks for the list. Empty braces, three times: one empty object per plant.
Here's why. Go has a rule about capital letters. A name that starts with a capital letter is exported: code in other packages can see it. A lowercase name is private to its own package. The code that turns values into JSON lives in another package, so it can't see id, name, light or stock. It skips them, without a warning, and each plant becomes an empty object.

So Ana capitalises the four names. JSON keys are usually lowercase, so each field gets a tag, the text between back quotes: json, colon, id, which says: call this field id in the JSON. A restart, and the list is all there, in one long line. With -s, curl stays silent about its progress, and jq, a small JSON tool, prints one plant per line.
sed -n '9,14p' main.gotype Plant struct { ID int `json:"id"` Name string `json:"name"` Light string `json:"light"` Stock int `json:"stock"` }
curl localhost:8080/plants[{"id":1,"name":"Fern","light":"shade","stock":12},{"id":2,"name":"Basil","light":"sun","stock":40},{"id":3,"name":"Olive tree","light":"sun","stock":2}]
curl -s localhost:8080/plants | jq -c '.[]'{"id":1,"name":"Fern","light":"shade","stock":12} {"id":2,"name":"Basil","light":"sun","stock":40} {"id":3,"name":"Olive tree","light":"sun","stock":2}
A browser is a client too: opening an address sends a GET. So Chromium shows the same list, with its pretty-print switched on.

One plant by its id
One file is getting crowded, so Ana splits it. Files in the same folder share package main, so they use each other's functions with no import. main.go now only lists the routes, and each route names a handler function in handlers.go. The new route has /plants/:id. The colon makes id a path parameter: whatever comes after /plants/ in the address. store.go now keeps the plants in a map instead of a list: Go's lookup table, from each id to its plant.
lsgo.mod go.sum handlers.go main.go store.go
cat main.gopackage main import "github.com/gin-gonic/gin" func main() { r := gin.Default() r.GET("/plants", listPlants) r.GET("/plants/:id", getPlant) r.Run(":8080") }
sed -n '10,16p' store.govar plants = map[int]Plant{ 1: {1, "Fern", "shade", 12}, 2: {2, "Basil", "sun", 40}, 3: {3, "Olive tree", "sun", 2}, } var nextID = 3
getPlant reads that parameter with c.Param. It arrives as text, so strconv.Atoi, from Go's standard package for converting text, turns it into a number, and gives back an error when it can't. Then the map lookup: ok is false when there's no such plant. For a bad id, answer 400, bad request. For a missing plant, 404. Otherwise, the plant. Ana asks for plant 2, and gets Basil.
sed -n '/^func getPlant/,/^}/p' handlers.gofunc getPlant(c *gin.Context) { id, err := strconv.Atoi(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "id must be a number"}) } p, ok := plants[id] if !ok { c.JSON(http.StatusNotFound, gin.H{"error": "plant not found"}) } c.JSON(http.StatusOK, p) }
curl localhost:8080/plants/2{"id":2,"name":"Basil","light":"sun","stock":40}
Now an id that isn't a number: /plants/abc. The first check should catch it. What do you think curl prints? Three answers, glued together: the error, a second error, and an empty plant.
curl localhost:8080/plants/abc{"error":"id must be a number"}{"error":"plant not found"}{"id":0,"name":"","light":"","stock":0}
c.JSON writes an answer, but it doesn't stop the function. So the code ran on: the lookup failed too, and the last line sent an empty plant. The server window says it plainly: headers were already written. The status line and headers go out first, so once the 400 was sent it couldn't change; the rest was just added on. The fix is one word after each error answer: return. Now abc gets a clean 400, and plant 9 a clean 404.
[GIN] 2026/11/13 - 02:24:36 | 200 | 109.667µs | ::1 | GET "/plants/2" [GIN-debug] [WARNING] Headers were already written. Wanted to override status code 400 with 404 [GIN-debug] [WARNING] Headers were already written. Wanted to override status code 400 with 200 [GIN] 2026/11/13 - 02:24:36 | 400 | 90.792µs | ::1 | GET "/plants/abc"
sed -n '/^func getPlant/,/^}/p' handlers.gofunc getPlant(c *gin.Context) { id, err := strconv.Atoi(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "id must be a number"}) return } p, ok := plants[id] if !ok { c.JSON(http.StatusNotFound, gin.H{"error": "plant not found"}) return } c.JSON(http.StatusOK, p) }
curl -i localhost:8080/plants/abcHTTP/1.1 400 Bad Request Content-Type: application/json; charset=utf-8 Date: Fri, 13 Nov 2026 02:24:41 GMT Content-Length: 31 {"error":"id must be a number"}
curl -i localhost:8080/plants/9HTTP/1.1 404 Not Found Content-Type: application/json; charset=utf-8 Date: Fri, 13 Nov 2026 02:24:41 GMT Content-Length: 27 {"error":"plant not found"}
Filtering with a query
Customers keep asking Ana for plants for a shady corner. Can the list do that? That's a query string: a question mark after the path, then a name and a value, light equals shade. c.Query reads it, and gives an empty text when it's not there, so the plain list still works. A map has no order in Go, so the matching plants are collected into a list and sorted by id before they go out. Shade: one plant, the fern. Sun: basil and the olive tree.
sed -n '/^func listPlants/,/^}/p' handlers.gofunc listPlants(c *gin.Context) { light := c.Query("light") list := []Plant{} for _, p := range plants { if light == "" || p.Light == light { list = append(list, p) } } slices.SortFunc(list, func(a, b Plant) int { return a.ID - b.ID }) c.JSON(http.StatusOK, list) }
curl "localhost:8080/plants?light=shade"[{"id":1,"name":"Fern","light":"shade","stock":12}]
curl -s "localhost:8080/plants?light=sun" | jq -c '.[]'{"id":2,"name":"Basil","light":"sun","stock":40} {"id":3,"name":"Olive tree","light":"sun","stock":2}
Path or query? The path names a thing: plant 2 is one plant, and if it doesn't exist, that's a 404. The query narrows a list: light equals shade, and if nothing matches, the answer is an empty list, still 200. A good rule: if the value says which one, use the path. If it says which ones, or how many, use the query.

Adding plants with POST
Adding a plant means reading JSON in, not just sending it out. Ana makes a second struct, NewPlant, for what a client may send: no id, because the server picks that. Each field gets a second tag, binding, with rules Gin checks for her. required: the name must be there. oneof: light is sun or shade, nothing else. min=0: no negative stock.

Here is NewPlant in handlers.go. Three fields, each with its json tag and its binding rules. Keeping it apart from Plant means a client can never choose its own id, even by sending one: there's simply no field to put it in.
sed -n '/^type NewPlant/,/^}/p' handlers.gotype NewPlant struct { Name string `json:"name" binding:"required"` Light string `json:"light" binding:"required,oneof=sun shade"` Stock int `json:"stock" binding:"min=0"` }
The handler, createPlant. ShouldBindJSON reads the request's body into the struct and runs every rule. If anything fails, answer 400 with the reason, and return. Otherwise, count nextID up by one, store the plant under it, and answer 201, created, with the new plant, so the client learns its id. Ana registers it with r.POST, and the server lists a third route.
sed -n '/^func createPlant/,/^}/p' handlers.gofunc createPlant(c *gin.Context) { var in NewPlant if err := c.ShouldBindJSON(&in); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } nextID++ p := Plant{ID: nextID, Name: in.Name, Light: in.Light, Stock: in.Stock} plants[p.ID] = p c.JSON(http.StatusCreated, p) }
go run .[GIN-debug] [WARNING] Creating an Engine instance with the Logger and Recovery middleware already attached. [GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production. - using env: export GIN_MODE=release - using code: gin.SetMode(gin.ReleaseMode) [GIN-debug] GET /plants --> main.listPlants (3 handlers) [GIN-debug] GET /plants/:id --> main.getPlant (3 handlers) [GIN-debug] POST /plants --> main.createPlant (3 handlers) [GIN-debug] [WARNING] You trusted all proxies, this is NOT safe. We recommend you to set a value. Please check https://github.com/gin-gonic/gin/blob/master/docs/doc.md#dont-trust-all-proxies for details. [GIN-debug] Listening and serving HTTP on :8080
With curl, -X picks the verb, POST, and -d sends a body: a mint plant, sun, five in stock. 201 created, and it's plant 4. Now three broken requests. No name: the required rule fails. Light set to moon: the oneof rule. Stock as the word five: the JSON itself doesn't fit an int. Each one a 400 with a reason, and nothing stored.
curl -i -X POST localhost:8080/plants -d '{"name":"Mint","light":"sun","stock":5}'HTTP/1.1 201 Created Content-Type: application/json; charset=utf-8 Date: Fri, 13 Nov 2026 02:24:45 GMT Content-Length: 46 {"id":4,"name":"Mint","light":"sun","stock":5}
curl -X POST localhost:8080/plants -d '{"light":"sun","stock":5}'{"error":"Key: 'NewPlant.Name' Error:Field validation for 'Name' failed on the 'required' tag"}
curl -X POST localhost:8080/plants -d '{"name":"Mint","light":"moon","stock":5}'{"error":"Key: 'NewPlant.Light' Error:Field validation for 'Light' failed on the 'oneof' tag"}
curl -X POST localhost:8080/plants -d '{"name":"Mint","light":"sun","stock":"five"}'{"error":"json: cannot unmarshal string into Go struct field NewPlant.stock of type int"}
jq length counts the plants in the list: four now. Ana restarts the server, and asks again: three. The mint is gone, because the map lives in the program's memory, and a new program starts empty. A real nursery would keep its plants in a database; only store.go would change. The routes and handlers stay as they are.
curl -s localhost:8080/plants | jq length4
curl -s localhost:8080/plants | jq length3
Changing and deleting
Two more routes complete the set. PUT replaces a plant: it checks the id and the body like before, then stores the new version. DELETE removes one, and answers 204, no content: success, with nothing to send back.
grep -n 'r\.' main.go7: r.GET("/plants", listPlants) 8: r.GET("/plants/:id", getPlant) 9: r.POST("/plants", createPlant) 10: r.PUT("/plants/:id", updatePlant) 11: r.DELETE("/plants/:id", deletePlant) 12: r.Run(":8080")
sed -n '/^func deletePlant/,/^}/p' handlers.gofunc deletePlant(c *gin.Context) { id, err := strconv.Atoi(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "id must be a number"}) return } if _, ok := plants[id]; !ok { c.JSON(http.StatusNotFound, gin.H{"error": "plant not found"}) return } delete(plants, id) c.Status(http.StatusNoContent) }
Seven olive trees arrived, so Ana puts plant 3 with a stock of seven. She deletes plant 1, the fern: 204. Deleting it again: 404, it's already gone. The list shows two plants, and the olive tree's new stock.
curl -X PUT localhost:8080/plants/3 -d '{"name":"Olive tree","light":"sun","stock":7}'{"id":3,"name":"Olive tree","light":"sun","stock":7}
curl -i -X DELETE localhost:8080/plants/1HTTP/1.1 204 No Content Date: Fri, 13 Nov 2026 02:24:52 GMT
curl -i -X DELETE localhost:8080/plants/1HTTP/1.1 404 Not Found Content-Type: application/json; charset=utf-8 Date: Fri, 13 Nov 2026 02:24:52 GMT Content-Length: 27 {"error":"plant not found"}
curl -s localhost:8080/plants | jq -c '.[]'{"id":2,"name":"Basil","light":"sun","stock":40} {"id":3,"name":"Olive tree","light":"sun","stock":7}
That's every status code this API uses so far. The 200s mean it worked: 200 with data, 201 when something new was made, 204 when there's nothing to send. The 400s mean the client got something wrong: 400 for bad input, 404 for nothing there, and soon, 401 for a missing key. And 500 means the server got it wrong. Picking the right one lets every client react without reading your error text.

Many requests at once
So far, one request at a time. A real API gets many at once, and Gin handles each request in its own goroutine: a lightweight thread, so the requests really do run at the same moment. Every one of them reaches the same map.

Ana tests that. seq counts to 300, and xargs runs curl for each number, thirty at a time: three hundred new plants, all posted at once. What happens to the server? It dies. Fatal error: concurrent map writes. Ana's next request finds nothing listening.
seq 300 | xargs -P 30 -I{} curl -s -o /dev/null -X POST localhost:8080/plants -d '{"name":"Mint","light":"sun","stock":5}'; echo donedone
[GIN] 2026/11/13 - 02:24:52 | 200 | 385.541µs | ::1 | PUT "/plants/3" [GIN] 2026/11/13 - 02:24:52 | 204 | 1.875µs | ::1 | DELETE "/plants/1" [GIN] 2026/11/13 - 02:24:52 | 404 | 49.667µs | ::1 | DELETE "/plants/1" [GIN] 2026/11/13 - 02:24:52 | 200 | 73.708µs | ::1 | GET "/plants" [GIN] 2026/11/13 - 02:24:52 | 201 | 62.958µs | ::1 | POST "/plants" [GIN] 2026/11/13 - 02:24:52 | 201 | 43.958µs | ::1 | POST "/plants" [GIN] 2026/11/13 - 02:24:52 | 201 | 12µs | ::1 | POST "/plants" [GIN] 2026/11/13 - 02:24:52 | 201 | 33.333µs | ::1 | POST "/plants" [GIN] 2026/11/13 - 02:24:52 | 201 | 52.958µs | ::1 | POST "/plants" [GIN] 2026/11/13 - 02:24:52 | 201 | 20.834µs | ::1 | POST "/plants" fatal error: concurrent map writes [GIN] 2026/11/13 - 02:24:52 | 201 | 30.167µs | ::1 | POST "/plants" goroutine 67 [running]: internal/runtime/maps.fatal({0x8e7ffd?, 0x1287d05200a0?}) /usr/local/go/src/runtime/panic.go:1197 +0x20 main.createPlant(0x1287d02fc300) /home/ana/nursery/handlers.go:51 +0x1a8 github.com/gin-gonic/gin.(*Context).Next(0x1287d02fc300) /home/ana/go/pkg/mod/github.com/gin-gonic/gin@v1.12.0/context.go:192 +0x74 github.com/gin-gonic/gin.CustomRecoveryWithWriter.func1(0x1287d04806c8?) /home/ana/go/pkg/mod/github.com/gin-gonic/gin@v1.12.0/recovery.go:90 +0x48 github.com/gin-gonic/gin.(*Context).Next(0x1287d02fc300) /home/ana/go/pkg/mod/github.com/gin-gonic/gin@v1.12.0/context.go:192 +0x74 github.com/gin-gonic/gin.LoggerWithConfig.func1(0x1287d02fc300) /home/ana/go/pkg/mod/github.com/gin-gonic/gin@v1.12.0/logger.go:282 +0x84 github.com/gin-gonic/gin.(*Context).Next(0x1287d02fc300) /home/ana/go/pkg/mod/github.com/gin-gonic/gin@v1.12.0/context.go:192 +0x74 github.com/gin-gonic/gin.(*Engine).handleHTTPRequest(0x1287d033a380, 0x1287d02fc300) /home/ana/go/pkg/mod/github.com/gin-gonic/gin@v1.12.0/gin.go:722 +0x344 github.com/gin-gonic/gin.(*Engine).ServeHTTP(0x1287d033a380, {0x127fbd8, 0x1287d0534000}, 0x1287d0526000) /home/ana/go/pkg/mod/github.com/gin-gonic/gin@v1.12.0/gin.go:672 +0x178 net/http.serverHandler.ServeHTTP({0x10?}, {0x127fbd8?, 0x1287d0534000?}, 0x1?) /usr/local/go/src/net/http/server.go:3440 +0xb0 net/http.(*conn).serve(0x1287d0518120, {0x1280638, 0x1287d0341350}) /usr/local/go/src/net/http/server.go:2161 +0x5a0 created by net/http.(*Server).Serve in goroutine 1 /usr/local/go/src/net/http/server.go:3608 +0x3e8 exit status 2
curl localhost:8080/plantscurl: (7) Failed to connect to localhost port 8080 after 0 ms: Couldn't connect to server
Two goroutines wrote to the map at the same moment. Go's maps are not built for that, and the runtime, the part of Go that runs your program, checks for it and stops everything rather than keep going with a broken map. The lines below it are the stack trace: the functions that were running, newest on top. Notice Recovery in the stack: it was there, and couldn't help. A panic, the ordinary kind of crash, is what Recovery catches. This is a fatal error instead, and nothing can catch it.
The fix is a mutex, short for mutual exclusion: a lock that only one goroutine can hold at a time. Ana adds one next to the map, and every handler starts with mu.Lock, and defer mu.Unlock, which unlocks when the handler returns, whichever way it leaves. The same 300 requests again. The server is still up, and it holds 303 plants: the first three, plus every one of the 300.
tail -n 2 store.govar mu sync.Mutex
sed -n '/^func createPlant/,/^}/p' handlers.gofunc createPlant(c *gin.Context) { mu.Lock() defer mu.Unlock() var in NewPlant if err := c.ShouldBindJSON(&in); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } nextID++ p := Plant{ID: nextID, Name: in.Name, Light: in.Light, Stock: in.Stock} plants[p.ID] = p c.JSON(http.StatusCreated, p) }
seq 300 | xargs -P 30 -I{} curl -s -o /dev/null -X POST localhost:8080/plants -d '{"name":"Mint","light":"sun","stock":5}'; echo donedone
curl -s localhost:8080/plants | jq length303
Middleware and a key
Right now, anyone who can reach the server can add or delete plants. Ana wants a key for her staff. A check that runs before handlers is called middleware, like Logger and Recovery. Back in the mail room, it's the desk at the door that checks a badge before an envelope goes in. requireKey compares a header, X-API-Key, a label written on the envelope, with a setting the server reads at start, NURSERY_KEY. No match: AbortWithStatusJSON answers 401, unauthorized, and stops the chain. A match: c.Next passes the request on.
cat middleware.gopackage main import ( "net/http" "os" "github.com/gin-gonic/gin" ) func requireKey(c *gin.Context) { if c.GetHeader("X-API-Key") != os.Getenv("NURSERY_KEY") { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing or wrong API key"}) return } c.Next() }
Why not write the key in the code? Code gets copied, into Git and onto laptops, and the key would travel with it. An environment variable is a setting the server is started with, so each machine can have its own key, and the code never holds one.

To put it only on the staff routes, Ana uses a route group: r.Group, slash plants, with requireKey. POST, PUT and DELETE move into the group. The two GET routes stay outside, open to customers. The server window shows it: the three staff routes now have four handlers: Logger, Recovery, requireKey, and the handler itself.
sed -n '/^func main/,/^}/p' main.gofunc main() { r := gin.Default() r.GET("/plants", listPlants) r.GET("/plants/:id", getPlant) staff := r.Group("/plants", requireKey) staff.POST("", createPlant) staff.PUT("/:id", updatePlant) staff.DELETE("/:id", deletePlant) r.Run(":8080") }
go run .[GIN-debug] [WARNING] Creating an Engine instance with the Logger and Recovery middleware already attached. [GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production. - using env: export GIN_MODE=release - using code: gin.SetMode(gin.ReleaseMode) [GIN-debug] GET /plants --> main.listPlants (3 handlers) [GIN-debug] GET /plants/:id --> main.getPlant (3 handlers) [GIN-debug] POST /plants --> main.createPlant (4 handlers) [GIN-debug] PUT /plants/:id --> main.updatePlant (4 handlers) [GIN-debug] DELETE /plants/:id --> main.deletePlant (4 handlers) [GIN-debug] [WARNING] You trusted all proxies, this is NOT safe. We recommend you to set a value. Please check https://github.com/gin-gonic/gin/blob/master/docs/doc.md#dont-trust-all-proxies for details. [GIN-debug] Listening and serving HTTP on :8080
Ana started this server with go run, as always. She sends a POST with no key at all. 201, created. The key check let her in. Here's why: NURSERY_KEY was never set, so the server read an empty text. The request had no header, which also reads as an empty text. Empty equals empty, so every request passes. That's the key check anyone could skip.
curl -i -X POST localhost:8080/plants -d '{"name":"Mint","light":"sun","stock":5}'HTTP/1.1 201 Created Content-Type: application/json; charset=utf-8 Date: Fri, 13 Nov 2026 02:25:03 GMT Content-Length: 46 {"id":4,"name":"Mint","light":"sun","stock":5}
Started with the key set, the check works. No header: 401, unauthorized. The right key in the header: 201. So Ana makes the server refuse to start without a key. main reads it once, and if it's empty, log.Fatal prints why and exits. Now forgetting the key stops the server at once, instead of quietly letting everyone in.
NURSERY_KEY=green-thumb-42 go run .[GIN-debug] [WARNING] Creating an Engine instance with the Logger and Recovery middleware already attached. [GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production. - using env: export GIN_MODE=release - using code: gin.SetMode(gin.ReleaseMode) [GIN-debug] GET /plants --> main.listPlants (3 handlers) [GIN-debug] GET /plants/:id --> main.getPlant (3 handlers) [GIN-debug] POST /plants --> main.createPlant (4 handlers) [GIN-debug] PUT /plants/:id --> main.updatePlant (4 handlers) [GIN-debug] DELETE /plants/:id --> main.deletePlant (4 handlers) [GIN-debug] [WARNING] You trusted all proxies, this is NOT safe. We recommend you to set a value. Please check https://github.com/gin-gonic/gin/blob/master/docs/doc.md#dont-trust-all-proxies for details. [GIN-debug] Listening and serving HTTP on :8080
curl -i -X POST localhost:8080/plants -d '{"name":"Mint","light":"sun","stock":5}'HTTP/1.1 401 Unauthorized Content-Type: application/json; charset=utf-8 Date: Fri, 13 Nov 2026 02:25:06 GMT Content-Length: 36 {"error":"missing or wrong API key"}
curl -i -X POST localhost:8080/plants -H 'X-API-Key: green-thumb-42' -d '{"name":"Mint","light":"sun","stock":5}'HTTP/1.1 201 Created Content-Type: application/json; charset=utf-8 Date: Fri, 13 Nov 2026 02:25:06 GMT Content-Length: 46 {"id":4,"name":"Mint","light":"sun","stock":5}
sed -n '/^func main/,/^}/p' main.gofunc main() { key := os.Getenv("NURSERY_KEY") if key == "" { log.Fatal("NURSERY_KEY is not set") } setupRouter(key).Run(":8080") }
go run .2026/11/13 02:25:09 NURSERY_KEY is not set exit status 1
Tests
Every fix so far was checked by hand, with curl. A test checks it for you, every time. Tests live in files ending in underscore test dot go, and go test finds them by that name. Ana moved the routes into a function, setupRouter, so a test can build the same router. httptest.NewRecorder catches the answer, NewRequest builds a request, and ServeHTTP runs it through Gin with no network at all. If the code isn't 200, or Fern is missing, t.Fatalf, from the test's helper t, fails the test.
sed -n '17,25p' main_test.gofunc TestGetPlant(t *testing.T) { r := setupRouter("test-key") w := httptest.NewRecorder() r.ServeHTTP(w, httptest.NewRequest("GET", "/plants/1", nil)) if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `"name":"Fern"`) { t.Fatalf("got %d %s", w.Code, w.Body.String()) } }
Three more tests check that a missing plant gets 404, that a bad id gets exactly one answer, and that a POST without the key gets 401. go test -v runs them all, and names each one. Four passes, in a few thousandths of a second.
go test -v=== RUN TestGetPlant [GIN] 2026/11/13 - 02:25:15 | 200 | 59.958µs | 192.0.2.1 | GET "/plants/1" --- PASS: TestGetPlant (0.00s) === RUN TestMissingPlant [GIN] 2026/11/13 - 02:25:15 | 404 | 8.417µs | 192.0.2.1 | GET "/plants/99" --- PASS: TestMissingPlant (0.00s) === RUN TestCreateNeedsKey [GIN] 2026/11/13 - 02:25:15 | 401 | 6.25µs | 192.0.2.1 | POST "/plants" --- PASS: TestCreateNeedsKey (0.00s) === RUN TestBadID [GIN] 2026/11/13 - 02:25:15 | 400 | 3.5µs | 192.0.2.1 | GET "/plants/abc" --- PASS: TestBadID (0.00s) PASS ok nursery 0.003s
Here's what a test is for. Ana deletes that return again, just for a moment, to bring the bug back, and runs go test. FAIL: TestBadID, with two answers glued together, printed right there, the same bug as before, caught in one second. With the return back in place, ok again.
go test[GIN] 2026/11/13 - 02:25:16 | 200 | 49.792µs | 192.0.2.1 | GET "/plants/1" [GIN] 2026/11/13 - 02:25:16 | 404 | 7.375µs | 192.0.2.1 | GET "/plants/99" [GIN] 2026/11/13 - 02:25:16 | 401 | 5.875µs | 192.0.2.1 | POST "/plants" [GIN] 2026/11/13 - 02:25:16 | 400 | 3.75µs | 192.0.2.1 | GET "/plants/abc" --- FAIL: TestBadID (0.00s) main_test.go:51: got 400 {"error":"id must be a number"}{"error":"plant not found"}, want 400 {"error":"id must be a number"} FAIL exit status 1 FAIL nursery 0.002s
go test[GIN] 2026/11/13 - 02:25:16 | 200 | 53.416µs | 192.0.2.1 | GET "/plants/1" [GIN] 2026/11/13 - 02:25:16 | 404 | 7.667µs | 192.0.2.1 | GET "/plants/99" [GIN] 2026/11/13 - 02:25:16 | 401 | 5.792µs | 192.0.2.1 | POST "/plants" [GIN] 2026/11/13 - 02:25:16 | 400 | 3.209µs | 192.0.2.1 | GET "/plants/abc" PASS ok nursery 0.002s
One file to ship
Last, shipping it. go build compiles everything into one file: nursery-api, 28MB. Gin, every package it uses, and Go's own runtime are inside, so the server that runs it needs no Go at all. In the server window, Ana starts the file itself, with GIN_MODE set to release: the debug lines are gone. It answers the same, and Logger still writes its line.
go build -o nursery-api
ls -lh nursery-api-rwxr-xr-x 1 ana ana 28M Nov 13 02:25 nursery-api
GIN_MODE=release NURSERY_KEY=green-thumb-42 ./nursery-api
curl localhost:8080/plants/2{"id":2,"name":"Basil","light":"sun","stock":40}
[GIN] 2026/11/13 - 02:25:19 | 200 | 176.292µs | ::1 | GET "/plants/2"
And Go builds for other systems from the same laptop. Set GOOS, the target system, to windows, and GOARCH, the target processor, to amd64, and go build makes a Windows program, an.exe you can copy to a Windows server and run.
GOOS=windows GOARCH=amd64 go build -o nursery-api.exe
file nursery-api.exenursery-api.exe: PE32+ executable (console) x86-64, for MS Windows, 16 sections
Where would Ana take it next? A database, so plants survive a restart: only store.go changes, and a database handles many writers itself. Settings from the environment, like the key, for the port too. A container image, so the file runs the same everywhere. And versioned paths, like /v1/plants, so a later change doesn't break old clients.

Recap
Three things to keep. Capital letters and tags decide what JSON sees. After every error answer, return. And anything handlers share, lock it.

If you built along, a like helps YouTube show this to more beginners, and subscribing brings you the next tutorial.
Sources
- Gin documentation (quickstart, routing, parameters in path, query string, model binding and validation, middleware, grouping routes, testing)
- Gin source, v1.12.0 (gin.Default adds Logger and Recovery; the 'Headers were already written' warning)
- The Go Programming Language Specification, exported identifiers
- encoding/json (only exported fields are encoded; struct tags)
- Go FAQ, why are map operations not defined to be atomic
- sync.Mutex
- net/http/httptest
- Go modules reference (go mod init, go mod tidy, go.sum)
- Go downloads and install steps
- validator (binding tags required, oneof, min)
Demos: a stock Ubuntu 24.04 container on our own lab machine, standing in for Ana's laptop; the plant nursery and its plants are made up. Watch the full video on YouTube.