Last Updated: May 17, 2026
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.
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:
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.func helper() is private to the package; func Helper() is part of the package's public API.Cost: Visibility is purely a compile-time check. There's no runtime overhead from exported vs unexported access. The compiler refuses to build code that touches unexported names from outside the package; that's the whole mechanism.
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.
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.
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:
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.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.
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 style | Use 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.
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.
Cost: This is a compile-time/reflection-time rule, not a runtime fee. The encoder skips unexported fields without inspecting them.
A few patterns that work well in real Go code, and a few that don't:
Export when:
Name on a Product is a good fit.ID or a Status.Keep unexported when:
sync.Mutex, a channel). Letting outside code touch these is a recipe for races.Don't:
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.
package see each other's unexported names freely; code in any other package sees only the exported ones.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.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.