Struct Fields

Accessing, annotating, and understanding the memory layout of fields in Go structs

A struct is a collection of named values, each of a specific type. Those values are its fields. Every struct you define — whether it models a person, a database row, or a configuration object — is only as useful as the fields it carries. This section moves beyond the basic declaration of structs and focuses on three practical aspects of fields: how you read and write them in code, how you annotate them with metadata for tools like encoding/json, and why their physical order in memory can affect the size of your data structures.


Field Access

Accessing a field means reading its current value or assigning a new one. In Go, this always uses the dot operator — . — no matter whether you hold the struct directly or through a pointer.

package main
import "fmt"
type Point struct {
    X int
    Y int
}
func main() {
    p := Point{X: 10, Y: 20}
    fmt.Println(p.X) // reads field X
    p.Y = 30         // writes field Y
    fmt.Println(p.Y)
}

When you run the code, p.X prints 10 and p.Y prints 30. The dot operator works identically for struct values and struct pointers because Go automatically dereferences the pointer for you.

ptr := &Point{X: 5, Y: 15}
fmt.Println(ptr.X) // no need to write (*ptr).X
ptr.Y = 25         // directly modifies the original struct

The compiler knows that ptr is a pointer to a Point and transparently follows the pointer to reach the field. Writing (*ptr).X is legal but rare in idiomatic Go; the shorthand is preferred.

Nil Pointer Access:

If the pointer itself is nil, accessing a field through it causes a runtime panic — just like dereferencing any nil pointer. Always ensure a struct pointer is initialized before field access, either by assigning the address of an existing value or calling a constructor function that returns a valid pointer.

var ptr *Point        // ptr is nil
fmt.Println(ptr.X)    // panic: runtime error: invalid memory address or nil pointer dereference

A struct field is either exported (its name starts with an uppercase letter) or unexported (lowercase). Exported fields are accessible from any package that imports the struct’s package. Unexported fields are visible only within the same package. This distinction matters when you try to access a field from another package and get a compile error:

package shapes
type Box struct {
    width  int // unexported
    Height int // exported
}
package main
import "shapes"
func main() {
    b := shapes.Box{}
    b.Height = 10 // allowed
    // b.width = 5 // compile error: width not exported
}

For a beginner, the mental model is straightforward: the dot is like reaching into the struct’s labelled compartments and pulling out or replacing what’s inside. If you have a pointer, Go quietly reaches through it for you. If the field name starts with a capital letter, the compartment is visible from anywhere; otherwise it’s private to the package.

Unexported Fields and External Packages:

An unexported field cannot be accessed outside its own package, even if you try to set it through a struct literal — the compiler will reject the entire literal if you include a field name that isn’t exported. This is a deliberate design choice to enforce encapsulation without classes.


Struct Tags

A struct tag is a short string literal attached to a field, written between backticks immediately after the field’s type. Tags are invisible to the program’s normal execution — they don’t affect field access, assignment, or arithmetic — but they can be inspected at runtime through the reflect package. The most common consumer of struct tags is the standard library’s encoding/json package, which uses tags to decide how a field should appear in JSON output.

type User struct {
    ID    int    `json:"id"`
    Name  string `json:"name"`
    Email string `json:"email,omitempty"`
}

When you marshal a User value to JSON, the encoder reads the json tag for each field and uses the specified key name instead of the Go field name. So ID becomes "id" in the JSON, Name becomes "name", and Email is omitted from the output entirely if it holds the zero value (an empty string).

package main
import (
    "encoding/json"
    "fmt"
)
type User struct {
    ID    int    `json:"id"`
    Name  string `json:"name"`
    Email string `json:"email,omitempty"`
}
func main() {
    u := User{ID: 1, Name: "Alice"}
    data, _ := json.Marshal(u)
    fmt.Println(string(data))
}

The output is {"id":1,"name":"Alice"} — the Email field disappeared because it was empty and tagged with omitempty. Without the struct tag, the JSON keys would be ID, Name, and Email, mirroring the Go identifiers.

