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.
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
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
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
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
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
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
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:
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]Taskabove) 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 OKfor a delete that didn't actually find anything to delete — check existence first and return404so 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.