Building a REST API with Gin

A complete CRUD API using route groups, path/query params, and proper JSON error responses.

A complete CRUD API: Tasks

Putting routing, groups, binding, and validation together into one realistic example — a small task-management API.

Go
package main

import (
    "net/http"
    "strconv"
    "sync"

    "github.com/gin-gonic/gin"
)

type Task struct {
    ID    int    `json:"id"`
    Title string `json:"title" binding:"required"`
    Done  bool   `json:"done"`
}

type TaskStore struct {
    mu     sync.Mutex
    tasks  map[int]Task
    nextID int
}

func NewTaskStore() *TaskStore {
    return &TaskStore{tasks: make(map[int]Task), nextID: 1}
}

func main() {
    store := NewTaskStore()
    r := gin.Default()

    v1 := r.Group("/api/v1")
    {
        v1.GET("/tasks", store.listTasks)
        v1.GET("/tasks/:id", store.getTask)
        v1.POST("/tasks", store.createTask)
        v1.PUT("/tasks/:id", store.updateTask)
        v1.DELETE("/tasks/:id", store.deleteTask)
    }

    r.Run(":8080")
}

Grouping every route under /api/v1 in one place makes versioning explicit and lets you attach group-wide middleware (auth, rate limiting) in a single .Use() call instead of repeating it on every route.

List, with query-parameter filtering

Go
func (s *TaskStore) listTasks(c *gin.Context) {
    s.mu.Lock()
    defer s.mu.Unlock()

    onlyDone := c.Query("done") == "true" // ?done=true

    result := make([]Task, 0)
    for _, t := range s.tasks {
        if onlyDone && !t.Done {
            continue
        }
        result = append(result, t)
    }

    c.JSON(http.StatusOK, gin.H{"tasks": result})
}

Get by ID, with a proper error response

Go
func (s *TaskStore) getTask(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
    }

    s.mu.Lock()
    defer s.mu.Unlock()

    task, ok := s.tasks[id]
    if !ok {
        c.JSON(http.StatusNotFound, gin.H{"error": "task not found"})
        return
    }

    c.JSON(http.StatusOK, task)
}

Create, with binding + validation

Go
func (s *TaskStore) createTask(c *gin.Context) {
    var input Task
    if err := c.ShouldBindJSON(&input); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
        return
    }

    s.mu.Lock()
    defer s.mu.Unlock()

    input.ID = s.nextID
    s.nextID++
    s.tasks[input.ID] = input

    c.JSON(http.StatusCreated, input)
}

Update

Go
func (s *TaskStore) updateTask(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
    }

    var input Task
    if err := c.ShouldBindJSON(&input); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
        return
    }

    s.mu.Lock()
    defer s.mu.Unlock()

    if _, ok := s.tasks[id]; !ok {
        c.JSON(http.StatusNotFound, gin.H{"error": "task not found"})
        return
    }

    input.ID = id
    s.tasks[id] = input
    c.JSON(http.StatusOK, input)
}

Delete

Go
func (s *TaskStore) deleteTask(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
    }

    s.mu.Lock()
    defer s.mu.Unlock()

    if _, ok := s.tasks[id]; !ok {
        c.JSON(http.StatusNotFound, gin.H{"error": "task not found"})
        return
    }

    delete(s.tasks, id)
    c.Status(http.StatusNoContent)
}

Trying it end to end

Bash
curl -X POST localhost:8080/api/v1/tasks -d '{"title":"Write docs"}'
# {"id":1,"title":"Write docs","done":false}

curl "localhost:8080/api/v1/tasks?done=false"
# {"tasks":[{"id":1,"title":"Write docs","done":false}]}

curl -X PUT localhost:8080/api/v1/tasks/1 -d '{"title":"Write docs","done":true}'
# {"id":1,"title":"Write docs","done":true}

curl -X DELETE localhost:8080/api/v1/tasks/1
# 204 No Content

A consistent error shape

Every error response above uses the same {"error": "..."} shape. For a larger API, formalize this with a small helper so every handler produces identically-structured errors instead of ad hoc ones:

Go
func errorResponse(c *gin.Context, status int, message string) {
    c.JSON(status, gin.H{"error": message})
}

Common mistakes

  • Sharing mutable in-memory state (like the map[int]Task above) across goroutines without a mutex — Gin handles each request on its own goroutine, so concurrent requests can race on unsynchronized shared state.
  • Inconsistent error response shapes across different handlers, making client-side error handling harder than it needs to be — pick one JSON error shape for the whole API.
  • Returning 200 OK for a delete that didn't actually find anything to delete — check existence first and return 404 so clients can tell the two cases apart.

Interview questions

Q: Why group routes with r.Group("/api/v1") instead of repeating the prefix on every route? It keeps versioning and shared concerns in one place — the prefix is defined once, and any middleware you .Use() on the group (authentication, rate limiting, logging) automatically applies to every route registered under it, without repeating .Use() calls per route.

Q: How would you version a Gin API as it evolves? Typically with a path prefix per version (/api/v1, /api/v2 as separate route groups), letting both versions run side by side during a migration period, backed by handlers that can share underlying logic while only the request/response shape or route differs between versions.

Q: Why does the example above use a mutex around the in-memory task map? Gin (like net/http) handles each incoming request on its own goroutine, so multiple requests can read and write the shared map concurrently. Go maps are not safe for concurrent access, so without a sync.Mutex guarding every access, concurrent requests could corrupt the map or trigger a runtime crash.