Go Anonymous Structs — Professional / Internals Level¶
1. Overview¶
This document covers anonymous-struct usage as it appears in production Go: standard-library tests, real HTTP handlers in net/http and Kubernetes' apiserver, configuration shapes, and the linter rules that constrain abuse. It is a tour of where anonymous structs are correct in real code, where they are wrong, and what the broader ecosystem has converged on.
2. Standard Library Usage¶
2.1 Test Tables¶
Anonymous structs as the element type of a test slice is one of the dominant idioms in the Go standard library.
src/strings/strings_test.go:
var ContainsTests = []struct {
str, substr string
expected bool
}{
{"abc", "bc", true},
{"abc", "bcd", false},
{"abc", "", true},
{"", "a", false},
}
src/encoding/json/decode_test.go carries hundreds of such tables, including nested anonymous structs:
var unmarshalTests = []struct {
in string
ptr any
out any
err error
golden bool
disallow bool
}{
...
}
The pattern is consistent: a slice literal with an inline struct type, named fields, named-field initializers when the row is nontrivial, and a for ... t.Run ... driver loop.
2.2 Inline JSON in Tests¶
src/encoding/json/encode_test.go:
type SamePointerNoCycle struct {
Ptr1, Ptr2 *SamePointerNoCycle
}
var samePointerNoCycle = &SamePointerNoCycle{}
For one-off encoding fixtures, anonymous structs appear directly:
2.3 Network Code¶
net/http uses anonymous structs sparingly for internal sentinels and for inline test responses. Real handler responses are usually named types because they form part of a stable contract.
2.4 Compiler Internals¶
cmd/compile/internal/types2/struct.go defines the Struct type itself. The compiler is a heavy user of structured data, but rarely uses anonymous structs in its code — internal types are named for traceability.
3. Real-World Production Patterns¶
3.1 Inline Response Body in HTTP Handlers¶
Common in small services:
func handleHealth(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(struct {
Status string `json:"status"`
Build string `json:"build"`
}{
Status: "ok",
Build: buildID,
})
}
Pros: response shape is local. Cons: contract drifts silently if another handler returns "the same shape" — different anonymous struct.
3.2 Kubernetes Apiserver Patterns¶
In Kubernetes' kubernetes/staging/src/k8s.io/apiserver, response shapes are almost always named — these shapes form the public API surface. Anonymous structs appear in: - Test setup. - Inline error responses inside handler-internal helpers. - Subtest-row tables in unit tests.
The rule there is: anything that goes on the wire as part of the API has a named type.
3.3 Configuration in Test Setup¶
func TestPipeline(t *testing.T) {
cfg := struct {
Workers int
BatchSize int
FlushPeriod time.Duration
}{
Workers: 4,
BatchSize: 100,
FlushPeriod: 10 * time.Millisecond,
}
pipe := newPipeline(cfg.Workers, cfg.BatchSize, cfg.FlushPeriod)
...
}
The configuration is local to the test; naming it adds noise.
3.4 Shaping a One-Off External Request¶
Calling a third-party API that needs a small JSON envelope:
body, _ := json.Marshal(struct {
APIKey string `json:"api_key"`
Query string `json:"q"`
}{APIKey: key, Query: query})
If this same envelope shows up in three callers, promote to a named type.
3.5 Inline Pluck on Decode¶
var probe struct {
Token string `json:"access_token"`
}
if err := json.NewDecoder(resp.Body).Decode(&probe); err != nil {
return "", err
}
return probe.Token, nil
The full response has 15 fields; the caller cares about one. A named DTO would mislead readers into thinking the rest matters.
3.6 Embedded "Section" Subgroups¶
Common in YAML/JSON-driven configuration:
type ServiceConfig struct {
Name string
HTTP struct {
Listen string
Timeout time.Duration
}
DB struct {
DSN string
MaxConn int
}
}
Each section is anonymous because it would be referenced only via cfg.HTTP.X. Some teams promote these subgroups to named types for testability; both are valid.
4. Where Anonymous Structs Are Wrong¶
4.1 gRPC Service Definitions¶
Protobuf-generated types must be named — protoc cannot emit anonymous shapes, and downstream code must reference them by name. There is no anonymous option in the gRPC ecosystem.
4.2 Persisted Database Models¶
ORM-mapped structs (e.g. gorm, ent) require named types. Migration generators reference them; query builders rely on type identity.
// Required pattern:
type User struct {
ID uint `gorm:"primaryKey"`
Email string `gorm:"uniqueIndex"`
...
}
An anonymous struct cannot carry the ORM tags' lifecycle correctly because there is no central place to evolve it.
4.3 Public Library APIs¶
A library that exposes a function func New() (struct{...}, error) forces every caller to repeat the shape. Library authors invariably name the type.
4.4 Domains That Need Methods¶
Validation, formatting, marshalling overrides — any of these require methods, which require a named type.
4.5 Stable Wire Schemas¶
OpenAPI-defined responses, event-bus messages, persistent log entries — all require a named type so the schema can be evolved with code review.
5. Linter Rules¶
5.1 staticcheck SA9004¶
staticcheck does not have a dedicated "ban anonymous structs" rule, but SA9004 (and related rules) flag oddities like duplicate field tags. Anonymous structs are often the site of these because the shape is repeated by hand.
5.2 revive¶
revive includes a rule cognitive-complexity and function-length that indirectly catches enormous test tables, often built from anonymous structs.
A custom revive rule like max-anonymous-struct-fields or no-anonymous-struct-in-exported-signature is a common in-house linter at large Go shops.
5.3 golint Successors¶
Modern Go-vet warns about exported-without-doc; anonymous structs in exported signatures often trip this because there is no name to document.
5.4 Custom AST Linters¶
Teams often write a small AST walker that: - Counts fields in inline *ast.StructType literals. - Flags duplicates across files. - Flags anonymous structs in exported function parameters and returns.
6. Style Guides¶
6.1 Google Go Style Guide¶
The Google style guide recommends named types for any shape used in more than one place or any shape that crosses a package boundary. Anonymous structs are accepted in tests and inline JSON.
6.2 Uber Go Style Guide¶
Uber's guide prefers named types for production code. Anonymous structs are explicitly recommended only for test tables.
6.3 Effective Go¶
Effective Go does not prohibit anonymous structs, and it shows them in examples for composite literals. The implicit recommendation is "use them for one-offs."
7. Production Use Cases — Examples¶
7.1 Health Endpoint¶
type Server struct {
build string
}
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(struct {
Status string `json:"status"`
Build string `json:"build"`
}{
Status: "ok",
Build: s.build,
})
}
7.2 Internal Audit Log¶
func (s *Server) audit(action string, userID int, extra map[string]any) {
s.audits.Send(struct {
At time.Time `json:"at"`
Action string `json:"action"`
UserID int `json:"user_id"`
Extra map[string]any `json:"extra,omitempty"`
}{
At: time.Now(),
Action: action,
UserID: userID,
Extra: extra,
})
}
If s.audits is internal to the server, an anonymous struct keeps the schema next to the producer. If audit becomes a shared platform service, promote to a named AuditEvent type.
7.3 SQL Row Pluck¶
var row struct {
ID int64
Email string
}
err := db.QueryRow("SELECT id, email FROM users WHERE id = ?", id).Scan(&row.ID, &row.Email)
Pulling two fields from a row inside a private helper. If the helper is called from many places or grows, promote.
7.4 Test Table for an Algorithm¶
func TestNormalize(t *testing.T) {
cases := []struct {
in string
want string
}{
{" hi ", "hi"},
{"\thi\n", "hi"},
{"", ""},
}
for _, c := range cases {
if got := normalize(c.in); got != c.want {
t.Errorf("normalize(%q) = %q, want %q", c.in, got, c.want)
}
}
}
7.5 One-Off Internal API Body¶
func (c *Client) submitMetric(name string, value float64) error {
body, _ := json.Marshal(struct {
Name string `json:"name"`
Value float64 `json:"value"`
At int64 `json:"at"`
}{Name: name, Value: value, At: time.Now().Unix()})
return c.post("/metrics", body)
}
If submitMetric is one of many endpoints sharing a base envelope, promote.
8. Anti-Patterns Found in Real Code¶
8.1 Same Shape, Two Files¶
// internal/handlers/health.go
type response = struct {
Status string `json:"status"`
Build string `json:"build"`
}
// internal/handlers/version.go (drift!)
type response = struct {
Status string `json:"status"`
Build string `json:"build_id"` // tag drift
}
The "shared" alias to an anonymous shape diverges across files. The compiler does not warn because each file has its own alias.
8.2 Anonymous Struct in Exported Signature¶
// Bad — public API
func New() (struct{ ID int }, error) { ... }
// Caller:
h, err := New()
// h's type is struct{ ID int } — caller must spell it to pass to other code.
8.3 Wide Test Table¶
cases := []struct {
name, in, lang, locale, want string
flags, debug bool
timeout time.Duration
setup func()
expect func(*testing.T)
}{ /* 50 entries */ }
The row deserves a named type, possibly with builder helpers.
8.4 Inline Struct With Methods Wanted¶
// Bad — wants String() but cannot have it
log.Printf("%v", struct{ X int }{42}) // prints {42} — no formatting control
8.5 Cross-Package Sharing via Anonymous Type¶
A package returns an anonymous struct; a downstream package builds the same shape locally. Identity holds, but maintenance is fragile.
9. Code Review Heuristics¶
When reviewing anonymous-struct usage: - Search the package for the same shape; if found, suggest promotion. - Check field count: more than five is a smell. - Check tag consistency: drift is silent. - Check exposure: anonymous in exported signatures is a hard "no". - Check method needs: any planned String(), Validate(), Marshal* means name it. - Check schema stability: wire formats need names.
10. Real OSS References¶
| Project | Pattern |
|---|---|
src/encoding/json/decode_test.go | Heavy table-driven tests with anonymous structs |
src/strings/strings_test.go | Multi-table anonymous struct tests |
src/net/http/server_test.go | Anonymous structs in inline test fixtures |
| Kubernetes apiserver | Named for wire types; anonymous for tests and internals |
| Caddy server | Named types throughout; anonymous in tests |
| Hugo | Named for content types; anonymous for inline shaping |
| Cobra | Named for commands; anonymous for option groupings in tests |
11. Tooling¶
11.1 gopls Behavior¶
gopls will not suggest extracting an anonymous struct to a named type by default, but the "extract type" code action can be invoked.
11.2 go vet¶
go vet does not specifically warn about anonymous structs but will catch issues like field-tag malformations even on anonymous structs.
11.3 staticcheck¶
Tag-format warnings (S1037, SA9004) apply to anonymous structs.
11.4 errcheck¶
Indifferent to anonymous structs.
11.5 IDE Refactors¶
GoLand / VS Code Go can extract an anonymous struct into a named type via "Extract Type" or "Refactor".
12. Performance Behavior in Production¶
In benchmarks, anonymous and named structs are indistinguishable. Reflection-based libraries (encoding/json, gob) cache the type descriptor on first use; the cache is keyed by *reflect.rtype. Anonymous structs share the descriptor across the program when shape matches.
There is no measurable performance reason to choose one over the other. The only differences are maintenance, documentation, and method support.
13. Migration Strategies¶
When you decide to promote: 1. Find every site that constructs the shape. 2. Define the named type next to the most prominent user. 3. Replace inline literals with the named type. 4. Add a doc comment. 5. If the shape will be marshaled, write MarshalJSON/UnmarshalJSON on the named type if needed. 6. Run tests.
Tools that help: - gopls "Extract Type" code action. - gofmt -r for simple structural rewrites. - Custom AST tooling for large-scale migrations.
14. Real Bug Stories¶
14.1 Tag Drift in Two Handlers¶
A team had two handlers returning "the same" anonymous shape. One day, a developer changed json:"id" to json:"user_id" in one handler. The two responses diverged silently. Discovered weeks later when a client library tried to merge results.
Fix: promote to a named UserResp type.
14.2 Anonymous Struct in Exported API¶
A library returned func Stat() struct{ N int; Bytes int64 }. Callers' code became unmaintainable when the shape grew a third field — every caller had to update.
Fix: introduce a named Stat type, deprecate the function, ship a v2.
14.3 Cross-Package Identity Surprise¶
Code relied on reflect.DeepEqual between values from two packages. Fields drifted slightly (one added a tag), and DeepEqual silently returned false. Detected via failing test only after weeks of diagnosis.
Fix: shared named type.
15. Senior Production Checklist¶
- Anonymous shapes used only in tests and inline serialization.
- No anonymous struct in exported function signatures.
- No anonymous struct in cross-package contracts.
- Field tags consistent across "same" anonymous shapes.
- Promotion to named type whenever the shape is reused.
- Methods provided via wrapping in a named struct, never attempted on the anonymous one.
16. Summary¶
In production Go, anonymous structs are concentrated in test tables, inline JSON shaping, and private helpers. They are absent from gRPC types, persisted models, and stable wire schemas. The standard library uses them heavily in tests and sparingly in handlers. Linter rules and style guides converge on the same advice: named types by default, anonymous when local AND simple AND no methods needed. Performance is identical; the trade-off is purely about maintenance and API design.