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 streaming import ( "bufio"
Streaming NDJSON logs over HTTP in Go
http-streaming
channels
select
Advanced
10 steps
php
<?php namespace App\Services\Checkout;
Validating coupons with Laravel's Pipeline
pipeline
chain of responsibility
transactions
Intermediate
7 steps
go
package api import ( "crypto/sha256"
ETag conditional requests in Gin
http-caching
etag
conditional-requests
Intermediate
6 steps
php
<?php namespace App\Services;
How a password strength validator works in PHP
validation
regular-expressions
data-driven
Intermediate
8 steps
go
func (w *Watcher) resetDebounce(d time.Duration) { if !w.timer.Stop() { select { case <-w.timer.C:
Debouncing a stream of events in Go
debounce
timers
channels
Advanced
7 steps
rust
use chrono::{Duration, NaiveDate}; #[derive(Debug)] pub struct DateRange {
Parsing and iterating date ranges in Rust
error-handling
iterators
parsing
Intermediate
7 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.