go 62 lines · 8 steps

Query-string filtering in Gin with GORM

Bind and validate URL query params into a struct, then build a conditional GORM query from whatever the client actually sent.

Explained by highlit
1package handlers
2 
3import (
4 "net/http"
5 "time"
6 
7 "github.com/gin-gonic/gin"
8)
9 
10type ProductFilter struct {
11 Categories []string `form:"category"`
12 Tags []int `form:"tag"`
13 Statuses []string `form:"status" binding:"omitempty,dive,oneof=active draft archived"`
14 MinPrice float64 `form:"min_price" binding:"omitempty,gte=0"`
15 MaxPrice float64 `form:"max_price" binding:"omitempty,gtefield=MinPrice"`
16 Sort string `form:"sort,default=created_at"`
17 Page int `form:"page,default=1" binding:"gte=1"`
18 PerPage int `form:"per_page,default=20" binding:"gte=1,lte=100"`
19}
20 
21func (h *Handler) ListProducts(c *gin.Context) {
22 var filter ProductFilter
23 if err := c.ShouldBindQuery(&filter); err != nil {
24 c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
25 return
26 }
27 
28 query := h.db.WithContext(c.Request.Context()).Model(&Product{})
29 
30 if len(filter.Categories) > 0 {
31 query = query.Where("category IN ?", filter.Categories)
32 }
33 if len(filter.Tags) > 0 {
34 query = query.
35 Joins("JOIN product_tags pt ON pt.product_id = products.id").
36 Where("pt.tag_id IN ?", filter.Tags).
37 Group("products.id")
38 }
39 if len(filter.Statuses) > 0 {
40 query = query.Where("status IN ?", filter.Statuses)
41 }
42 if filter.MaxPrice > 0 {
43 query = query.Where("price BETWEEN ? AND ?", filter.MinPrice, filter.MaxPrice)
44 }
45 
46 var products []Product
47 if err := query.
48 Order(filter.Sort).
49 Offset((filter.Page - 1) * filter.PerPage).
50 Limit(filter.PerPage).
51 Find(&products).Error; err != nil {
52 c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load products"})
53 return
54 }
55 
56 c.JSON(http.StatusOK, gin.H{
57 "data": products,
58 "page": filter.Page,
59 "per_page": filter.PerPage,
60 "applied_at": time.Now().UTC(),
61 })
62}
01 / 01
STEP 01

Walkthrough

Space play ←→ step click any line
Three takeaways
  1. 1Struct tags let you declare parsing, defaults, and validation rules in one place instead of scattering manual checks.
  2. 2Building a query incrementally with guarded Where clauses keeps optional filters truly optional.
  3. 3Validating pagination and cross-field constraints at the boundary protects the database layer from bad input.

Related explainers

Share this explainer

Here's the card — post it anywhere.

Query-string filtering in Gin with GORM — share card
Made with highlit — turn any snippet into a walkthrough like this in about a minute.
Explain your code