Binding and validating requests in Echo
You are building a signup endpoint. The client sends a JSON payload with a name and an email address. You need that data inside a Go struct so you can save it to the database. You also need to ensure the email looks like an email and the name is not just whitespace. If you skip validation, your database fills with garbage, or your code crashes when it tries to process an empty string where a user ID is expected. Echo provides tools to handle this cleanly without writing regex parsers by hand or scattering checks across your business logic.
Binding versus validation
Binding and validation are two distinct steps. Binding takes the raw HTTP request body and maps it to a Go struct. It reads the JSON, XML, or form data and populates the struct fields. Validation checks the populated struct against rules you define. It ensures the data is safe and correct before your application logic touches it.
Think of binding as unpacking a delivery box and placing items on the correct shelves. Validation is checking the expiration dates and verifying nothing is broken before you put the food in the pantry. Echo separates these concerns so you can swap validators or change data formats without rewriting your core logic. The framework gives you c.Bind for deserialization and c.Validate for constraint checking.
Minimal example
Here is the simplest setup: define a struct with validation tags, bind the request, then validate. You need a validator library to interpret the tags. The community standard is go-playground/validator.
// User defines the data shape and validation rules.
type User struct {
// required ensures the field is not empty; email checks format.
Name string `json:"name" validate:"required"`
Email string `json:"email" validate:"required,email"`
}
// CustomValidator bridges Echo's interface with go-playground/validator.
type CustomValidator struct {
validator *validator.Validate
}
// Validate checks the struct against its tags.
func (cv *CustomValidator) Validate(i interface{}) error {
// Struct validates the entire object, not just a single field.
return cv.validator.Struct(i)
}
func main() {
e := echo.New()
// Inject the validator so handlers can call c.Validate().
e.Validator = &CustomValidator{validator: validator.New()}
e.POST("/signup", func(c echo.Context) error {
var u User
// Bind reads the body and populates u.
if err := c.Bind(&u); err != nil {
return err
}
// Validate rejects the request if tags fail.
if err := c.Validate(&u); err != nil {
return err
}
return c.JSON(http.StatusOK, u)
})
e.Logger.Fatal(e.Start(":1323"))
}
The receiver name in CustomValidator is cv, a short abbreviation matching the type. This follows Go convention: receiver names are usually one or two letters. You do not need this or self. The struct tags use json for binding and validate for validation. These tags live side by side and serve different purposes.
Binding unpacks. Validation protects.
How it works at runtime
When a request hits the handler, c.Bind inspects the Content-Type header. If it sees application/json, it uses json.Unmarshal under the hood. It maps JSON keys to struct fields using the json tags. If the JSON is malformed or a type does not match, Bind returns an error immediately. Echo catches this error and responds with a 400 Bad Request before your handler logic runs.
Echo's c.Bind is smarter than raw json.Unmarshal. It supports multiple content types. If the client sends application/x-www-form-urlencoded, Bind parses the form data. It also merges data from the request body and query parameters. If the client sends a name in the query string and a different name in the body, Bind populates the struct with values from both sources. This can be surprising. If you only want body data, use c.JSON instead of c.Bind. c.JSON ignores query parameters and only parses the body as JSON. This distinction prevents accidental data leakage or confusion when clients mix parameters.
After binding succeeds, c.Validate takes over. It reflects over the struct, reads the validate tags, and runs the checks. The required tag ensures the field is not the zero value. For strings, this means the length must be greater than zero. The email tag runs a regex pattern to verify the format. If any check fails, Validate returns an error, and Echo again responds with 400. The error body usually contains details about which field failed.
The omitempty tag changes how validation works. If a field has omitempty, the validator skips all other rules on that field if the value is empty. This is useful for optional fields. If you mark a field as validate:"omitempty,email", an empty string passes validation. A non-empty string must be a valid email. Without omitempty, the field is always checked. If you combine required and omitempty, required wins because it checks for emptiness first. Always put required first to be safe.
The go-playground/validator library supports dozens of built-in tags. Common ones include min and max for length or numeric bounds, alpha and alphanum for character sets, and oneof to restrict values to a whitelist. You can chain tags with commas. Using oneof is safer than writing custom regex for enums. If you have a status field that must be active or inactive, use validate:"oneof=active inactive". This keeps the allowed values in one place and makes the code self-documenting.
Realistic example
Real applications often need nested structs and custom error responses. Clients expect structured error messages, not raw validator output. Here is a handler that handles a nested address and formats validation errors for the client.
// Address is a nested struct that also needs validation.
type Address struct {
// Street is required; City is optional but must be alphabetic if present.
Street string `json:"street" validate:"required"`
City string `json:"city" validate:"omitempty,alpha"`
}
// CreateUserRequest nests the address for a complete signup payload.
type CreateUserRequest struct {
// Name is required; Email must be valid.
Name string `json:"name" validate:"required"`
Email string `json:"email" validate:"required,email"`
// Address is required and its fields are validated recursively.
Addr Address `json:"address" validate:"required"`
}
func handleSignup(c echo.Context) error {
var req CreateUserRequest
if err := c.Bind(&req); err != nil {
// Bind fails on bad JSON or type mismatches.
return echo.NewHTTPError(http.StatusBadRequest, "malformed input")
}
if err := c.Validate(&req); err != nil {
// Type assert to access detailed validation errors.
if verrs, ok := err.(validator.ValidationErrors); ok {
// Build a user-friendly error map from the slice.
errors := make(map[string]string)
for _, e := range verrs {
errors[e.Field()] = e.Tag() + " failed"
}
return echo.NewHTTPError(http.StatusUnprocessableEntity, errors)
}
return echo.NewHTTPError(http.StatusInternalServerError, "validation error")
}
// Business logic proceeds here.
return c.JSON(http.StatusCreated, map[string]string{"status": "ok"})
}
The nested Address struct gets validated automatically when you validate the parent struct. The validator traverses the tree and checks all tagged fields. The handler type-asserts the error to validator.ValidationErrors to extract field names and failed tags. This lets you return a clean JSON object like {"email": "email failed", "address.street": "required failed"}.
Validation errors return 422 Unprocessable Entity by default in many setups, or 400 Bad Request. The HTTP spec suggests 422 for semantic validation errors, while 400 is for syntax errors. Echo's default error handler returns 400 for both bind and validate errors. You can customize this by implementing a custom Echo.HTTPErrorHandler. If you want 422 for validation, check the error type in your handler and return the appropriate code. This helps clients distinguish between malformed JSON and invalid data values.
After validation, your handler might call a database or external service. These calls should respect cancellation. Echo provides the request context via c.Request().Context(). Pass this context to any downstream function. If the client disconnects, the context cancels, and your goroutines stop working. Validation happens fast, but the work after validation can take time. Always thread the context through long-lived operations.
Format errors for the client, not the stack trace.
Pitfalls and errors
If you forget to set e.Validator, calling c.Validate triggers a nil pointer panic at runtime. The compiler will not warn you because c.Validate is a method call that checks the interface. You will see panic: runtime error: invalid memory address or nil pointer dereference in your logs. Always initialize the validator in your setup.
If you use c.Bind but the client sends XML while your struct has only json tags, the binding silently fails to populate fields, leaving you with zero values. The compiler will not catch this mismatch. You get undefined: pkg if you forget imports, but here the error is logical. The struct ends up empty, and validation might pass if you did not mark fields as required. Check the Content-Type header if you need strict format enforcement.
Pointer fields behave differently with validation. If a field is a pointer, required checks if the pointer is nil. If the pointer is not nil, the validator checks the value behind the pointer. If you use omitempty on a pointer, it skips validation if the pointer is nil. This can lead to subtle bugs if you expect the value to be validated when the pointer exists but points to a zero value. Use value types for simple fields unless you need to distinguish between "not provided" and "provided as empty".
The if err != nil { return err } pattern is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. In Echo handlers, returning an error automatically sends a response. You do not need to write c.JSON for errors unless you want a custom format. Trust the error return convention.
Set the validator or panic at runtime.
When to use what
Use c.Bind when you need to support multiple content types like JSON, XML, and form data in the same endpoint. Use c.JSON when you only accept JSON and want a simpler call that ignores other content types. Use c.Validate when your struct has validation tags and you need to check constraints before processing. Use a custom validator function when you need cross-field validation, like checking that a password matches a confirmation field, which struct tags cannot handle alone. Use middleware like echo-contrib/validator when you want to centralize validation logic and avoid repeating boilerplate in every handler. Use plain json.Unmarshal when you are building a low-level handler without a framework and need full control over the decoding process.
Pick the tool that matches your content type and validation scope.