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