結論

  • gin が実行時に読む検証タグは binding だけ。validate タグはコンパイルも lint も通り、検証は一度も走らない
  • 検証は ShouldBind* の中でだけ、デコード直後の値に掛かる。c.JSON はレスポンスを検証せず、ハンドラで trim した後の値も検証しない
  • required は「ゼロ値でない」の検査で、型によって意味が変わる。値型は "" / 0 / false を弾き、ポインタとスライスは nil だけを弾く (&"" と [] は通る)
  • タグは左から評価され、最初に落ちたタグ 1 つだけが返る。required,min=1 の int に 0 を送ると、返るのは min でなく required
  • required は値の形式を見ない。URL なら net/url で分解して、scheme と host を許可リストと完全一致で比べる

以下は Go 1.26.4 / gin v1.12.0 / go-playground/validator v10.30.1 で実測した。

前提: gin は go-playground/validator をタグ名 binding で呼ぶ

検証が走るのは ShouldBind の中だけで、デコード直後の値に binding タグを左から当てる

gin の ShouldBindJSON / ShouldBindQuery は、デコードした struct を go-playground/validator に渡す。 validator 本体の既定タグ名は validate だが、gin は初期化時にタグ名を binding へ差し替えている。

// gin v1.12.0 binding/default_validator.go
func (v *defaultValidator) lazyinit() {
	v.once.Do(func() {
		v.validate = validator.New()
		v.validate.SetTagName("binding")
	})
}

以下の例は、次の形のハンドラで検証した。bind に失敗したら 400 とエラー文字列を返す。

func create[T any](c *gin.Context) {
	var req T
	if err := c.ShouldBindJSON(&req); err != nil {
		c.String(http.StatusBadRequest, err.Error())
		return
	}
	c.JSON(http.StatusOK, req)
}

validate タグは gin では評価されない

同じ struct の 2 つのタグのうち、gin が読むのは binding だけ

validate:"..." と書いたフィールドは、bind しても検証されない。範囲外の値がそのまま通る。

type GeoQuery struct {
	Lat *float64 `form:"lat" validate:"omitempty,min=-90,max=90"`
	Lon *float64 `form:"lon" binding:"omitempty,min=-180,max=180"`
}

r.GET("/geo", func(c *gin.Context) {
	var q GeoQuery
	if err := c.ShouldBindQuery(&q); err != nil {
		c.String(http.StatusBadRequest, err.Error())
		return
	}
	c.Status(http.StatusOK)
})
GET /geo?lat=999 => 200
GET /geo?lon=999 => 400 Key: 'GeoQuery.Lon' Error:Field validation for 'Lon' failed on the 'max' tag
GET /geo?lon=180 => 200

min / max は境界の値を含む (lon=180 は通る)。

validator を単体で使った経験があると、自然に validate: と書く。 別のプロジェクトから struct をコピーしたときにも混ざる。 同じリポジトリに binding: と validate: が混在していたら、validate: の方は全部効いていないと疑う。

validate タグが生きているかは、validator を直接呼ぶコードの有無で決まる。

# gin がタグ名を差し替えている箇所
grep -rn 'SetTagName' $(go env GOMODCACHE)/github.com/gin-gonic/gin@*/binding/
# validator を直接呼んでいる箇所。0 件なら validate タグは実行時に読まれない
grep -rnE 'validator\.New\(|validate\.Struct\(' --include='*.go' .

一方、OpenAPI 生成器の swag は binding:"required" と validate:"required" の両方を読んで、スキーマの required を出す。 タグは文字列のメタデータでしかなく、どの名前を読むかは読む側が決める。

  • PITFALL: コンパイルも go vet も golangci-lint も通る。正常系しかテストしていないと、検証済みに見えるまま残る

レスポンスは検証されない

c.JSON は json.Marshal して書き出すだけで、binding タグを見ない

binding タグが効くのは bind の経路だけ。c.JSON は json.Marshal して書き出すだけで、validator を通らない。 レスポンス型に binding:"required" を付けても、実行時の挙動は変わらない。

type UserResponse struct {
	Name string `json:"name,omitempty" binding:"required"`
}

r.GET("/user", func(c *gin.Context) {
	c.JSON(http.StatusOK, UserResponse{})
})
// GET /user => 200 {}

エラーもログも出ず、required のはずのキーが消えた 200 が返る。 omitempty を外せば {"name":""} になる。

