AlgoMaster Logo

Exported vs Unexported Fields

Last Updated: May 17, 2026

9 min read

Go controls visibility with a single rule: the first letter of a name decides whether it's accessible from another package. Uppercase means exported (public). Lowercase means unexported (package-private). There's no public, private, or protected keyword. This lesson walks through what that rule actually means, how it shapes the boundary between packages, and how to use it to keep struct fields safe from accidental misuse.

The Capitalization Rule

Every identifier in Go (struct names, field names, function names, constants, types, methods) follows the same rule. If the name starts with an uppercase letter, code in other packages can see it. If it starts with a lowercase letter, only code inside the same package can see it.

Inside package main, all four fields are reachable. The struct itself lives in the same package as the code touching it, so the lowercase/uppercase distinction doesn't change anything here. The rule only kicks in once another package wants to read or write the fields.

A few details worth pinning down:

  • "Uppercase" means an upper-case Unicode letter, not just A through Z. Names starting with a non-letter character (digits, underscores) follow different rules and we'll skip them; in practice every Go identifier starts with a letter.
  • The rule applies to the first letter only. productName is unexported (lowercase p), ProductName is exported (uppercase P), and Productname is also exported. The casing of the rest of the name is a style choice, not a visibility rule.
  • This applies to types and functions too, not only struct fields. func helper() is private to the package; func Helper() is part of the package's public API.

What "Package" Actually Means

The exported/unexported boundary is the package, not the file. Two files inside the same package see each other's unexported names freely. Code in a different package cannot, even if both packages live in the same module or the same Git repository.

Imagine a project with this layout:

Both wallet.go and helpers.go declare package wallet. They share visibility. A lowercase function defined in wallet.go is callable from helpers.go without ceremony. But main.go lives in a different package (main), so it sees only the uppercase names that wallet exports.

The two files inside wallet are one unit. Anything declared in either file is visible to the other. The dotted arrows show that. The solid arrow from main into wallet only carries the exported names; the lowercase ones might as well not exist from main's point of view.

This is the part that surprises people coming from languages where visibility is per-file or per-class. In Go, the file boundary doesn't matter. Splitting a package across ten files is purely an organizational choice, not an encapsulation tool.

A Two-Package Example

The point of unexported fields is enforcing rules that the type's author cares about. The classic case is a money balance: you want to control how it's read and how it's changed, not let any caller set it to whatever they like.

Here's a small wallet package with one exported type and one unexported field.

The methods (New, Balance, Deposit, Withdraw) are the only way for code outside wallet to interact with a Wallet. For now, treat them as functions attached to a type. The detail that matters here is the field list: OwnerID is uppercase, so anything in main can read or write it. balance is lowercase, so it's locked to the wallet package.

Now the consumer:

The consumer can read w.OwnerID because it's exported. The consumer reads the balance through w.Balance() because the field itself is unexported. Every modification goes through Deposit or Withdraw, which means the validation rules (no negatives, no overdrafts) are enforced no matter who's calling.

Now look at what happens if main tries to reach the unexported field directly:

The compiler refuses:

The error message is interesting. It doesn't say "private" or "unexported". From main's point of view, the field literally does not exist. The compiler treats lowercase fields as if they aren't there at all when seen from another package. That's why a struct with an unexported field can still be passed around by an outside package; the outside code just can't name that field.

The green box on the left is the public surface of Wallet. The red box on the right is invisible from main. Code inside wallet/wallet.go and any other file in package wallet sees both boxes; outside code sees only the green one.

This is the encapsulation pattern: keep the data unexported, expose behavior through exported methods, validate inside the methods. The compiler enforces the boundary for free.

Why Encapsulate at All

The wallet example shows the mechanic; here's the motivation in plain terms. If balance were exported, any code could write:

That single line breaks the rule "balances are non-negative". The bug isn't in wallet. The bug is in the caller. But the caller is going to be 50 files across 5 services written by 8 different people over 3 years. Someone will eventually write that line, possibly with // TODO: fix later, and the production database will fill up with corrupted records.

Unexported fields take that option away at compile time. The caller can't reach balance, so the caller can't violate the invariant. The only paths into balance are Deposit and Withdraw, both of which validate before touching the field. The author of wallet doesn't have to trust the rest of the codebase; the compiler does the trusting for them.

This is the same shape as the rule "keep state private, expose behavior" in any language with classes. Go has no classes, but the package boundary plus the capitalization rule gives you the same protection.

There are three other reasons to keep fields unexported, all worth knowing:

  1. Change safety. If balance is exported, you can never rename or restructure it without breaking every caller in the world. If it's unexported, you can change it as much as you want (rename, split into two fields, store as *big.Int for precision) and no caller notices, as long as the public methods still work.
  2. Validation on write. Every assignment to a public field is a potential bug. With unexported fields plus setter methods, every change goes through a function you control. You can validate, log, or fire an event without changing the call site.
  3. Computed reads. A Balance() method can do work that a plain field can't: round to cents, convert currency, lazily fetch from a cache, return a copy of a slice instead of the live one. The caller writes w.Balance() either way.

