Core

Request Binding

Fill your request struct from the path, query string, headers, form fields, and JSON body.

Binding is driven by struct tags. Declare where each field comes from and ezz fills it in before your handler runs.

type SearchRequest struct {
    Team    string   `path:"team"`
    Query   string   `query:"q"`
    Tags    []string `query:"tags"`
    Page    int      `query:"page" default:"1"`
    Limit   int      `query:"limit" default:"25"`
    TraceID string   `header:"X-Trace-ID"`
    Filters Filters  `json:"filters"`
}

Choose a source

TagReads fromExample
pathA {name} segment of the route pattern`path:"id"`
queryThe query string`query:"page"`
headerA request header`header:"X-Api-Version"`
formForm values, via ParseForm`form:"email"`
jsonThe JSON request body`json:"name"`
defaultA fallback for missing query parameters`query:"page" default:"1"`

A field can carry more than one tag. Fields without any of these tags are left at their zero value unless they appear in the JSON body.

Understand the binding order

Binding happens in two passes:

  1. If the request has a Content-Type containing application/json, the body is decoded into the whole struct.
  2. Then path, query, header, and form values are applied field by field.

Because the second pass runs last, a request value overwrites whatever the body set for the same field. That is useful for canonicalising an ID:

type UpdateUserRequest struct {
    ID    string `path:"id"`               // always wins
    Name  string `json:"name"`
    Email string `json:"email"`
}

A body field is only overwritten when the corresponding request value is present and non-empty, so ?name= does not blank out a name that came from the body.

Decode JSON bodies

The body is decoded when the Content-Type header contains application/json and the request either declares a length or uses chunked encoding. An empty body is not an error, so POST with no body binds nothing and validation decides whether that is acceptable.

type CreateUserRequest struct {
    Name    string   `json:"name" validate:"required"`
    Email   string   `json:"email" validate:"required,email"`
    Tags    []string `json:"tags"`
    Profile *Profile `json:"profile"`
}

Nested structs, slices, maps, and pointers all work, because this pass is encoding/json.

A malformed body returns 400 with a message describing the decode failure:

{
  "error": true,
  "message": "failed to decode JSON body: invalid character 'x' looking for beginning of value",
  "status": 400
}

Supply defaults

default applies to query fields whose parameter is absent or empty. It is not consulted for path, header, form, or body values.

type ListRequest struct {
    Page  int    `query:"page" default:"1"`
    Limit int    `query:"limit" default:"25" validate:"min=1,max=100"`
    Sort  string `query:"sort" default:"created_at"`
}

Defaults are also written into the OpenAPI schema, so the docs show them.

Bind lists

A slice field accepts either a comma-separated value or a repeated parameter:

type FilterRequest struct {
    Tags []string `query:"tags"`
    IDs  []int    `query:"id"`
}
Terminal
curl 'localhost:8080/items?tags=go,http&id=1&id=2'

Both forms end up as ["go","http"] and [1,2]. Values are trimmed of surrounding whitespace.

Supported field types

Strings, all signed and unsigned integer widths, floats, booleans, slices of those, and pointers to those. A pointer field is allocated on demand, which lets you tell "absent" from "zero":

type PatchRequest struct {
    Active *bool `query:"active"`   // nil when the parameter is missing
}

Booleans accept the strconv.ParseBool set: 1, t, T, true, TRUE, True, and their false counterparts. A value that fails to convert returns 400 naming the parameter:

{
  "error": true,
  "message": "failed to set query parameter limit: strconv.ParseInt: parsing \"abc\": invalid syntax",
  "status": 400
}

Unexported fields are skipped. Anything else, such as a time.Time from a query parameter, needs to be taken as a string and parsed in the handler.

Read form submissions

type ContactRequest struct {
    Name    string `form:"name" validate:"required"`
    Email   string `form:"email" validate:"required,email"`
    Message string `form:"message" validate:"required,min=10"`
}

app.POST("/contact", ezz.H(func(ctx *ezz.Context, req ContactRequest) (*Ack, error) {
    return &Ack{Received: true}, nil
})).Public()

ParseForm is called once per request, and only if the struct has at least one form field. It covers application/x-www-form-urlencoded bodies and query values. For file uploads, reach for ctx.Request.FormFile directly.

Bind by hand

The same machinery is exported for cases outside a typed handler, such as a plain http.Handler or a background worker replaying stored requests.

import "github.com/sulv-io/ezz/binding"

var req CreateUserRequest
if err := binding.Bind(r, &req); err != nil {
    // 400
}
if err := binding.Validate(req); err != nil {
    // 400
}
FunctionPurpose
binding.Bind(r, target)Full binding: body, then path, query, header, form
binding.BindJSON(r, target)Body only
binding.BindQuery(values, target)Query values only, falling back to lowercased field names
binding.BindPath(r, target)Path parameters only
binding.NewBinder(type)A reusable binder for one type
binding.NewValidator(type)A reusable validator for one type

Reflection metadata for each struct is cached the first time it is seen, so repeated binding of the same type does not re-walk the fields.

Next steps

  • Validation for the rules that run after binding.
  • OpenAPI for how these tags turn into parameters and schemas.
Copyright © 2026