Field Access in Go Structs

Learn how to read and write individual struct fields in Go using the dot operator, the rules for exported and unexported fields, how pointers affect field access, and the most common mistakes developers make.

The dot operator is the only mechanism Go provides for reaching into a struct and grabbing or changing a single field. It works the same way on both a concrete struct value and a pointer to a struct, which is one of the features that makes Go feel unusually smooth when you first encounter it.

Understanding field access means knowing three things: the compile‑time check that keeps typos from turning into runtime bugs, the auto‑dereference behavior that removes the need for * or ->, and the visibility boundary created by uppercase and lowercase field names.

How the Dot Operator Selects a Field

A struct field is accessed by writing the struct variable name, a dot, and the field name. The compiler verifies that the field name exists in the struct type. If the field is missing, the program will not compile.

package main
import "fmt"
type Coordinate struct {
    X float64
    Y float64
}
func main() {
    point := Coordinate{X: 10.5, Y: 20.2}
    fmt.Println(point.X)
}

Running this prints 10.5. The expression point.X resolves to the value stored in the X field of point. The compiler looked up the struct definition for Coordinate, confirmed that X is a valid field name of type float64, and emitted the correct memory access instruction.

Field names are case‑sensitive:

point.x and point.X are different identifiers. A lowercase x will cause a compile error unless you have explicitly defined a field named x. The case of the first letter also controls whether the field is visible from other packages, which is explained further down.

This compile‑time safety is why Go programs rarely see errors like “property ‘name’ is undefined” at runtime. The dot operator is statically typed — it guarantees the field exists before the program ever runs.

Accessing Fields Through a Struct Pointer

When you have a pointer to a struct, you still use the dot operator. Go automatically dereferences the pointer for you. There is no separate arrow operator like -> in C or C++.

func main() {
    point := Coordinate{X: 1.0, Y: 2.0}
    ptr := &point
    fmt.Println(ptr.X) // reads through the pointer
    fmt.Println(ptr.Y)
}

Both ptr.X and ptr.Y work exactly as they would on the original point value. Under the hood, the compiler translates ptr.X into (*ptr).X, but you do not need to write that manually. This design decision makes pointer usage in Go feel almost invisible — you can pass a struct by pointer for efficiency or mutability without changing how you read its fields.

The explicit dereference form is valid but rarely used:

fmt.Println((*ptr).X) // identical to ptr.X

The reason this is important for beginners: you will frequently see functions that return a pointer to a struct, like time.Now() returning *time.Time. You can immediately write t.Year() or t.Month() without ever worrying about whether you have a value or a pointer.

Modifying Fields After a Struct Is Created

Struct fields are mutable by default. You can assign a new value to any field using the same dot syntax, as long as the struct variable itself is not a constant (structs cannot be declared const).

func main() {
    var c Coordinate
    c.X = 7.5
    c.Y = 13.8
    fmt.Printf("%+v\n", c) // {X:7.5 Y:13.8}
}

If you have a pointer to the struct, assigning through the pointer also modifies the original value, because the pointer carries the address of the same memory.

func main() {
    c := Coordinate{X: 1.0, Y: 2.0}
    ptr := &c
    ptr.X = 99.0
    fmt.Println(c.X) // 99.0 — the original was changed
}

The auto‑dereference behavior applies to assignment as well: ptr.X = 99.0 is equivalent to (*ptr).X = 99.0. The compiler knows the left side of the assignment is a field access and follows the pointer automatically.

A copy of a struct does not affect the original:

If you pass a struct by value to a function or assign it to a method with a value receiver, you are working with a copy. Assignments to that copy’s fields have no effect on the caller’s struct. This is a frequent source of confusion when learning methods in Go.

func moveByValue(c Coordinate) {
    c.X += 10
}
func main() {
    p := Coordinate{X: 5.0, Y: 5.0}
    moveByValue(p)
    fmt.Println(p.X) // 5.0, the copy was modified, not p
}

The warning above applies whenever you see a function signature that takes a struct directly rather than a pointer to it.

Exported and Unexported Fields — The Visibility Rule

Go controls access to struct fields across package boundaries using the first character of the field name. If a field name starts with an uppercase letter, it is exported (public). If it starts with a lowercase letter, it is unexported (private to the package where the struct is defined).

This rule is tied to the structure of Go programs: packages are the unit of code organization, and anything that starts with a lowercase letter is invisible to code in any other package.

Consider two packages. Package bank defines a struct:

// bank/account.go
package bank
type Account struct {
    Owner   string // exported
    balance int    // unexported
}