If the only constructor is wallet.New(...), callers can't even create a Wallet with garbage data, let alone modify one afterward.

Mixing Exported and Unexported Fields

A struct often has some fields meant for outside readers and some meant only for internal bookkeeping. Both styles can live in the same struct.

Inside package main, all five fields are writable. Move Product into its own package and only SKU, Name, Price, and InStock() survive as the external surface. The two lowercase fields become invisible.

This split is common in real Go code:

Field styleUse it for
Exported (Name, Price)Data the caller is supposed to read or set, and where direct field access is fine.
Unexported (stockCount, lastUpdated)Internal state, anything that needs validation on write, or anything you might want to refactor later.

A rule of thumb: start with everything unexported. Promote a field to uppercase only when you have a concrete reason for outside callers to touch it directly. It's easier to widen a public API later than to narrow one.

Effect on Encoding and Reflection

The capitalization rule has a side effect that surprises people the first time they hit it: anything that uses reflection to walk a struct can only see the exported fields. The standard library's JSON encoder uses reflection, so it follows the same rule.

balance is gone from the output. The encoder isn't broken; it's correctly refusing to read a field it can't see from outside the Customer's package. The reverse is also true: a JSON document with a "balance":1200.50 field will not populate balance when unmarshaled, because the decoder can't set an unexported field either.

This is usually what you want. Sensitive or internal fields stay out of the wire format by default. For now, the relevant fact is the visibility check: if you want JSON to include a field, the field must be exported.

Practical Guidelines

A few patterns that work well in real Go code, and a few that don't:

Export when:

  • The field is meant for direct read/write by callers and has no invariants. A Name on a Product is a good fit.
  • The struct is a "plain data" type that's mostly a bag of values (configuration, request/response payloads, DTOs). Most fields should be exported.
  • The field is part of a contract that other packages rely on, like an ID or a Status.

Keep unexported when:

  • There's an invariant the field has to respect (non-negative balance, sorted order, valid state machine transitions).
  • The field is internal bookkeeping that callers shouldn't even know exists (caches, timestamps, locks, mutexes).
  • You might want to change the field's representation later. Anything internal can be refactored freely.
  • The field holds a reference to a goroutine-shared resource (a sync.Mutex, a channel). Letting outside code touch these is a recipe for races.

Don't:

  • Don't export fields just because "it's easier". Easier today is a backwards-compatibility headache tomorrow.
  • Don't write getters and setters for every unexported field by default. Go isn't Java. Add a method when there's a real reason: validation, computation, or hiding the field's representation. Plain pass-through getters are clutter.
  • Don't try to use case to communicate intent within a package. Lowercase doesn't mean "internal use only, please" between two files in the same package; it means "the compiler will stop other packages from seeing this". Within one package, both files see everything.

The rule of thumb most experienced Go developers settle on: when in doubt, keep it lowercase. You can always add a Setter() method later. You can't remove an exported field without breaking callers.

Here's one more concrete example that shows the difference. Two ways to design the same Cart type.

Option A is shorter but trusts every caller to keep Total and Count in sync with Items. The first time someone writes c.Items = append(c.Items, "tea") without bumping Total, the cart is broken and the bug shows up far away from the cause. Option B makes that mistake impossible: the only way to add an item is through Add, and Add keeps everything consistent.

The combination of unexported fields, exported constructors, and exported methods is how Go expresses what other languages call "encapsulation". One rule, one keyword (package), and the compiler does the policing.

Summary

  • The first letter of a name controls visibility across packages: uppercase is exported (public), lowercase is unexported (package-private). The rule applies to struct fields, functions, types, methods, and constants.
  • Visibility is per package, not per file. Two files declaring the same package see each other's unexported names freely; code in any other package sees only the exported ones.
  • From outside the package, an unexported field is treated as if it doesn't exist. The compile error is "no field or method X", not "X is private".
  • Keep fields unexported when they have invariants (non-negative balances, sorted order, internal state). Expose behavior through exported methods that validate on the way in. The compiler enforces the boundary; the type's author doesn't have to trust every caller.
  • encoding/json and other reflection-based code follow the same visibility rule. Unexported fields are skipped during marshal and unmarshal. If you want a field in the JSON output, it must start with an uppercase letter.
  • A good default is to start with everything lowercase and promote to uppercase only when you have a concrete reason. Widening a public API is cheap; narrowing one is a breaking change.

In the next lesson, Anonymous Structs, we'll look at one-off struct types that you can declare and use without naming them, plus where they show up in real Go code.