Structs Beginner¶
Introduction¶
Structs are Go's way of defining custom data types by grouping fields together. Unlike classes in OOP languages, Go structs have no inheritance, no constructors, and no implicit this/self. Instead, Go uses composition (covered in intermediate topics) and standalone constructor functions. Structs are value types -- assignment copies all fields. Understanding struct tags, the NewXxx constructor pattern, and exported vs unexported fields is essential for Go interviews and production code.
Defining and Using Structs¶
type User struct {
ID int
Username string
Email string
Active bool
CreatedAt time.Time
}
// Field access
u := User{ID: 1, Username: "alice", Email: "alice@example.com"}
fmt.Println(u.Username) // "alice"
u.Active = true
Struct Literals¶
// Named fields (preferred -- order doesn't matter, self-documenting)
u1 := User{
ID: 1,
Username: "alice",
Email: "alice@example.com",
}
// Positional fields (fragile -- breaks if struct definition changes)
u2 := User{1, "alice", "alice@example.com", true, time.Now()}
// Pointer with & (common pattern for constructor-like usage)
u3 := &User{
ID: 2,
Username: "bob",
}
Avoid Positional Literals
Positional struct literals break when fields are added or reordered. Use named fields -- the go vet tool warns about unkeyed struct literals for exported types.
Zero Values¶
Every struct field is initialized to its type's zero value when not explicitly set.
var u User
fmt.Println(u.ID) // 0
fmt.Println(u.Username) // ""
fmt.Println(u.Active) // false
fmt.Println(u.CreatedAt) // 0001-01-01 00:00:00 +0000 UTC
// Useful idiom: check if struct is zero-valued
if u == (User{}) {
fmt.Println("empty user")
}
Design Tip
Design your structs so that the zero value is useful. For example, sync.Mutex{} is an unlocked mutex, bytes.Buffer{} is a ready-to-use buffer. This is an important Go idiom.
Structs Are Value Types¶
u1 := User{ID: 1, Username: "alice"}
u2 := u1 // copies ALL fields
u2.Username = "bob"
fmt.Println(u1.Username) // "alice" -- original unchanged
Passing a struct to a function copies it. Use a pointer when you need to modify the original or avoid expensive copies.
func deactivate(u *User) {
u.Active = false // modifies the original
}
u := &User{ID: 1, Active: true}
deactivate(u)
fmt.Println(u.Active) // false
Pointer to Struct: Auto-Dereference¶
Go automatically dereferences struct pointers on field access -- no -> operator needed.
u := &User{ID: 1, Username: "alice"}
// These are equivalent:
fmt.Println(u.Username) // auto-dereferenced (idiomatic)
fmt.Println((*u).Username) // explicit dereference (unnecessary)
Constructor Functions: The NewXxx Pattern¶
Go doesn't have constructors. By convention, a factory function named NewXxx returns an initialized struct (usually a pointer).
type Server struct {
host string
port int
timeout time.Duration
logger *log.Logger
}
func NewServer(host string, port int) *Server {
return &Server{
host: host,
port: port,
timeout: 30 * time.Second,
logger: log.Default(),
}
}
// Usage
srv := NewServer("localhost", 8080)
Functional Options Pattern (Production-Grade)¶
type Option func(*Server)
func WithTimeout(d time.Duration) Option {
return func(s *Server) { s.timeout = d }
}
func WithLogger(l *log.Logger) Option {
return func(s *Server) { s.logger = l }
}
func NewServer(host string, port int, opts ...Option) *Server {
s := &Server{
host: host,
port: port,
timeout: 30 * time.Second,
logger: log.Default(),
}
for _, opt := range opts {
opt(s)
}
return s
}
// Clean, extensible API
srv := NewServer("localhost", 8080,
WithTimeout(10*time.Second),
WithLogger(customLogger),
)
Exported vs Unexported Fields¶
Field visibility is controlled by the first letter: uppercase = exported, lowercase = unexported.
type Config struct {
Host string // exported -- accessible from other packages
Port int // exported
apiKey string // unexported -- only accessible within this package
retries int // unexported
}
JSON and Unexported Fields
encoding/json (and most reflection-based packages) cannot see unexported fields. They are silently ignored during marshal/unmarshal:
Struct Tags¶
Tags are string metadata attached to fields, read via reflection at runtime. The json tag is the most common.
type User struct {
ID int `json:"id" db:"user_id"`
Username string `json:"username" db:"username" validate:"required,min=3"`
Email string `json:"email" db:"email" validate:"required,email"`
Password string `json:"-"`
Active bool `json:"active,omitempty"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
Common Tag Directives¶
| Tag | Meaning |
|---|---|
json:"name" |
Use name as the JSON key |
json:"-" |
Exclude from JSON entirely |
json:",omitempty" |
Omit if zero value |
json:",string" |
Encode number/bool as JSON string |
db:"column" |
Database column mapping (sqlx, gorm) |
validate:"required" |
Validation rules (go-validator) |
yaml:"name" |
YAML key name |
JSON Marshal/Unmarshal in Practice¶
// Struct to JSON
u := User{ID: 1, Username: "alice", Email: "alice@ex.com", Password: "secret123"}
data, err := json.Marshal(u)
// {"id":1,"username":"alice","email":"alice@ex.com","active":false,"created_at":"0001-01-01T00:00:00Z"}
// Password excluded (json:"-"), Active included (not omitempty for bool example)
// JSON to struct
var u2 User
err = json.Unmarshal(data, &u2)
// With omitempty -- zero-valued Active omitted
u3 := User{ID: 2, Username: "bob", Email: "bob@ex.com"}
data2, _ := json.Marshal(u3)
// {"id":2,"username":"bob","email":"bob@ex.com","created_at":"0001-01-01T00:00:00Z"}
// "active" is missing because it's false (zero value) and tag has omitempty
Anonymous Structs¶
Useful for one-off data structures, test cases, and JSON decoding of nested responses.
// Inline struct variable
point := struct {
X, Y int
}{10, 20}
// Table-driven tests (very common Go pattern)
tests := []struct {
name string
input int
expected int
}{
{"positive", 5, 25},
{"zero", 0, 0},
{"negative", -3, 9},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := square(tt.input); got != tt.expected {
t.Errorf("square(%d) = %d, want %d", tt.input, got, tt.expected)
}
})
}
// Quick JSON decode without defining a type
var resp struct {
Data struct {
ID int `json:"id"`
Name string `json:"name"`
} `json:"data"`
Error string `json:"error"`
}
json.Unmarshal(body, &resp)
Struct Comparison¶
Structs are comparable if all their fields are comparable.
type Point struct{ X, Y int }
p1 := Point{1, 2}
p2 := Point{1, 2}
fmt.Println(p1 == p2) // true -- field-by-field comparison
// Structs with non-comparable fields cannot use ==
type Data struct {
Values []int // slices are NOT comparable
}
// d1 == d2 // compile error!
// Use reflect.DeepEqual(d1, d2) for deep comparison (slow, use in tests only)
Map Keys
Only comparable structs can be used as map keys. This makes structs like Point{X, Y int} excellent composite keys.
Quick Reference¶
| Concept | Syntax | Notes |
|---|---|---|
| Define | type T struct { ... } |
Uppercase name = exported type |
| Named literal | T{Field: value} |
Preferred, order-independent |
| Pointer | &T{...} |
Returns *T |
| Zero value | T{} or var t T |
All fields at zero values |
| Field access | s.Field |
Auto-dereferences pointers |
| Constructor | func NewT(...) *T |
Convention, not language feature |
| Tags | `json:"name"` |
Metadata read via reflection |
| Export | Uppercase field |
Visible outside package |
| Unexport | lowercase field |
Package-private |
| Comparison | s1 == s2 |
Only if all fields are comparable |
Best Practices¶
- Always use named fields in struct literals -- positional literals are fragile
- Design useful zero values -- a zero-valued struct should be valid or obviously "empty"
- Use the
NewXxxpattern when initialization logic is needed (defaults, validation, resource allocation) - Use
json:"-"to exclude sensitive fields (passwords, tokens) from serialization - Use
omitemptythoughtfully -- it hides zero values, which may be semantically meaningful - Prefer value receivers for small, immutable structs; use pointer receivers for large structs or when mutation is needed
- Keep struct definitions close to where they're used; avoid giant "models" packages
Common Pitfalls¶
Unkeyed struct literals
// Breaks if a field is added between ID and Email
u := User{1, "alice", "alice@ex.com", true, time.Now()}
go vet catches this for exported structs.
Mutation through value receiver
Use a pointer receiver(u *User) when the method needs to modify the struct.
Copying structs with sync primitives
type SafeCounter struct {
mu sync.Mutex
n int
}
c1 := SafeCounter{}
c2 := c1 // WRONG -- copies the mutex, undefined behavior
sync.Mutex, sync.WaitGroup, or similar. Pass by pointer. Use go vet to detect this.
Struct tag typos
Struct tags have no compile-time validation. A typo like `josn:"name"` silently does nothing. Use go vet and linters like staticcheck to catch tag errors.
Performance Considerations¶
| Scenario | Recommendation |
|---|---|
| Small struct (≤3-4 fields, no slices/maps) | Pass by value -- fits in registers, avoids heap allocation |
| Large struct (many fields or large arrays) | Pass by pointer -- avoids expensive copy |
| Struct used as map key | Must be comparable; keep it small for fast hashing |
| Hot-path struct allocation | Consider pre-allocating with sync.Pool for short-lived large structs |
| Struct field ordering | Group fields by size (largest first) to minimize padding; use fieldalignment linter |
Struct Padding
Go adds padding bytes between fields for memory alignment. Reordering fields from largest to smallest can reduce struct size:
Interview Tips¶
Interview Tip
When asked "Does Go have classes?", answer: No. Go uses structs for data and attaches behavior through methods with receivers. There's no inheritance -- Go favors composition over inheritance via struct embedding.
Interview Tip
Know the difference between value and pointer receivers: value receivers operate on a copy (safe, can't mutate), pointer receivers can mutate the original. If any method needs a pointer receiver, convention says all methods on that type should use pointer receivers for consistency.
Interview Tip
If asked about the functional options pattern, explain: it solves the "constructor with many optional parameters" problem cleanly -- each option is a function that modifies the struct, making the API extensible without breaking existing callers.
Interview Tip
Be ready to explain why json.Marshal ignores unexported fields: Go's encoding/json uses reflection, and the reflect package cannot access unexported fields from outside the defining package. This is a deliberate encapsulation boundary.
Key Takeaways¶
- Structs are value types -- assignment and function calls copy all fields
- Use named field literals (
T{Field: val}) -- never positional - The
NewXxxconstructor pattern replaces traditional constructors - Exported fields start with uppercase; unexported with lowercase
- Struct tags (
json,db,validate) provide metadata read via reflection - Auto-dereference:
ptr.Fieldworks the same as(*ptr).Field - Anonymous structs are ideal for table-driven tests and one-off JSON decoding
- Never copy structs containing
sync.Mutexor similar synchronization primitives - Struct embedding (composition) exists but is covered in intermediate topics