The red squiggle that hides in plain sight
You just finished building a User struct in your models package. You import it into your handlers package to print the user's ID. The compiler stops you with a red squiggle. You stare at the code. The field is right there. You can see it in the source file. Go refuses to let you touch it.
This happens to everyone coming from languages where visibility is controlled by keywords like public or private. Go takes a different approach. The error message reads cannot refer to unexported field or method name. It feels like the compiler is lying to you. The field exists. The struct exists. The import works. The only difference is the case of the first letter.
Capitalization is the lock
Go uses capitalization to control visibility. If a name starts with a capital letter, it is exported. Any package that imports your package can see it. If a name starts with a lowercase letter, it is unexported. Only code inside the same package can access it.
There are no keywords. No public. No private. No protected. Just the case of the first letter. This rule applies to types, fields, methods, and variables. It keeps the API surface clean. You don't have to read a wall of modifiers to know what is safe to use. You look at the name. Capital means shared. Lowercase means local.
Public names start with a capital letter. Private names start lowercase. No keywords like public or private. This convention is baked into the language design. The compiler enforces it strictly.
The minimal case
Here is a struct with one exported field and one unexported field.
// package models
package models
// User holds user data.
type User struct {
// ID is exported so other packages can read it.
ID int
// name is unexported to hide implementation details.
name string
}
When you import this package, only ID is visible.
// package main
package main
import "myapp/models"
func main() {
// Create a user with the exported field.
u := models.User{ID: 1}
// This works because ID starts with a capital letter.
println(u.ID)
// This fails to compile.
// println(u.name)
}
The compiler rejects the access to name with cannot refer to unexported field or method name. The error is precise. It tells you exactly which symbol is hidden. You fix it by capitalizing the field if you want to share it, or by adding a method if you want to control access.
How the compiler enforces boundaries
The compiler checks visibility at compile time. It looks at the package boundary. When you import a package, the compiler only exposes the exported symbols. Unexported symbols are stripped from the interface. This means you cannot accidentally depend on internal details.
If you rename an unexported field, nothing breaks in other packages because they couldn't see it anyway. This encourages refactoring. You can change internal structure without touching callers. The compiler guarantees that unexported members stay unexported. You cannot bypass this rule with standard code.
Reflection can inspect unexported fields, but it cannot modify them from another package. If you use reflect.ValueOf(u).FieldByName("name"), the result has CanSet() return false. The boundary holds even at runtime. This design prevents accidental leaks of internal state.
Encapsulation without keywords
In Go, encapsulation often looks different. You might keep a field unexported and provide a method to access it. This is common when you need to validate data or compute a value. The method name usually matches the field name but starts with a capital letter. You don't write GetID. You write ID. The method call u.ID() returns the value. This reads naturally.
The receiver name is usually one or two letters matching the type. You write (u *User), not (this *User) or (self *User). This keeps the code concise and consistent with the standard library.
// package models
package models
// User manages user state.
type User struct {
// id is the internal identifier.
id int
}
// NewUser creates a user with a valid ID.
func NewUser(id int) *User {
// Validate input before creating the object.
if id <= 0 {
return nil
}
return &User{id: id}
}
// ID returns the user's identifier.
func (u *User) ID() int {
// Return the unexported field safely.
return u.id
}
Usage in another package looks like this.
// package main
package main
import "myapp/models"
func main() {
// Create a user through the constructor.
u := models.NewUser(101)
if u == nil {
return
}
// Access the ID via the method.
println(u.ID())
}
Name the method after the field. Drop the Get prefix. This pattern keeps the API clean while protecting internal state.
Testing the hidden parts
Testing unexported code requires a specific setup. Go allows test files to access unexported members if they are in the same package. You create a file named user_test.go with package models. This file can see name. You can write tests that verify internal logic.
If you need to test from an external package, you must expose the behavior through exported methods. This keeps the test focused on the public API. The standard library uses both approaches. Internal tests verify implementation details. External tests verify behavior.
// package models_test
package models
import "testing"
// TestUserInternal validates unexported behavior.
func TestUserInternal(t *testing.T) {
// Access unexported field directly in same-package test.
u := &User{name: "alice"}
if u.name != "alice" {
t.Error("name mismatch")
}
}
Test files in the same package see everything. External tests see only what is exported.
Embedded types and promotion
Embedded structs can trip you up. If you embed a type from another package, only the exported fields of that type become accessible. Unexported fields remain hidden behind the embedded type. You must access them through the embedded type's methods.
// package models
package models
// Admin embeds User to inherit behavior.
type Admin struct {
// User is embedded to promote exported fields.
User
// role is specific to admins.
role string
}
If User has an unexported field id, Admin cannot access id directly. Admin can only call User.ID() if the method exists. Promotion only works for exported members. This rule prevents accidental exposure of internal fields through embedding.
Embedded types promote only what is exported. Unexported fields stay behind the embedded type.
Interfaces and export rules
Interfaces interact with visibility. An interface can only contain exported methods if the interface itself is exported. If you define an exported interface, all its methods must be exported. Otherwise, external packages cannot implement the interface.
This constraint ensures that interfaces are usable across package boundaries. If an interface has an unexported method, only types in the same package can implement it. External packages cannot satisfy the interface. This limits the interface to internal use.
// package models
package models
// Reader is an exported interface.
type Reader interface {
// Read is exported so external types can implement it.
Read(p []byte) (n int, err error)
}
If Read were lowercase, Reader could not be exported. The compiler would reject the interface definition. This rule keeps interfaces consistent.
Interfaces export methods, not fields. Fields are part of structs, not interfaces.
Decision matrix
Use an exported field when the value is plain data and callers need direct access. Use an unexported field when the value represents internal state that callers should not modify. Use a method to access an unexported field when you need to validate input or compute the result. Use a constructor function when you must enforce invariants before the object exists. Use the same package declaration when multiple files need to share unexported helpers. Use an interface when you want to define behavior without exposing the underlying struct.