Skip to content

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:

c := Config{Host: "localhost", apiKey: "secret"}
data, _ := json.Marshal(c)
// {"Host":"localhost","Port":0} -- apiKey is missing!


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

  1. Always use named fields in struct literals -- positional literals are fragile
  2. Design useful zero values -- a zero-valued struct should be valid or obviously "empty"
  3. Use the NewXxx pattern when initialization logic is needed (defaults, validation, resource allocation)
  4. Use json:"-" to exclude sensitive fields (passwords, tokens) from serialization
  5. Use omitempty thoughtfully -- it hides zero values, which may be semantically meaningful
  6. Prefer value receivers for small, immutable structs; use pointer receivers for large structs or when mutation is needed
  7. 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()}
Always use named fields. go vet catches this for exported structs.

Mutation through value receiver

func (u User) Deactivate() {
    u.Active = false // modifies a COPY, original unchanged
}
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
Never copy structs containing 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:

// 24 bytes (with padding)
type Bad struct {
    a bool   // 1 byte + 7 padding
    b int64  // 8 bytes
    c bool   // 1 byte + 7 padding
}
// 16 bytes (optimal)
type Good struct {
    b int64  // 8 bytes
    a bool   // 1 byte
    c bool   // 1 byte + 6 padding
}


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 NewXxx constructor 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.Field works the same as (*ptr).Field
  • Anonymous structs are ideal for table-driven tests and one-off JSON decoding
  • Never copy structs containing sync.Mutex or similar synchronization primitives
  • Struct embedding (composition) exists but is covered in intermediate topics