How to Use Struct Field Visibility (Exported vs Unexported)

Go struct fields starting with a capital letter are exported for external access, while lowercase fields remain private to the package.

The capital letter trap

You published a library that manages a database connection pool. You defined a Pool struct with a connections slice to hold active connections. You shipped the package. A user of your library decides to clear your connections slice directly because they thought it was a good way to reset the pool. Your pool breaks. The user's code compiles. The panic happens at runtime.

The problem isn't the user. The problem is that connections started with a capital letter. In Go, capitalization controls visibility. A capital letter makes a field exported, which means any package can read or write it. A lowercase letter makes a field unexported, which locks it inside the package. This rule applies to struct fields, methods, variables, and types. There are no public or private keywords. The first character is the only signal.

Capitalization is the rule

Go uses the first letter of an identifier to determine visibility. If the name starts with a capital letter, it is exported. If it starts with a lowercase letter, it is unexported. This is a deliberate design choice. The language creators wanted to minimize keywords and reduce cognitive load. Capitalization is enough. It forces you to think about the name. If you capitalize it, you are declaring that the world can touch it.

Think of a restaurant. The menu is exported. Customers see it and order from it. The recipe book is unexported. Only the kitchen staff sees it. If a customer could rewrite the recipe book, the kitchen would collapse. Struct fields work the same way. Exported fields are the menu. Unexported fields are the recipe book.

The compiler enforces this boundary at compile time. There is no runtime overhead. The visibility check happens when you build the code. If another package tries to access an unexported field, the build fails. The code simply doesn't exist in the compiled output for other packages.

Minimal example

Here's the simplest struct showing the difference between exported and unexported fields.

package main

import "fmt"

// User holds basic profile data.
type User struct {
	// Name is exported because it starts with a capital letter.
	Name string
	// email is unexported because it starts with a lowercase letter.
	email string
}

func main() {
	u := User{
		Name:  "Alice",
		email: "alice@example.com",
	}
	// Accessing exported field works fine.
	fmt.Println(u.Name)
	// Accessing unexported field works inside the same package.
	fmt.Println(u.email)
}

The User struct has two fields. Name starts with a capital N, so it is exported. email starts with a lowercase e, so it is unexported. Inside main, which is in the main package, you can access both fields. The code compiles and runs.

If you move this struct to a package called userpkg and try to access u.email from main, the compiler rejects the program with u.email undefined (cannot refer to unexported field or method email). The error is clear. The field exists, but you are not allowed to see it.

Capitalization is the lock. The compiler is the guard.

Why no keywords?

Many languages use keywords like public, private, protected, or internal. Go dropped them. The design philosophy favors simplicity. Fewer keywords mean less syntax to memorize. Capitalization is a signal that is hard to miss. It also ties visibility to naming. If you name a field email, you are saying it is internal. If you name it Email, you are saying it is part of the public API.

This choice has a side effect. You cannot accidentally make something public by forgetting a keyword. In a language with keywords, you might write email string and assume it's private, but if the default is public, you've leaked data. In Go, the default is private. Lowercase is the safe default. You must actively choose to export by capitalizing. This reduces accidental leaks.

The community accepts this convention. You will never see public or private in Go code. If you see a struct field starting with a capital letter, it is exported. If it starts with lowercase, it is unexported. That's the whole rule.

Realistic example: Config with methods

Real code usually hides implementation details. You expose what users need and hide the rest. Methods bridge the gap. You can provide getters and setters to control access to unexported fields. This lets you enforce invariants, compute values, or add logging without exposing the raw field.

Here's a Config struct that hides the port and tracks when it was loaded.

package config

import "time"

// Config holds application settings.
type Config struct {
	// Host is the server address.
	Host string
	// port is internal because it defaults to 8080.
	port int
	// lastUpdated tracks when the config was loaded.
	lastUpdated time.Time
}

// NewConfig creates a Config with defaults.
func NewConfig(host string) *Config {
	return &Config{
		Host: host,
		// port is set to default inside the package.
		port: 8080,
		// lastUpdated is set to now.
		lastUpdated: time.Now(),
	}
}

// GetPort returns the internal port number.
func (c *Config) GetPort() int {
	return c.port
}

// SetPort updates the port with validation.
func (c *Config) SetPort(p int) {
	if p < 1 || p > 65535 {
		// Reject invalid ports.
		return
	}
	c.port = p
}

The Config struct has three fields. Host is exported. port and lastUpdated are unexported. The NewConfig function creates a Config with default values. It sets port to 8080 and lastUpdated to the current time. Callers get a *Config pointer. They can read Host and call GetPort(). They cannot touch port directly. The SetPort method validates the port range before updating the field.

