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
‹ swipe to step through ›
Walkthrough
Space play
←→ step
click any line
Three takeaways
- 1Struct tags let you declare parsing, defaults, and validation rules in one place instead of scattering manual checks.
- 2Building a query incrementally with guarded Where clauses keeps optional filters truly optional.
- 3Validating pagination and cross-field constraints at the boundary protects the database layer from bad input.
Related explainers
go
package theme import ( "fmt"
Per-tenant HTML templates with Gin's renderer
multi-tenancy
concurrency
html-templates
Advanced
8 steps
php
<?php namespace App\Http\Controllers;
Streaming a filtered CSV export in Laravel
streaming
csv-export
query-builder
Intermediate
9 steps
go
package auth import ( "net/http"
Setting and reading secure session cookies in Go
cookies
session-management
security
Intermediate
6 steps
java
@Repository public interface OrderRepository extends JpaRepository<Order, Long> { @Query("""
Keyset pagination with Spring Data JPA
pagination
cursor
jpa
Advanced
8 steps
go
package middleware import ( "net/http"
A maintenance-mode gate in Gin
middleware
atomic-flag
concurrency
Intermediate
8 steps
go
func ServeVideo(c *gin.Context) { id := c.Param("id") video, err := videoRepo.FindByID(c.Request.Context(), id) if err != nil {
Streaming video files with Gin
http streaming
range requests
file serving
Intermediate
6 steps
Share this explainer
Here's the card — post it anywhere.
Made with highlit — turn any snippet into a walkthrough like this in about a minute.
Explain your code
Embed this explainer
Drop the interactive walkthrough into a blog or docs. Views never cost a credit.
<iframe src="https://highlit.co/explainers/query-string-filtering-in-gin-with-gorm-explained-go-b8b3/embed?autoplay=1" width="100%" height="520" loading="lazy" style="border:0"></iframe>
Autoplay is on by default — add ?autoplay=0 to start paused.