Most bugs and security issues in a JSON API start where untrusted input comes in. This article covers the three input-heavy features almost every Gin API needs: Gin validation for body, query and path parameters with error messages clients can use; safe image uploads with FormFile; and pagination with limit, offset and total pages, where the total count runs concurrently with the page query.
This is part three of the series. Part one built the articles API with GORM and PostgreSQL, and part two added JWT login and Casbin roles. The code extends the same project.
Where request data comes from in Gin
Gin binds each input source into a struct, using a different tag for each:
| Source | Example | Struct tag | Bind method |
|---|---|---|---|
| JSON body | {"title":"..."} | json:"title" | c.ShouldBindJSON(&req) |
| Query string | ?page=2&limit=12 | form:"page" | c.ShouldBindQuery(&q) |
| Path parameter | /articles/:id | uri:"id" | c.ShouldBindUri(&u) |
| Multipart or URL-encoded form | file uploads | form:"title" | c.ShouldBind(&f) |
c.Query, c.DefaultQuery, c.Param and c.PostForm still work for single values, but return strings you must convert and check yourself. Binding gives you conversion and validation in one step.
Use the ShouldBind* methods. The Bind* variants abort with a bare 400 as soon as binding fails, which leaves you no room to write a useful error body.
type articleURI struct {
ID uint `uri:"id" binding:"required,min=1"`
}
type listQuery struct {
Page int `form:"page,default=1" binding:"min=1"`
Limit int `form:"limit,default=12" binding:"min=1,max=100"`
Category uint `form:"category"`
Sort string `form:"sort,default=newest" binding:"oneof=newest oldest title"`
}
type articleRequest struct {
Title string `json:"title" binding:"required,notblank,min=5,max=200"`
Excerpt string `json:"excerpt" binding:"required,notblank,max=500"`
Body string `json:"body" binding:"required,notblank"`
Image string `json:"image" binding:"omitempty,max=255"`
CategoryID uint `json:"categoryId" binding:"required,gt=0"`
}The binding tag uses go-playground/validator rules. The ones you will reach for most often are required, min, max, gt, oneof, email, url, omitempty (skip the other rules when the field is empty) and dive (validate each element of a slice). default= inside a form tag fills a missing query parameter before validation runs.
One detail catches people out: required on a number fails for 0, and on a bool it fails for false. If zero is a valid value, use a pointer (*int) so "missing" and "zero" are different.
Custom validation errors that clients can use
By default, a failed binding produces Key: 'articleRequest.Title' Error:Field validation for 'Title' failed on the 'min' tag, which leaks Go names and is useless to a frontend. First, report field names as the client sent them and register custom rules, once at startup:
package validation
import (
"reflect"
"strings"
"github.com/gin-gonic/gin/binding"
"github.com/go-playground/validator/v10"
)
func Setup() error {
v, ok := binding.Validator.Engine().(*validator.Validate)
if !ok {
return nil
}
// Use json/form/uri tag names in errors instead of Go field names.
v.RegisterTagNameFunc(func(f reflect.StructField) string {
for _, key := range []string{"json", "form", "uri"} {
name := strings.SplitN(f.Tag.Get(key), ",", 2)[0]
if name != "" && name != "-" {
return name
}
}
return f.Name
})
// "required" accepts " ". This rule does not.
return v.RegisterValidation("notblank", func(fl validator.FieldLevel) bool {
return strings.TrimSpace(fl.Field().String()) != ""
})
}Second, translate validator.ValidationErrors into a field-to-message map:
func Respond(c *gin.Context, err error) {
var ve validator.ValidationErrors
if !errors.As(err, &ve) {
// Malformed JSON, wrong types, unreadable body.
c.JSON(http.StatusBadRequest, gin.H{"error": "malformed request"})
return
}
fields := make(map[string]string, len(ve))
for _, fe := range ve {
fields[fe.Field()] = message(fe)
}
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "validation failed", "fields": fields})
}
func message(fe validator.FieldError) string {
unit := ""
if fe.Kind() == reflect.String {
unit = " characters"
}
switch fe.Tag() {
case "required", "notblank":
return "is required"
case "min":
return "must be at least " + fe.Param() + unit
case "max":
return "must be at most " + fe.Param() + unit
case "gt":
return "must be greater than " + fe.Param()
case "oneof":
return "must be one of: " + fe.Param()
}
return "is invalid"
}Every handler now calls validation.Respond(c, err) when ShouldBind* fails, and the client gets a shape it can map onto form fields:
{
"error": "validation failed",
"fields": {
"title": "must be at least 5 characters",
"categoryId": "is required"
}
}Returning 400 or 422 for validation failures is a convention; pick one and keep malformed JSON (400) separate.
Rules that need the database, such as "this category exists", still belong in the handler, by mapping gorm.ErrForeignKeyViolated and gorm.ErrDuplicatedKey as in part one.
File upload with FormFile
The client uploads an image, receives a URL, and sends it in the image field when creating the article:
const maxImageSize = 5 << 20 // 5 MiB
var allowedImageTypes = map[string]string{
"image/jpeg": ".jpg",
"image/png": ".png",
"image/webp": ".webp",
}
func (h *UploadHandler) Image(c *gin.Context) {
// Hard cap on the whole request body, including multipart overhead.
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxImageSize+(1<<20))
file, err := c.FormFile("image")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "image is required and must be under 5 MiB"})
return
}
if file.Size > maxImageSize {
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "image must be under 5 MiB"})
return
}
ext, err := sniffImage(file)
if err != nil {
c.JSON(http.StatusUnsupportedMediaType, gin.H{"error": "only JPEG, PNG and WebP are allowed"})
return
}
name := randomName() + ext
if err := c.SaveUploadedFile(file, filepath.Join(h.dir, name)); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not save image"})
return
}
c.JSON(http.StatusCreated, gin.H{"url": "/uploads/" + name})
}
// sniffImage checks the real content, not the client-supplied header or extension.
func sniffImage(fh *multipart.FileHeader) (string, error) {
f, err := fh.Open()
if err != nil {
return "", err
}
defer f.Close()
head := make([]byte, 512)
n, err := io.ReadFull(f, head)
if err != nil && !errors.Is(err, io.ErrUnexpectedEOF) {
return "", err
}
ext, ok := allowedImageTypes[http.DetectContentType(head[:n])]
if !ok {
return "", errors.New("unsupported file type")
}
return ext, nil
}
func randomName() string {
b := make([]byte, 16)
_, _ = rand.Read(b) // crypto/rand
return hex.EncodeToString(b)
}Why each step is there:
MaxBytesReaderis the real size limit.r.MaxMultipartMemoryonly sets how much of the form is kept in memory before the rest spills to temporary files. It does not reject large uploads.- Sniff the bytes. The client chooses the part's
Content-Typeand the extension.http.DetectContentTypereads the actual first bytes. - Never reuse
file.Filename. It can contain../, collide with other files, or carry a dangerous extension. - Only serve what you accepted. Refuse SVG and HTML unless you sanitize them, because both can carry scripts.
Register the route behind the auth middleware from part two, add p, editor, /api/v1/uploads/images, POST to the Casbin policy, and serve the directory:
if err := os.MkdirAll("uploads", 0o755); err != nil {
log.Fatal(err)
}
r.Static("/uploads", "./uploads") // no directory listing
protected.POST("/uploads/images", uploads.Image)Unix permissions for upload directories
| Mode | Owner | Group | Others | Use |
|---|---|---|---|---|
0755 | rwx | r-x | r-x | Upload directory |
0644 | rw- | r-- | r-- | Uploaded files |
0777 | rwx | rwx | rwx | Avoid: any local user can replace files |
Files saved by SaveUploadedFile get 0666 minus the process umask, which usually means 0644. Run the service as a non-root user that owns only this directory.
Local disk is fine for a single instance. With several replicas or short-lived containers, store uploads in object storage such as S3 behind a CDN; only the save step changes.
Pagination with limit, offset and total pages
Returning every row gets slower as the table grows. Offset pagination is the simplest fix:
?page=1&limit=12 -> OFFSET 0 LIMIT 12
?page=2&limit=12 -> OFFSET 12 LIMIT 12
offset = (page - 1) * limit
totalPages = ceil(total / limit)The client also wants the total, which needs a second COUNT(*) query. The two queries are independent, so they can run concurrently. The generic helper below does that with errgroup:
package pagination
import (
"context"
"golang.org/x/sync/errgroup"
"gorm.io/gorm"
)
type Params struct{ Page, Limit int }
type Meta struct {
Page int `json:"page"`
Limit int `json:"limit"`
Total int64 `json:"total"`
TotalPages int `json:"totalPages"`
PrevPage *int `json:"prevPage"`
NextPage *int `json:"nextPage"`
}
type Page[T any] struct {
Data []T `json:"data"`
Meta Meta `json:"meta"`
}
// Paginate runs the COUNT and the page query concurrently. base holds the
// filters; list adds page-only options such as Preload and Order.
func Paginate[T any](ctx context.Context, base *gorm.DB, p Params,
list func(*gorm.DB) *gorm.DB) (Page[T], error) {
q := base.Session(&gorm.Session{}) // safe to reuse from two goroutines
var (
total int64
items []T
)
g, ctx := errgroup.WithContext(ctx)
g.Go(func() error {
return q.WithContext(ctx).Model(new(T)).Count(&total).Error
})
g.Go(func() error {
offset := (p.Page - 1) * p.Limit
return list(q.WithContext(ctx)).Limit(p.Limit).Offset(offset).Find(&items).Error
})
if err := g.Wait(); err != nil {
return Page[T]{}, err
}
totalPages := int((total + int64(p.Limit) - 1) / int64(p.Limit))
meta := Meta{Page: p.Page, Limit: p.Limit, Total: total, TotalPages: totalPages}
if p.Page > 1 {
prev := p.Page - 1
meta.PrevPage = &prev
}
if p.Page < totalPages {
next := p.Page + 1
meta.NextPage = &next
}
if items == nil {
items = []T{} // encode as [] rather than null
}
return Page[T]{Data: items, Meta: meta}, nil
}The Session call is essential. A GORM query built with Where is not safe to reuse, because chained calls mutate the same statement. Without it, the goroutines would race and the count could inherit the page query's LIMIT. Note that Count takes an *int64 in GORM v2, and Preload stays in list, away from the count.
Running both queries at once uses two pool connections per request and only pays off when both are slow. For errgroup and cancellation in depth, see Go concurrency in production.
The handler whitelists the sort order, because Order() inserts its argument into SQL as is:
var sortOrders = map[string]string{
"newest": "created_at DESC, id DESC",
"oldest": "created_at ASC, id ASC",
"title": "title ASC, id ASC",
}
func (h *ArticleHandler) List(c *gin.Context) {
var q listQuery
if err := c.ShouldBindQuery(&q); err != nil {
validation.Respond(c, err)
return
}
base := h.db.Model(&models.Article{})
if q.Category != 0 {
base = base.Where("category_id = ?", q.Category)
}
page, err := pagination.Paginate[models.Article](c.Request.Context(), base,
pagination.Params{Page: q.Page, Limit: q.Limit},
func(tx *gorm.DB) *gorm.DB { return tx.Preload("Category").Order(sortOrders[q.Sort]) })
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not load articles"})
return
}
c.JSON(http.StatusOK, page)
}The id tie-breaker keeps the order stable, so rows with equal timestamps don't jump between pages. GET /api/v1/articles?page=2&limit=12 now returns data plus a meta object with page, limit, total, totalPages, prevPage and nextPage (null on the first and last page).
Offset pagination has limits: the database reads and discards every skipped row, so deep pages are slow, and new rows shift the pages. For feeds or very large tables, use keyset pagination, which SQL query optimization explains.
FAQ
What is the difference between ShouldBind and Bind in Gin?
ShouldBind* returns the error so you write the response. Bind* aborts with a bare 400. For JSON APIs, use ShouldBind*.
How do I validate query parameters in Gin?
Declare a struct with form tags and binding rules, then call c.ShouldBindQuery. Use form:"page,default=1" for defaults.
How do I limit upload size in Gin?
Wrap the body with http.MaxBytesReader before calling FormFile, and check file.Size. MaxMultipartMemory does not limit size.
Should I use offset or cursor pagination?
Offset suits numbered pages on moderate data. Cursor (keyset) pagination suits feeds and large tables.
Checklist
- All input is bound into structs with
ShouldBind*and validated withbindingtags. - Validation errors use client field names and a stable JSON shape.
- Uploads are capped with
MaxBytesReader, sniffed, renamed and stored with0755/0644. - Pagination limits
limit, whitelists sort columns and adds a tie-breaker. - Concurrent count and page queries use a fresh GORM session.
The articles API now covers what most projects need on day one. If you want help taking a Go API like this to production, Vectorkub builds and reviews backend systems.