Notice the receiver name. The method uses (c *Config). The convention is to use a short name, usually one or two letters matching the type. (this *Config) or (self *Config) is not idiomatic. Go developers expect (c *Config), (u *User), or (s *Server).

The "accept interfaces, return structs" mantra applies here. NewConfig returns a *Config. The caller gets the struct. They can read exported fields. They cannot mutate unexported fields. This is safe. You are returning a concrete type with controlled internals.

Hide the plumbing. Expose the knobs.

Pitfalls and silent failures

Visibility rules are strict, but they interact with other features in ways that can trip you up. The compiler catches direct access errors, but some issues only appear at runtime.

JSON marshaling ignores unexported fields

The encoding/json package only touches exported fields. If you have an unexported field, the encoder skips it. You get no error. The field just vanishes from the output. This is a common surprise for beginners.

package main

import (
	"encoding/json"
	"fmt"
)

// User has a mix of exported and unexported fields.
type User struct {
	Name  string `json:"name"`
	email string `json:"email"`
}

func main() {
	u := User{Name: "Alice", email: "alice@example.com"}
	// Marshal converts the struct to JSON bytes.
	data, err := json.Marshal(u)
	if err != nil {
		panic(err)
	}
	// The output only contains the exported field.
	fmt.Println(string(data))
}

The output is {"name":"Alice"}. The email field is missing. The json:"email" tag is ignored because the field is unexported. The encoder skips it by design. You should not leak internal state via JSON. If you need to include email in the JSON, you must export the field or use a custom MarshalJSON method.

JSON skips unexported fields. Check your marshaling output.

Reflection panics on unexported fields

The reflect package can inspect unexported fields, but you cannot modify them. If you try to set an unexported field via reflection, the program panics.

package main

import (
	"fmt"
	"reflect"
)

type Secret struct {
	value string
}

func main() {
	s := Secret{value: "hidden"}
	v := reflect.ValueOf(s)
	// Field returns the reflection value of the first field.
	f := v.Field(0)
	// CanSet checks if the field can be modified.
	fmt.Println(f.CanSet())
	// Set panics because the field is unexported.
	f.SetString("new")
}

The output is false followed by a panic: panic: reflect: Set of unexported field. The CanSet method returns false for unexported fields. Calling SetString triggers the panic. This protects the language's safety guarantees. You cannot bypass visibility with reflection.

Reflection respects visibility. Unexported fields are read-only.

Embedding and promoted fields

When you embed a struct, its fields are promoted to the outer struct. Visibility rules still apply. If you embed an unexported struct, all its fields remain unexported. If you embed an exported struct, its exported fields become accessible, but its unexported fields stay hidden.

package main

import "fmt"

// Base has an exported and an unexported field.
type Base struct {
	ID    int
	secret string
}

// Wrapper embeds Base.
type Wrapper struct {
	Base
}

func main() {
	w := Wrapper{
		Base: Base{
			ID:     1,
			secret: "shhh",
		},
	}
	// Accessing promoted exported field works.
	fmt.Println(w.ID)
	// Accessing promoted unexported field fails.
	fmt.Println(w.secret)
}

The compiler rejects w.secret with w.secret undefined (cannot refer to unexported field or method secret). The embedding promotes ID but not secret. The visibility of the embedded fields follows the same rules as direct fields.

Embedding doesn't change visibility. Unexported fields stay unexported.

Testing unexported things

Tests live in the same package as the code they test. This means tests can access unexported fields, functions, and variables. This is a convention. You can write thorough tests without exposing internals. The _test.go files are part of the package. They see everything.

package mypkg

import "testing"

// secret is hidden from other packages.
var secret = "shhh"

// TestSecretAccess proves tests can see unexported variables.
func TestSecretAccess(t *testing.T) {
	// Accessing secret works because the test is in the same package.
	if secret != "shhh" {
		t.Fatal("secret changed")
	}
}

The test accesses secret directly. This works because mypkg_test.go is in the mypkg package. You can test internal logic without leaking it. This is a powerful feature. It lets you keep the public API clean while still verifying internal behavior.

Tests live in the package. They see everything.

Decision matrix

Visibility choices shape your API. Use the right level of exposure for each field.

Use an exported field when the value is part of the public API and callers need to read or write it directly.

Use an unexported field when the value is internal state, a security secret, or an implementation detail that callers should not touch.

Use a getter method when you need to compute a value or validate access before returning it.

Use a setter method when you need to enforce invariants or side effects when a value changes.

Use an unexported struct entirely when the type is only used within the package and never returned to callers.

Default to unexported. Export only what you must.

Where to go next