# Echo request binding, validation, and error design: kill invalid input at a type-safe boundary

> An implementation guide to bring Go Echo's (v5) input processing to production quality. Faithful to the official documentation, it explains in real code c.Bind and struct tags, single-source binders, generic type-safe binders, go-playground/validator integration, DTO separation, a custom Binder, HTTPError and the centralized error handler, and Problem-Details-style 422 formatting.

- Published: 2026-06-28
- Author: 友田 陽大
- Tags: Go, Echo, 型安全, セキュリティ, バリデーション, アーキテクチャ設計
- URL: https://tomodahinata.com/en/blog/go-echo-request-binding-validation-error-handling-guide
- Category: Go & Echo in production
- Pillar guide: https://tomodahinata.com/en/blog/go-echo-framework-production-guide

## Key points

- c.Bind pours path, query, and body into a struct via struct tags. Headers are out of scope; call BindHeaders explicitly.
- Don't Bind directly to a business struct. Define a dedicated receiving DTO and prevent unintended field overwrites (mass assignment) like IsAdmin.
- You want to take numbers, bools, and slices type-safely. With v5's generic PathParam[T]/QueryParam[T] and the Fluent binder, drop conversion failures to a 400.
- For validation, overlay go-playground/validator on echo.Validator, format it into a per-field 422, and return 'which is wrong and why.'
- Don't swallow errors; return them. With the centralized HTTPErrorHandler, hide internal details, structure 4xx, and log 5xx with a correlation ID.

---

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](/blog/go-echo-framework-production-guide). It implements Echo v5's **binding → validation → error formatting** along the [security principle](/blog/nextjs-supabase-application-security-guide) 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.Message` becomes a `string`, and the error-handler signature is inverted to `func(c *echo.Context, err error)`. Always confirm the latest at the [official](https://echo.labstack.com/guide/binding/) 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.

```go
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 take `Authorization` or custom headers into a struct, call `echo.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.

```go
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.

```go
// ❌ 危険：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 脆弱性）
	// ...
}
```

```go
// ✅ 安全：受け口専用の 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.**

```go
// パスパラメータ /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.

```go
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.

```go
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`.

```go
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 `return`s an `error`, and the conversion is handled by **one `HTTPErrorHandler`.**

```go
// ステータス付きエラー（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).

```go
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 `return`ing 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).**

```go
// 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](/blog/go-echo-framework-production-guide#9-アーキテクチャハンドラを薄く保ち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](/blog/nextjs-go-openapi-end-to-end-type-safety)).

---

## 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`.

```go
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

1. **`c.Bind` bundles path, query, and body.** **Headers are out of scope** — make `BindHeaders` explicit.
2. **Don't Bind directly to a business struct.** Prevent mass assignment with a dedicated receiving DTO.
3. **Separate the input DTO and the output DTO.** Don't put sensitive fields in the output.
4. **Take single values type-safely** — drop conversion failures to a 400 with `PathParam[T]` / the Fluent binder.
5. **For validation, overlay `go-playground/validator` on `echo.Validator`** and format it into a 422 per field.
6. **Don't swallow errors; `return` them.** Consolidate formatting, logging, and sensitive-info blocking in one place with the centralized handler.
7. **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](/blog/go-echo-middleware-cors-csrf-jwt-rate-limit-security-guide), return to the [Go Echo production-operation guide](/blog/go-echo-framework-production-guide) to view the whole picture.