レスポンス型の required が変えるのは、swag が生成する OpenAPI の required 配列だけ。 「このキーは必ず返る」という契約の宣言であって、実行時のゲートではない。

  • PITFALL: required と omitempty を同じ項目に付けると、値が空のときだけキーが消える。サーバは 200、テストも緑で、壊れるのは OpenAPI から型を生成したクライアントだけ

required は型によって意味が変わる

値型はゼロ値を弾き、ポインタとスライスは nil だけを弾く

required は「その型のゼロ値でないこと」の検査。validator のドキュメントは次のとおり書いている。

For numbers ensures value is not zero. For strings ensures value is not “”. For booleans ensures value is not false. For slices, maps, pointers, interfaces, channels and functions ensures the value is not nil.

ポインタは nil かどうかだけを見て、指す先の値は見ない。JSON の [] は nil でない空スライスになるので、required を通る。 {"v": ...} を ShouldBindJSON した結果は次のとおり。

型とタグ{}{"v":null}ゼロ値を送る
string + required400400"" => 400
*string + required400400"" => 200
*string + required,min=1400400"" => 400 (min)
int + required4004000 => 400
*int + required4004000 => 200
bool + required400400false => 400
*bool + required400400false => 200
[]int + required400400[] => 200
[]int + min=1400 (min)400 (min)[] => 400 (min)

値型ではキー欠落・null・ゼロ値がどれも同じゼロ値になる ( Go の JSON が警告なしに潰す null と欠落 )。 required はその区別の消えた後の値を見るので、3 つとも同じエラーになる。

要件ごとの型とタグは次のとおり。

要件型とタグ
空でない値が必ず要るstring + required
未指定 (変更しない) と空文字 (空にする) を分ける*string
キーが必須で、空文字も弾く*string + required,min=1
0 や false を正当な値として受ける*int / *bool + required
空配列を弾く[]T + min=1
  • PITFALL: 「required はポインタでしか使えない」は誤り。値型 + required は普通に効く。逆に *T と T を整理のつもりで入れ替えると、&"" / 0 / false / [] の送信が 200 と 400 の間で反転する

タグは左から評価され、最初に落ちたタグだけが返る

required,min=1,max=99 の int に 0 を送ると、required で止まって min まで届かない

validator は 1 つのフィールドのタグを左から順に評価し、最初に落ちたタグでそのフィールドの検証を終える。 非ポインタの int では、キー欠落も 0 も required で落ちる。範囲違反のつもりで送った 0 が、必須違反として返る。

type Order struct {
	Qty int `json:"qty" binding:"required,min=1,max=99"`
}
入力required,min=1,max=99min=1,max=99
{}requiredmin
{"qty":0}requiredmin
{"qty":100}maxmax
POST /orders {"qty":0} => 400 Key: 'Order.Qty' Error:Field validation for 'Qty' failed on the 'required' tag

非ポインタの int では、required が弾く値 (0) は min=1 も弾く。2 つを並べても弾く範囲は変わらないので、片方に寄せる。 API 契約で「範囲外は OUT_OF_RANGE」とエラーコードを決めているなら、required を落として範囲の判定に任せる。キー欠落も 0 になるので、両方とも min で返る。

  • PITFALL: required を落とすと、swag が生成する OpenAPI の required 配列からもその項目が外れる。リクエストとレスポンスでスキーマを共有していると、レスポンス側の required も一緒に落ちる

検証はハンドラの正規化より前に走る

bind の検証は trim 前の値を見るので、空白だけの名前は通り、前後に空白のある enum は落ちる

タグの検証はデコード処理の一部で、ハンドラの本体より手前にある。 bind の後で strings.TrimSpace を掛ける設計では、タグは trim 前の値を検証する。

type Reception struct {
	Type string `json:"type" binding:"required,oneof=general special"`
	Name string `json:"name" binding:"required"`
}

func createReception(c *gin.Context) {
	var req Reception
	if err := c.ShouldBindJSON(&req); err != nil {
		c.String(http.StatusBadRequest, err.Error())
		return
	}
	req.Type = strings.TrimSpace(req.Type)
	req.Name = strings.TrimSpace(req.Name)
	// ここで req.Name == "" になりうる
	c.JSON(http.StatusOK, req)
}
入力結果
{"type":" general ","name":"a"}400 (oneof)。trim すれば正しい値なのに落ちる
{"type":"general","name":" "}200 {"type":"general","name":""}。required を通った後で空文字になる

