結論
- 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 で呼ぶ

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 では評価されない

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 => 200min / 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 も通る。正常系しかテストしていないと、検証済みに見えるまま残る
レスポンスは検証されない

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 は型によって意味が変わる

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 + required | 400 | 400 | "" => 400 |
*string + required | 400 | 400 | "" => 200 |
*string + required,min=1 | 400 | 400 | "" => 400 (min) |
int + required | 400 | 400 | 0 => 400 |
*int + required | 400 | 400 | 0 => 200 |
bool + required | 400 | 400 | false => 400 |
*bool + required | 400 | 400 | false => 200 |
[]int + required | 400 | 400 | [] => 200 |
[]int + min=1 | 400 (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 の間で反転する
タグは左から評価され、最初に落ちたタグだけが返る

validator は 1 つのフィールドのタグを左から順に評価し、最初に落ちたタグでそのフィールドの検証を終える。
非ポインタの int では、キー欠落も 0 も required で落ちる。範囲違反のつもりで送った 0 が、必須違反として返る。
type Order struct {
Qty int `json:"qty" binding:"required,min=1,max=99"`
}| 入力 | required,min=1,max=99 | min=1,max=99 |
|---|---|---|
{} | required | min |
{"qty":0} | required | min |
{"qty":100} | max | max |
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 の後で 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":"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 の host | allowedOrigin | 前方一致 |
|---|---|---|---|
https://allowed.com/callback | allowed.com | 通す | 通す |
HTTPS://Allowed.com/path | Allowed.com | 通す | 弾く |
https://allowed.com@evil.com | evil.com | 弾く | 通す |
https://allowed.com.evil.com | allowed.com.evil.com | 弾く | 通す |
//allowed.com | allowed.com (scheme が空) | 弾く | 弾く |
https:\\evil.com | 空 | 弾く | 弾く |
https://allowed.com evil.com | パースエラー | 弾く | 通す |
https://allowed.com:443 | allowed.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 を受けるならポインタにする |
ポインタ + required | nil だけ弾く。&"" は通る | 空文字も弾くなら min=1 を足す |
スライス + required | nil だけ弾く。[] は通る | 空配列を弾くなら min=1 |
required,min=1 の int に 0 | required で返る | エラーコードを分けたいなら required を外す |
| bind 後の trim | 検証は trim 前の値に掛かる | trim 後に binding.Validator.ValidateStruct で検証し直す |
| ハンドラ内のガード | bind で 400 になる入力では届かない | 切り出した関数を直接テストする |
URL 項目の required | 形式を見ない | url.Parse して scheme と host を許可リストと EqualFold で比べる |