An API's robustness is almost decided by how much invalid input you can kill at the boundary. Receiving "abc" as an ID and throwing it at the DB, registering with a required field empty, having IsAdmin: true passed in arbitrarily — for these, "checking later" is too late; the production practice is to create a state that is correct at the point it could be converted to a type.
This article is the input-processing / error-design edition of the Go Echo production-operation guide. It implements Echo v5's binding → validation → error formatting along the security principle of "don't trust external input."
The rules of this article: the API is based on the Echo official documentation (v5, as of June 2026). v5 is an area with large diffs from v4 — the binder's signature is inverted (
Bind(c, target)), generic type-safe extraction (PathParam[T]) is added,HTTPError.Messagebecomes astring, and the error-handler signature is inverted tofunc(c *echo.Context, err error). Always confirm the latest at the official before production.
1. c.Bind: bundle four sources via struct tags
c.Bind(&dto) pours, in one call, path parameters, query, and the request body (JSON/XML/form) according to the struct tags.
type SearchRequest struct {
Keyword string `query:"q"`
Tags []string `query:"tag"` // 繰り返し ?tag=go&tag=web → ["go","web"]
Page int `query:"page"`
Category string `param:"category"` // パスパラメータ /search/:category
}
func search(c *echo.Context) error {
var req SearchRequest
if err := c.Bind(&req); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid request")
}
// req は型変換済み(page は int、tags は []string)
return c.JSON(http.StatusOK, req)
}
| Tag | Source |
|---|---|
json | request body (JSON) |
xml | request body (XML) |
form | form data |
query | query parameters |
param | path parameters |
header | headers (out of scope of c.Bind. Described later) |
An important pitfall: headers aren't bundled by
c.Bind. When you want to takeAuthorizationor custom headers into a struct, callecho.BindHeaders(c, &dto)explicitly. "I bound but the header is empty" is the top cause of this.
1.1 Bind only a single source
When you want to strictly bind "only the body" or "only the query," use the per-source function. You can prevent inflow from an unintended source.
echo.BindBody(c, &payload) // ボディのみ
echo.BindQueryParams(c, &payload) // クエリのみ
echo.BindPathValues(c, &payload) // パスパラメータのみ
echo.BindHeaders(c, &payload) // ヘッダのみ
2. Prevent mass assignment: separate the DTO from the business struct
This is the crux of security. You must not Bind directly to a DB entity or domain model.
// ❌ 危険:DB エンティティに直接 Bind
type User struct {
ID int64 `json:"id"`
Email string `json:"email"`
IsAdmin bool `json:"is_admin"` // 攻撃者が {"is_admin": true} を送れば昇格できる
}
func badCreate(c *echo.Context) error {
var u User
_ = c.Bind(&u) // IsAdmin まで外部から上書きされる(mass assignment 脆弱性)
// ...
}
// ✅ 安全:受け口専用の DTO を定義し、許可したフィールドだけを受ける
type CreateUserDTO struct {
Name string `json:"name" validate:"required,min=2,max=50"`
Email string `json:"email" validate:"required,email"`
Password string `json:"password" validate:"required,min=12"`
// IsAdmin は DTO に存在しない=外部から絶対にセットできない
}
func (h *UserHandler) Create(c *echo.Context) error {
var dto CreateUserDTO
if err := c.Bind(&dto); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid request body")
}
if err := c.Validate(&dto); err != nil {
return err
}
// DTO → ドメインへ「明示的に」詰め替える。権限は常にサーバーが決める
user, err := h.uc.Register(c.Request().Context(), dto.Name, dto.Email, dto.Password)
if err != nil {
return err
}
return c.JSON(http.StatusCreated, toUserResponse(user)) // 出力も専用 DTO
}
The official also clearly states "don't bind directly to a business struct; use a dedicated DTO and map explicitly to prevent unintended field exposure." Separating the input DTO and the output DTO, and never putting a sensitive field like Password in the output, is the same principle.
3. The generic type-safe binder: take a single value typed
A single value like an ID or page number is something you want to take type-safely without even carving a struct. v5 provides type-safe extraction using generics. Since it errors if it can't convert, you can structurally prevent the accident of receiving "abc" as an int64 ID.
// パスパラメータ /users/:id を int64 として取得
id, err := echo.PathParam[int64](c, "id")
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "id must be an integer")
}
// クエリ ?active=true を bool として取得
active, err := echo.QueryParam[bool](c, "active")
3.1 The Fluent binder: convert multiple values together and aggregate errors
When you batch-convert the type of multiple queries/parameters and pick up the errors together, the Fluent (flowing) binder is convenient.
var opts struct {
IDs []int64
Active bool
Limit int64
}
err := echo.QueryParamsBinder(c).
Int64s("id", &opts.IDs). // ?id=1&id=2 → []int64{1,2}
Bool("active", &opts.Active).
Int64("limit", &opts.Limit).
BindError() // 最初のエラー(または集約)を返す
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid query parameters")
}
PathValuesBinder(c) / FormFieldBinder(c) are the same lineage. Each method has derivatives like Int64 (optional) / MustInt64 (required, errors if absent) / Int64s (multiple), so you can express required / optional / multiple-value by type.
4. Validation: overlay go-playground/validator
Echo doesn't bundle a validation library. Instead, it provides an insertion point, the echo.Validator interface. Overlaying the de facto github.com/go-playground/validator/v10 is the standard.
import "github.com/go-playground/validator/v10"
type CustomValidator struct {
validator *validator.Validate
}
func (cv *CustomValidator) Validate(i any) error {
if err := cv.validator.Struct(i); err != nil {
return err // ここでは生エラーを返し、整形は集中エラーハンドラに任せる
}
return nil
}
func main() {
e := echo.New()
e.Validator = &CustomValidator{validator: validator.New()}
// ...
}
Declare rules with the validate:"..." tag. Let me list commonly-used ones.
| Tag | Meaning |
|---|---|
required | required (rejects the zero value) |
email | email format |
min=2,max=50 | string length / numeric range |
gte=0,lte=100 | greater-or-equal / less-or-equal |
oneof=draft published | one of the enumeration |
uuid4 | UUID v4 format |
dive | applies to each element of a slice/map |
4.1 Format the validation error into a "per-field 422"
Returning a raw error like {"message":"Key: 'CreateUserDTO.Email' Error:Field validation..."} as-is is unkind as an API and leaks information too. In production, return which field failed and why structured with 422 Unprocessable Entity.
type FieldError struct {
Field string `json:"field"`
Rule string `json:"rule"`
Message string `json:"message"`
}
// validator のエラーをフィールド単位に変換する
func toFieldErrors(err error) []FieldError {
var ve validator.ValidationErrors
if !errors.As(err, &ve) {
return nil
}
out := make([]FieldError, 0, len(ve))
for _, fe := range ve {
out = append(out, FieldError{
Field: fe.Field(),
Rule: fe.Tag(),
Message: humanize(fe), // "email は正しい形式で入力してください" 等
})
}
return out
}
Do this conversion only once, in the next chapter's centralized error handler. Not scattering the formatting logic across each handler is the point of DRY.
5. Error handling: return without swallowing, and format in one place
Echo's error handling is centralized. A handler/middleware just returns an error, and the conversion is handled by one HTTPErrorHandler.
// ステータス付きエラー(v5 はメッセージが string・非可変長)
return echo.NewHTTPError(http.StatusNotFound, "user not found")
// メッセージ省略時はステータステキスト
return echo.NewHTTPError(http.StatusBadGateway)
// 定義済みセンチネルエラー
return echo.ErrUnauthorized // 401
return echo.ErrForbidden // 403
5.1 A production-oriented centralized error handler
There are four requirements. (1) format validation errors into a 422 per field, (2) for a *echo.HTTPError, its status, (3) for anything else (unexpected), a 500 that hides the details, (4) log 5xx with a correlation ID. v5's signature is func(c *echo.Context, err error) (the argument order is inverted from v4).
type ErrorResponse struct {
Error string `json:"error"`
Fields []FieldError `json:"fields,omitempty"`
}
func customHTTPErrorHandler(c *echo.Context, err error) {
// すでに送信済みなら二重送信しない
if resp, uErr := echo.UnwrapResponse(c.Response()); uErr == nil && resp.Committed {
return
}
// ① バリデーションエラー → 422(フィールド単位)
var ve validator.ValidationErrors
if errors.As(err, &ve) {
_ = c.JSON(http.StatusUnprocessableEntity, ErrorResponse{
Error: "validation failed",
Fields: toFieldErrors(ve),
})
return
}
// ② ステータス解決(HTTPError は HTTPStatusCoder を実装)
code := http.StatusInternalServerError
msg := "internal server error"
var sc echo.HTTPStatusCoder
if errors.As(err, &sc) {
if s := sc.StatusCode(); s != 0 {
code = s
}
}
var he *echo.HTTPError
if errors.As(err, &he) {
msg = he.Message // v5 は string
}
// ③ 5xx は詳細を隠し、相関 ID 付きでログへ
if code >= 500 {
c.Logger().Error("unhandled error",
"error", err.Error(),
"path", c.Request().URL.Path,
"request_id", c.Response().Header().Get(echo.HeaderXRequestID),
)
msg = "internal server error" // クライアントには内部詳細を出さない
}
_ = c.JSON(code, ErrorResponse{Error: msg})
}
func main() {
e := echo.New()
e.HTTPErrorHandler = customHTTPErrorHandler
// ...
}
With this, each handler becomes just returning the domain's error, and the HTTP appearance, logging, and the blocking of sensitive information are consolidated in one place. The official also recommends "if you forward errors to Sentry / Elasticsearch / Splunk, etc., do it from this centralized handler."
5.2 Map domain errors to HTTP
The UseCase layer shouldn't know HTTP. Define domain-specific sentinel errors and map them to HTTP statuses in the centralized handler (or a thin conversion layer).
// domain 層:HTTP を知らない
var (
ErrUserNotFound = errors.New("user not found")
ErrEmailTaken = errors.New("email already taken")
)
// エラーハンドラ側でマッピング(抜粋)
switch {
case errors.Is(err, ErrUserNotFound):
code, msg = http.StatusNotFound, "user not found"
case errors.Is(err, ErrEmailTaken):
code, msg = http.StatusConflict, "email already taken"
}
With this separation, the business logic can be kept testable independently of HTTP (clean architecture + DI). When you want to align even the error format by type between client and server, a design that schematizes it with OpenAPI + Problem Details (RFC 9457) is effective (Next.js × Go × OpenAPI).
6. A custom Binder: receive a proprietary format
For requirements like Protocol Buffers, a proprietary Content-Type, or "always strictly interpret JSON numbers," make and swap in your own echo.Binder.
type StrictBinder struct {
fallback echo.DefaultBinder
}
func (b *StrictBinder) Bind(c *echo.Context, i any) error { // v5 は (c, target) の順
if c.Request().Header.Get(echo.HeaderContentType) == "application/vnd.myapp+json" {
// 独自フォーマットの処理
return decodeStrict(c.Request().Body, i)
}
return b.fallback.Bind(c, i) // それ以外は標準バインダに委譲
}
func main() {
e := echo.New()
e.Binder = &StrictBinder{}
}
Note that Bind's signature is Bind(c *echo.Context, i any) (the argument order is inverted in v5). Since the case of wanting to leverage the standard behavior is the majority, the form of delegating to DefaultBinder is safe.
Summary: the seven principles of boundary design
c.Bindbundles path, query, and body. Headers are out of scope — makeBindHeadersexplicit.- Don't Bind directly to a business struct. Prevent mass assignment with a dedicated receiving DTO.
- Separate the input DTO and the output DTO. Don't put sensitive fields in the output.
- Take single values type-safely — drop conversion failures to a 400 with
PathParam[T]/ the Fluent binder. - For validation, overlay
go-playground/validatoronecho.Validatorand format it into a 422 per field. - Don't swallow errors;
returnthem. Consolidate formatting, logging, and sensitive-info blocking in one place with the centralized handler. - Make domain errors independent of HTTP and map them to statuses in the conversion layer.
What separates a "working API" from an "API trustworthy in production" is exactly this boundary processing. Kill the input by type, and format the errors in one place — if you have this form, Echo's API becomes markedly more robust. Together with the preceding middleware design, return to the Go Echo production-operation guide to view the whole picture.