This is the written companion to the video above: a first Go backend, built with the Gin web framework, that creates and lists users. If you have never written Go before, you can follow this top to bottom in about half an hour; if you have, skim to the handlers.
Why Go for a backend
Go compiles to a single static binary, starts in milliseconds, and handles concurrent requests with goroutines rather than threads or an event loop you have to reason about. The standard library already includes an HTTP server, JSON encoding and a testing framework. Gin adds a router with path parameters, middleware and request binding on top, with very little magic. For an API that mostly moves JSON between clients and a database, that is a comfortable fit.
1. Set up the project
Install Go from go.dev/dl and confirm it with go version. Then create a module. The module path is just a name; it does not have to exist on GitHub yet.
mkdir usersapi && cd usersapi
go mod init github.com/yourname/usersapi
go get github.com/gin-gonic/gin
go.mod records the module name and its dependencies, and go.sum pins their checksums. Commit both.
2. A server that answers
Create main.go:
package main
import (
"net/http"
"github.com/gin-gonic/gin"
)
func main() {
r := gin.Default()
r.GET("/health", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok"})
})
r.Run(":8080")
}
gin.Default() gives you a router with logging and panic-recovery middleware already attached. gin.H is a shorthand for map[string]any. Run it with go run . and open http://localhost:8080/health. You should see {"status":"ok"}.
3. Define the user model
Keep the data type in its own file, user.go:
package main
import "time"
type User struct {
ID uint `json:"id"`
Name string `json:"name" binding:"required"`
Email string `json:"email" binding:"required,email"`
CreatedAt time.Time `json:"created_at"`
}
The struct tags do two jobs. json:"..." controls the field names in responses. binding:"..." tells Gin how to validate incoming JSON: required rejects a missing name, and required,email rejects a missing or malformed email. You get validation errors for free without writing a line of checking code.
4. Store users
For a first version, an in-memory store with a mutex is enough to understand the shape of the API. Put it in store.go:
package main
import (
"sync"
"time"
)
type Store struct {
mu sync.Mutex
nextID uint
users map[uint]User
}
func NewStore() *Store {
return &Store{nextID: 1, users: map[uint]User{}}
}
func (s *Store) Create(u User) User {
s.mu.Lock()
defer s.mu.Unlock()
u.ID = s.nextID
u.CreatedAt = time.Now()
s.users[u.ID] = u
s.nextID++
return u
}
func (s *Store) List() []User {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]User, 0, len(s.users))
for _, u := range s.users {
out = append(out, u)
}
return out
}
func (s *Store) Get(id uint) (User, bool) {
s.mu.Lock()
defer s.mu.Unlock()
u, ok := s.users[id]
return u, ok
}
The mutex matters: Gin serves every request on its own goroutine, so two concurrent POSTs would otherwise race on the map. Swapping this struct for a database later (the video uses GORM with SQLite) does not change the handlers, which is the point of keeping it behind methods.
5. Write the handlers
Handlers take a *gin.Context, read the request from it and write the response to it. In handlers.go:
package main
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
)
type Handlers struct {
store *Store
}
func (h *Handlers) CreateUser(c *gin.Context) {
var in User
if err := c.ShouldBindJSON(&in); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
u := h.store.Create(in)
c.JSON(http.StatusCreated, u)
}
func (h *Handlers) ListUsers(c *gin.Context) {
c.JSON(http.StatusOK, h.store.List())
}
func (h *Handlers) GetUser(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
u, ok := h.store.Get(uint(id))
if !ok {
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
return
}
c.JSON(http.StatusOK, u)
}
ShouldBindJSON decodes the body into the struct and runs the binding rules. c.Param("id") reads the :id segment from the route. Each early return after writing an error is deliberate; Gin does not stop the handler for you.
6. Wire the routes
Replace the body of main():
func main() {
store := NewStore()
h := &Handlers{store: store}
r := gin.Default()
r.GET("/health", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok"})
})
api := r.Group("/api/v1")
{
api.POST("/users", h.CreateUser)
api.GET("/users", h.ListUsers)
api.GET("/users/:id", h.GetUser)
}
r.Run(":8080")
}
Route groups keep a version prefix in one place and are where you would attach auth middleware later with api.Use(...).
7. Try it
With go run . in one terminal, in another:
curl -s -X POST localhost:8080/api/v1/users \
-H 'Content-Type: application/json' \
-d '{"name":"Ada","email":"[email protected]"}'
curl -s localhost:8080/api/v1/users
curl -s localhost:8080/api/v1/users/1
curl -s -X POST localhost:8080/api/v1/users -d '{"name":"NoEmail"}'
The last call returns 400 with a validation message naming the Email field. The video does the same walk-through in Postman if you prefer a UI.
Where to go next
- Persistence. Replace
Storewith a database. GORM plus SQLite is the quickest path and needs no server; Postgres viapgxis the production-grade one. - Configuration. Read the port and database URL from environment variables instead of hard-coding them.
- Tests.
net/http/httptestlets you run the Gin router in-process and assert on responses without starting a server. - Structure. Once the handlers grow, split into packages (
internal/users,internal/http). Resist doing that on day one. - Frontend. Part 4 of this series covers calling the API from a browser and fixing the CORS errors you will hit immediately.
That is a complete, if small, Go backend: a module, a router, a validated model, a safe store and three JSON endpoints. Everything after this is the same shapes repeated with more care.