Correct Tags Produce Predictable Output:

If the JSON output matches the key names and omissions you intended, your struct tags are working. A quick json.Marshal is the simplest sanity check when you first add tags to a struct.

A tag’s content is a raw string. The convention is to write a series of space-separated key-value pairs, where each value is a double-quoted string: key:"value". The json package looks for the json key; other packages may look for xml, yaml, db, validate, and so on. You can attach multiple tags to a single field:

type Record struct {
    Title string `json:"title" xml:"title" validate:"required"`
}

Tags are only meaningful for exported fields. If you add a tag to an unexported field, the encoding/json package will ignore both the field and the tag because it cannot access the field at all.

Tags on Unexported Fields Are Silently Ignored:

The compiler will not warn you that a tag on an unexported field is useless. If you find that a field never appears in your JSON output, check that its name starts with a capital letter.

Beginners often see tags as magical strings that control serialization. Internally, the reflect package provides StructTag methods like Get(key) and Lookup(key) to parse them. When you call json.Marshal, the encoder uses reflection to walk the struct fields, read their tags, and build the output. You rarely need to write tag-parsing code yourself, but knowing the mechanism helps demystify why the format matters: a missing colon, an extra space, or a misplaced quote can make a tag unparseable, and the tool consuming it will silently fall back to the Go field name.


Field Alignment and Padding

Go arranges struct fields in memory in the order you declare them, but it may insert invisible gaps — padding — between fields. Padding exists because the CPU accesses memory most efficiently when multi-byte values sit at addresses that are multiples of their size. A 4-byte int32 is happiest at an address divisible by 4; an 8-byte float64 wants an address divisible by 8. The compiler respects these alignment requirements and may add unused bytes between fields to satisfy them.

A simple struct can hide surprising padding:

package main
import (
    "fmt"
    "unsafe"
)
type Misaligned struct {
    a byte   // 1 byte
    b int64  // 8 bytes
    c byte   // 1 byte
}
type Aligned struct {
    b int64  // 8 bytes
    a byte   // 1 byte
    c byte   // 1 byte
}
func main() {
    fmt.Println(unsafe.Sizeof(Misaligned{})) // 24
    fmt.Println(unsafe.Sizeof(Aligned{}))    // 16
}

In Misaligned, the compiler places a at offset 0, then must insert 7 bytes of padding before b so that b starts at offset 8 (a multiple of 8). After b, c lands at offset 16, and the final struct size is rounded up to a multiple of 8, giving 24 bytes. In Aligned, placing the 8-byte field first avoids internal padding, and the two 1-byte fields sit together, so the total shrinks to 16 bytes. Both structs hold exactly the same data, yet one is 50% larger.

Padding Is Not a Bug:

Padding is part of the Go memory model, not a flaw. It guarantees that fields are properly aligned, which prevents penalties or errors on architectures that forbid misaligned access. The compiler’s layout decisions are deterministic and can be inspected with unsafe.Offsetof.

Most programs do not need to worry about field ordering. The benefit of rearranging fields only becomes measurable when you allocate millions of structs or when the struct is embedded inside a slice that dominates memory. If profiling shows excessive memory consumption, examining field order is a low-risk optimisation, but premature rearrangement for its own sake adds maintenance friction without a measurable win.

A beginner should picture a struct as a row of boxes that must be placed on a shelf where certain boxes can only sit at specific notches. The shelf’s notches are the alignment boundaries. If a large box can’t fit right after a tiny one, the carpenter (the compiler) adds a spacer so the next box sits properly. That’s padding. The total length of the shelf is the struct’s size.


Summary

Fields are the atomic units that give a struct its meaning. The dot operator makes them uniformly accessible regardless of whether the struct sits on the stack or behind a pointer. Struct tags connect fields to external systems without affecting runtime behaviour, turning a plain data holder into a schema that serialisers, validators, and ORMs can understand. Field alignment reveals that the compiler’s layout decisions have subtle performance and memory consequences — knowledge that pays off when you need to diagnose memory-heavy Go programs.