In the main package, you can import bank and access Owner directly, but balance will cause a compilation error.

package main
import (
    "fmt"
    "myapp/bank"
)
func main() {
    a := bank.Account{Owner: "Alex"}
    fmt.Println(a.Owner) // works
    // fmt.Println(a.balance) // compile error: a.balance undefined
    //                         (cannot refer to unexported field balance)
}

Unexported field access is a compile‑time error:

The compiler rejects any reference to an unexported field from outside its defining package. The error message will state that the field is undefined or inaccessible. This is not a runtime guard — the code will never build.

Within the same package, all fields are accessible regardless of case. The uppercase/lowercase distinction only matters when two different packages interact. This is why you often see struct definitions with exported fields for configuration or data transfer objects, and unexported fields for internal state that the package manages itself.

The same naming rule applies to methods: if a method name starts with an uppercase letter, other packages can call it. If you need to expose the value of an unexported field to outside code, you typically define an exported method that returns it — Go idioms prefer naming the method after the field, not GetBalance.

// bank/account.go
func (a *Account) Balance() int {
    return a.balance
}

A new user might think that because balance is unexported, you cannot see it at all from outside. That is true for direct dot access, but exported methods act as controlled access points. The field itself remains hidden from external struct literals and dot notation.

You can verify access with the compiler:

The simplest test: try to access the field from another package. If it compiles, the field is exported. If the compiler complains “cannot refer to unexported field”, your visibility boundary is correctly in place.

Accessing Fields of an Anonymous Struct

When a struct type is declared inline without a name, it is an anonymous struct. You can still create values of that type and access their fields with the dot operator. Anonymous structs appear often in table‑driven tests and in situations where you need a one‑off grouping of data.

func main() {
    person := struct {
        name string
        age  int
    }{
        name: "Ravi",
        age:  27,
    }
    fmt.Println(person.name)
}

The fields of an anonymous struct are just as real as those of a named struct. The compiler still enforces that you only reference declared fields. Because anonymous structs have no exported name, they can only be used within the same package or through interfaces, but field access works identically.

In table‑driven tests, you frequently see anonymous struct slices where each element contains input, expected output, and a description:

tests := []struct {
    input    string
    expected bool
}{
    {"racecar", true},
    {"hello", false},
}
for _, tt := range tests {
    result := isPalindrome(tt.input)
    // tt.expected is accessed with the dot operator
}

The dot notation remains the same regardless of whether the struct type has a name.

Common Mistakes That Cause Field Access to Fail

Several errors are easy to make and each gives a different kind of failure — compile error or runtime panic. Recognising them early saves debugging time.

Undefined field name. The compiler verifies field names against the struct definition. Typing p.Name when the struct has p.Name? No, if the field is Username, p.Name will not compile. The error is immediate.

Nil pointer dereference. A pointer to a struct can be nil. If you call a method on a nil pointer, some methods handle it gracefully, but accessing a field on a nil pointer causes a runtime panic because the memory for the struct does not exist.

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

This is not a compile‑time error because the compiler cannot know that ptr will be nil at runtime. Always ensure a pointer points to valid memory before accessing fields.

Accessing unexported fields from another package. As covered earlier, the compiler rejects this. The error message mentions that the field is not exported or is undefined.

Assuming a copy is the original. When a struct is passed by value, assignments to its fields affect only the local copy. After the function returns, the original remains unchanged. If you intend to modify the caller’s struct, pass a pointer or use a pointer receiver.

Value receivers discard field changes:

Methods with a value receiver receive a copy of the struct. Modifying a field inside the method has no lasting effect. This is by far the most common behavior that surprises newcomers after they learn the dot operator.

Summary

The dot operator is the single, consistent mechanism for reaching into a struct. It works on values and pointers without a separate syntax, and it is guarded at compile time against misspelled or inaccessible fields. Field visibility across packages is driven entirely by the case of the first letter — a simple rule that also serves as Go’s access control.

The most important takeaway is that the compiler is your field‑access safety net: if the code builds, the field exists and you have permission to read or write it (barring a nil pointer at runtime). This guarantee allows you to refactor structs aggressively, knowing that any field access that breaks will be caught immediately rather than silently misbehaving.

What you have learned here — the dot operator, pointer auto‑dereference, and exported/unexported naming — directly supports everything from struct literals to methods and embedding. Next, the chapter on Struct Tags explains how to attach metadata to those same fields, which is essential for JSON encoding, database mapping, and validation libraries.