A tiny Go linter that flags struct tags you don't want used globally, in a specific package by name, or in a specific package by import path.
notag is built on top of golang.org/x/tools/go/analysis, so it plugs into anything that speaks the standard analyzer protocol (e.g. singlechecker, custom drivers).
Different layers of an application have different serialization needs. JSON tags belong in the layer that talks to clients; database tags belong in the persistence layer; the domain layer in between should generally not carry either. notag lets you encode that boundary as a lint rule instead of relying on review discipline.
┌─────────────────────────────────────────┐
│ API/Controller Layer │
│ ┌─────────────────────────────────────┐│
│ │ type UserRequest struct { ││ ✅ JSON tags OK
│ │ Name string `json:"name"` ││
│ │ Email string `json:"email"` ││
│ │ } ││
│ └─────────────────────────────────────┘│
└─────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ Business Logic Layer │
│ ┌─────────────────────────────────────┐│
│ │ type User struct { ││ ❌ notag catches this!
│ │ Name string `json:"name"` ││
│ │ Email string ││
│ │ } ││
│ └─────────────────────────────────────┘│
└─────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ Data Layer │
│ ┌─────────────────────────────────────┐│
│ │ type UserEntity struct { ││ ✅ DB tags OK
│ │ ID int `db:"id"` ││
│ │ Name string `db:"name"` ││
│ │ Email string `db:"email"` ││
│ │ } ││
│ └─────────────────────────────────────┘│
└─────────────────────────────────────────┘
go install github.com/guerinoni/notag@latestFor each Go package the analyzer visits, notag:
- Computes the set of denied tag keys by combining
-denied(global) with any-denied-pkgentry whose key matches the package name and any-denied-pkg-pathentry whose key matches the package import path. - Walks every
structliteral, including nested anonymous structs and embedded fields. - Extracts the tag keys and values from each field's struct tag using a
reflect.StructTag-style parser. - Skips files marked as generated (
// Code generated ... DO NOT EDIT.); pass-include-generatedto override. - Reports each field whose tag declares any denied key — unless that key's value matches an
-allow key=valueexemption.
The diagnostic is positioned on the offending field, not on the enclosing struct, so editors and CI annotations point at the exact line:
internal/domain/user.go:7:2: field 'Name' contains denied tags: 'json'
# Deny `validate` and `xml` everywhere
notag -denied validate,xml ./...The key matches pass.Pkg.Name() — the bare package name as it appears in the package clause.
# In the "service" package, deny `json`
notag -denied-pkg service:json ./...
# Multiple flag uses are appended (different keys are independent)
notag -denied-pkg service:json -denied-pkg repository:xml ./...
# Repeating the same key also appends
notag -denied-pkg service:json -denied-pkg service:xml ./...
# equivalent to:
notag -denied-pkg service:json,xml ./...The key matches pass.Pkg.Path() — the full import path. Use this when several packages in the project share a name.
notag -denied-pkg-path github.com/guerinoni/notag/internal/domain:json,xml ./...By default notag ignores files whose first comment matches the standard // Code generated ... DO NOT EDIT. pattern, since you can't reasonably edit them by hand. If you generate the file yourself and want the same rules enforced, opt back in:
notag -include-generated -denied json ./...Sometimes a denied key has a legitimate "off-switch" value you still want to permit — most commonly json:"-", the explicit skip-serialization marker. Use -allow key=value (repeatable) to exempt those exact values from the deny:
# Deny json everywhere, but allow json:"-" as an explicit skip marker
notag -denied json -allow json=- ./...
# Multiple allowed values for the same key
notag -denied json -allow json=- -allow json=ignore ./...The match is exact: -allow json=- exempts json:"-" but does not exempt json:"-,omitempty". Keys with no allow entry behave as before.
All three sources are merged for each package the analyzer visits, deduplicated, then matched.
notag \
-denied db \
-denied-pkg service:json \
-denied-pkg-path github.com/org/be/internal/controllers:xml \
./...Given:
package domain
type User struct {
Name string `json:"name"`
Email string `xml:"email"`
}Running:
notag -denied json,xml ./...produces:
domain/user.go:4:2: field 'Name' contains denied tags: 'json'
domain/user.go:5:2: field 'Email' contains denied tags: 'xml'
Multiple denied keys on the same field are reported once per field, joined by comma:
field 'Name' contains denied tags: 'json,xml'
Fields declared with multiple names share their tag in Go, so each identifier is reported separately:
type T struct {
A, B string `json:"shared"`
}t.go:2:2: field 'A' contains denied tags: 'json'
t.go:2:2: field 'B' contains denied tags: 'json'
If you embed notag in a custom analyzer driver, build the analyzer with explicit configuration instead of CLI flags:
import (
"github.com/guerinoni/notag/pkg/analyzer"
"golang.org/x/tools/go/analysis/singlechecker"
)
func main() {
a := analyzer.NewAnalyzerWithConfig(analyzer.Setting{
GlobalTagsDenied: "validate",
Pkg: analyzer.PkgDenyMap{
"service": []string{"json"},
},
PkgPath: analyzer.PkgDenyMap{
"github.com/org/be/internal/domain": []string{"json", "xml"},
},
Allow: analyzer.AllowValues{
"json": []string{"-"},
},
})
singlechecker.Main(a)
}- Global tag restrictions
- Package-specific tag restrictions (by name)
- Package path-based restrictions
- Combine global + per-package directives
- Multiple tag support, deduped across sources
- Embedded fields and nested anonymous structs
- Multi-name fields (
A, B string \json:"x"``) reported per identifier - Robust struct-tag parsing (tab separators, escaped quotes)
- Skip generated files by default, opt back in with
-include-generated - Allow specific tag values (
-allow json=-) to bypass an otherwise-denied key