trim 後の値を検証し直すなら、gin が使っているのと同じ validator を binding.Validator から呼ぶ。

	req.Type = strings.TrimSpace(req.Type)
	req.Name = strings.TrimSpace(req.Name)
	if err := binding.Validator.ValidateStruct(&req); err != nil {
		c.String(http.StatusBadRequest, err.Error())
		return
	}
POST /receptions {"type":"general","name":"   "} => 400 Key: 'Reception.Name' Error:Field validation for 'Name' failed on the 'required' tag

これで空白だけの値は弾けるが、" general " は bind の時点で落ちたまま。 前後の空白を許して受けたいなら、oneof をタグから外し、trim の後に呼ぶ共有のバリデータへ寄せる。

同じ理由で、ハンドラの中のガードは bind を通った値にしか届かない。 HTTP 経由のテストで required に反する入力を投げても 400 で止まり、ガードの分岐は一度も通らない。ガードを試すなら、ハンドラから切り出した関数を直接呼ぶ。

  • PITFALL: 作成系はタグで検証し、更新系はハンドラで trim してから検証する、という非対称な構成だと、" general " は POST で 400、PUT で 200 になる。差が出るのは空白付きの入力だけなので、通常のテストデータでは気付けない
  • PITFALL: 空白だけでなく . / .. / __x__ のような「空ではないが下流で不正」な値も required を通る。行き先がドキュメント ID やパスだと、404 のはずが 500 になって初めて気付く

required は形式を検証しない

required を通った URL を url.Parse で分解すると、userinfo の後ろが本当の host になる

required が見るのはゼロ値かどうかだけ。URL のつもりの項目に何を入れても、空でなければ通る。

{"url":"not a url"}                    => 200
{"url":"https://allowed.com@evil.com"} => 200
{"url":"javascript:alert(1)"}          => 200

リダイレクト先や外部 API に渡す URL は、ハンドラで net/url に分解し、scheme と host を許可リストと完全一致で比べる。 url.Parse は scheme を小文字にそろえ、host の大文字小文字は残すので、比較は strings.EqualFold で行う。 許可リストが空なら何も通さない。

func allowedOrigin(raw string, allow []string) bool {
	u, err := url.Parse(raw)
	if err != nil {
		return false
	}
	origin := u.Scheme + "://" + u.Host
	for _, a := range allow {
		if strings.EqualFold(origin, a) {
			return true
		}
	}
	return false
}

allow = []string{"https://allowed.com"} での結果は次のとおり。前方一致 (strings.HasPrefix) と比べる。

入力url.Parse の hostallowedOrigin前方一致
https://allowed.com/callbackallowed.com通す通す
HTTPS://Allowed.com/pathAllowed.com通す弾く
https://allowed.com@evil.comevil.com弾く通す
https://allowed.com.evil.comallowed.com.evil.com弾く通す
//allowed.comallowed.com (scheme が空)弾く弾く
https:\\evil.com空弾く弾く
https://allowed.com evil.comパースエラー弾く通す
https://allowed.com:443allowed.com:443弾く通す

@ より前は userinfo として分離されるので、https://allowed.com@evil.com の接続先は evil.com になる。

  • PITFALL: u.Host はポートを含む。allowed.com:443 はポート無しの許可値と一致しない。安全側に倒れるが、ポート付きを通すかは仕様として決めておく

まとめ

場面挙動対策
validate:"..." タグgin は読まない。範囲外も通る入力の検証は binding: で書く。validate: は直接呼ぶコードがあるかを grep で確かめる
レスポンス型の binding:"required"c.JSON は検証しないrequired の項目に omitempty を付けない
値型 + required"" / 0 / false を弾く。欠落と区別できない0 や false を受けるならポインタにする
ポインタ + requirednil だけ弾く。&"" は通る空文字も弾くなら min=1 を足す
スライス + requirednil だけ弾く。[] は通る空配列を弾くなら min=1
required,min=1 の int に 0required で返るエラーコードを分けたいなら required を外す
bind 後の trim検証は trim 前の値に掛かるtrim 後に binding.Validator.ValidateStruct で検証し直す
ハンドラ内のガードbind で 400 になる入力では届かない切り出した関数を直接テストする
URL 項目の required形式を見ないurl.Parse して scheme と host を許可リストと EqualFold で比べる

参考