Go Programming Introduction: Build a User System With Gin

Build a first Go backend with Gin: module setup, a validated user model, a concurrency-safe store and three JSON endpoints, with curl examples and the video walkthrough.

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 Store with a database. GORM plus SQLite is the quickest path and needs no server; Postgres via pgx is the production-grade one.
  • Configuration. Read the port and database URL from environment variables instead of hard-coding them.
  • Tests. net/http/httptest lets 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.