This episode dissects binding and validation in Echo: how c.Bind turns JSON, XML, or form data into a struct, struct tags for query and URI binding, custom binders, go-playground validator integration, and centralized 400 error handling.

Every REST API receives input from clients — and input is the most error-prone point. Echo solves these two problems at once: binding to turn payloads into Go structs, and validation to ensure incoming data is valid before it touches your business logic.
Episode 5 dissects both: how c.Bind handles JSON, XML, and form data; struct tags for query and URI binding; custom binders for special formats; the pluggable go-playground/validator; and the pattern for centralized 400 error handling.
c.Bind reads the request body and fills it into the struct you provide. The payload type is detected automatically from the Content-Type header:
type CreateUserRequest struct {
Name string `json:"name"`
Email string `json:"email"`
}
e.POST("/users", func(c echo.Context) error {
req := new(CreateUserRequest)
if err := c.Bind(req); err != nil {
return err
}
return c.JSON(http.StatusCreated, req)
})When the client sends JSON {"name":"Arman","email":"arman@dev.id"}, c.Bind fills the struct and returns an error if the payload doesn't match. Notice the c.Bind(req) pattern — the struct must be passed as a pointer.
Echo's default binder handles several formats at once:
Content-Type: application/json.Content-Type: application/xml.Content-Type: application/x-www-form-urlencoded.multipart/form-data.curl -X POST http://localhost:8080/users \
-H "Content-Type: application/json" \
-d '{"name":"Arman","email":"arman@dev.id"}'Binding isn't only from the body. Echo supports the query and param tags so path parameters and query strings bind directly into a struct as well:
type ListFilter struct {
Page int `query:"page"`
Search string `query:"q"`
UserID string `param:"id"`
}
e.GET("/users/:id", func(c echo.Context) error {
f := new(ListFilter)
if err := c.Bind(f); err != nil {
return err
}
return c.JSON(http.StatusOK, f)
})With c.Bind(f), the Page field is filled from ?page=, Search from ?q=, and UserID from the :id path parameter. A single bind call handles all input sources.
Note that c.Bind only parses data — it doesn't validate meaning. A payload like {"name":"","email":"bukan-email"} binds successfully, and that's where the validator steps in.
Echo doesn't ship a built-in validator; it provides an interface any library can fill. The most common one is go-playground/validator. Wire it up through the e.Validator property:
import (
"github.com/go-playground/validator/v10"
)
type CustomValidator struct {
validator *validator.Validate
}
func (cv *CustomValidator) Validate(i interface{}) error {
return cv.validator.Struct(i)
}
e.Validator = &CustomValidator{validator: validator.New()}The CustomValidator struct implements the echo.Validator interface with the Validate method. After this, validation tags on your structs are processed automatically.
go-playground/validator provides hundreds of rules through struct tags. Add tags like required, email, and gte:
type CreateUserRequest struct {
Name string `json:"name" validate:"required,min=3"`
Email string `json:"email" validate:"required,email"`
Age int `json:"age" validate:"gte=0,lte=130"`
}The rule required,min=3 means the field is mandatory and at least three characters long, email validates the email format, and gte/lte bounds the numeric range. The validator runs automatically when the handler calls c.Validate.
The correct pattern is to bind first, then validate:
e.POST("/users", func(c echo.Context) error {
req := new(CreateUserRequest)
if err := c.Bind(req); err != nil {
return err
}
if err := c.Validate(req); err != nil {
return err
}
return c.JSON(http.StatusCreated, req)
})c.Validate(req) invokes the registered validator and returns a validation error if any rule fails.
For special needs, register your own validation function:
cv.validator.RegisterValidation("strong", func(fl validator.FieldLevel) bool {
value := fl.Field().String()
return len(value) >= 8 && value != strings.ToLower(value)
})This function adds a new rule named strong that you can use in any struct tag: validate:"required,strong".
Echo splits errors into two groups: HTTPError, which already has a status code, and plain errors. Errors from the binder and validator are plain errors, so they default to 500. To keep your API consistent, recognize both types and turn them into 400:
if err := c.Bind(req); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "format payload tidak valid")
}
if err := c.Validate(req); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}With this pattern, every input error produces a consistent 400 response.
Episode 5 makes you master Echo's input pipeline: c.Bind turns JSON, XML, or form data into a struct; the query and param tags capture input from the URL; go-playground/validator validates the meaning of the data; custom rules handle special needs; and 400 errors are handled centrally.
Key takeaways:
c.Bind parses the body according to Content-Type into a struct pointer.query and param tags bind URL values into a struct.c.Bind only parses; c.Validate checks the meaning.validate tags like required and email run automatically.HTTPErrors.In episode 6 next, we'll discuss middleware — how to write middleware with c.Next(), registering it at the root, group, and route levels, pre-middleware, and built-in middleware like CORS, JWT, BodyLimit, Gzip, and RateLimit.