Exported vs Unexported Identifiers

Go has no public, private, or protected keywords. Instead, it uses a single, almost embarrassingly simple rule based on capitalization: any identifier — a function, type, variable, constant, struct field, or method — that starts with an uppercase letter is exported and visible to other packages; anything starting with a lowercase letter is unexported and stays private to the package that declares it. This one convention shapes how every Go package designs its public API, and understanding it deeply is essential once you start splitting code across packages and modules.

Overview: How Visibility Works in Go

In most languages, visibility is controlled with explicit keywords attached to each declaration. In Go, visibility is a property of the identifier’s name itself, evaluated at the package level. The rule is: if the first letter of an identifier is uppercase, the compiler treats it as exported. Otherwise, it is unexported. This applies uniformly to package-level functions, types, variables, and constants, as well as to struct fields and methods.

Crucially, this rule operates at the package boundary, not the file boundary. Two files that both declare package mathutil can freely call each other’s unexported functions even though they live in separate .go files — because from the compiler’s point of view, all files sharing the same package clause are really one compilation unit. Visibility only becomes relevant when code in a different package tries to reach in with a selector expression like mathutil.someHelper. If someHelper starts with a lowercase letter, that selector simply does not exist as far as the outside package is concerned — the compiler reports “undefined” or “unexported field or method,” and refuses to build.

This has a subtle but important consequence for struct fields: exporting a struct type does not automatically export its fields. type User struct { Name string; age int } declares an exported type User, but only Name is reachable from other packages; age is invisible outside the package that defines User, even though the struct itself is public. The same applies to methods: a method with a lowercase name cannot be called through the dot operator from another package, no matter how public the receiver type is.

Under the hood, this is enforced purely at compile time by the type checker inspecting identifier names — there is no runtime concept of “private” the way some languages use access modifiers with runtime enforcement. Notably, Go’s reflect package refuses to let you read or set unexported struct fields even from code inside the same package, which is exactly why libraries like encoding/json silently skip unexported fields rather than crash. There is no separate access-control table generated in the binary — by the time your program is compiled, unexported names have simply been checked and are unreachable from any other package’s perspective.

Because visibility is baked into the name, Go encourages a naming discipline: exported identifiers should have clear, well-documented names (with a doc comment starting with the identifier’s name, by convention, e.g. // Greet returns a friendly greeting.) because they form your package’s public contract. Unexported identifiers are implementation detail — you’re free to rename or delete them at any time without breaking anyone who imports your package.

Syntax

There is no special syntax to “mark” something exported — you just choose the case of the first letter when you declare it. The pattern below shows a typical package with both kinds of identifiers:

// File: mathutil/mathutil.go
package mathutil

// Add returns the sum of two integers. Exported: callers in other
// packages use it as mathutil.Add(...).
func Add(a, b int) int {
	return addHelper(a, b)
}

// addHelper is unexported: it can only be called from within package mathutil.
func addHelper(a, b int) int {
	return a + b
}
  • Add — capital A, so it is exported; another package that imports mathutil can call mathutil.Add(2, 3).
  • addHelper — lowercase a, so it is unexported; mathutil.addHelper(2, 3) from outside the package fails to compile with “undefined: mathutil.addHelper.”
  • The rule applies identically to type, const, var, struct fields, interface methods, and methods on a type.
  • There is no partial visibility — an identifier is either fully exported (reachable via package.Identifier) or fully unexported (reachable only inside its own package).

Examples

Example 1: Exported and unexported functions in the same package

Within a single package — including package main — unexported identifiers are completely ordinary; the “private” restriction only kicks in once another package tries to reach them. This example defines an unexported helper and calls it from an exported function, all inside package main:

package main

import "fmt"

// Greet is exported because it starts with an uppercase letter.
func Greet(name string) string {
	return greetInternal(name)
}

// greetInternal is unexported: only code inside this package can call it.
func greetInternal(name string) string {
	return "Hello, " + name + "!"
}

func main() {
	message := Greet("Gopher")
	fmt.Println(message)
}

Output:

Hello, Gopher!

main calls Greet, which in turn calls the unexported greetInternal. That inner call is perfectly legal because both functions live in the same package — visibility rules never block same-package access, no matter the casing.

Example 2: Encapsulation with an unexported field

The most common real-world use of unexported identifiers is protecting a type’s internal state so it can only change through controlled, exported methods. Here, Counter‘s field is unexported, so callers cannot set it directly — they must go through Increment:

package main

import "fmt"

type Counter struct {
	value int // unexported: only mutable through this package's exported methods
}

// NewCounter constructs a ready-to-use Counter.
func NewCounter() *Counter {
	return &Counter{}
}

// Increment increases the counter by one.
func (c *Counter) Increment() {
	c.value++
}

// Value returns the current count.
func (c *Counter) Value() int {
	return c.value
}

func main() {
	counter := NewCounter()
	counter.Increment()
	counter.Increment()
	counter.Increment()
	fmt.Println(counter.Value())
}

Output:

3

If Counter lived in its own package, code outside that package could not write counter.value = 100 — the field name is invisible to them. They are forced to use Increment, which means the package author fully controls how the value changes. This is Go’s answer to encapsulation: no private keyword, just a lowercase field name plus exported methods that mediate access.

Example 3: Unexported fields and reflection-based libraries

Standard-library packages that use reflection, like encoding/json, respect export rules too — and they respect them even when the struct is defined in the same package as the code calling json.Marshal. Reflection in Go can never read or write an unexported field from outside code, by design, so encoding/json silently skips unexported fields rather than including them:

package main

import (
	"encoding/json"
	"fmt"
)

type User struct {
	Name string
	age  int
}

func main() {
	u := User{Name: "Ava", age: 30}
	data, err := json.Marshal(u)
	if err != nil {
		fmt.Println("error:", err)
		return
	}
	fmt.Println(string(data))
}

Output:

{"Name":"Ava"}

Even though age is set to 30 in the struct literal, it never appears in the JSON output. encoding/json uses the reflect package to walk the struct’s fields, and reflect refuses to expose unexported fields to outside code as a language-level guarantee — not a convention encoding/json chose, but a rule enforced by the runtime itself. This is why every field you want serialized, logged, or otherwise inspected by a reflection-based library must be exported.

How It Works Step by Step

When you write otherpkg.Something, here is what the compiler does:

  1. It resolves otherpkg to the imported package’s compiled symbol table (built from that package’s exported declarations).
  2. It looks up Something in that table. Because unexported identifiers are excluded from a package’s export data entirely, a lowercase name simply is not present to find.
  3. If found (meaning it was exported), the compiler checks that the usage — call, field access, type reference — matches the exported declaration’s signature or type.
  4. If not found, compilation fails immediately with an error such as undefined: otherpkg.something or c.value undefined (cannot refer to unexported field or method value).
  5. This all happens purely at compile time. Nothing about visibility is checked while the program runs — by the time you have a binary, unreachable symbols were already rejected from other packages’ perspective.

Within the same package, none of this lookup-and-restrict process applies: the compiler treats every file under one package clause as a single namespace, so an unexported identifier declared in file_a.go is directly visible to file_b.go as long as both declare package foo.

Common Mistakes

Mistake 1: Trying to reach an unexported field from another package

It is easy to forget that a field you can see in your editor (because you can read the source) is not something you can touch from outside the defining package:

// Wrong — attempting to set an unexported field from another package
package main

import (
	"fmt"
	"example.com/app/config"
)

func main() {
	c := config.Settings{}
	c.apiKey = "secret" // compile error: c.apiKey undefined (cannot refer to unexported field or method apiKey)
	fmt.Println(c.apiKey)
}

The fix is for the config package to either export the field, or — more idiomatically — provide an exported constructor and accessor method that validate the input:

// Right — config package exposes controlled access
package config

type Settings struct {
	apiKey string
}

// NewSettings constructs a Settings with a validated API key.
func NewSettings(key string) Settings {
	return Settings{apiKey: key}
}

// APIKey returns the configured key.
func (s Settings) APIKey() string {
	return s.apiKey
}

Now callers use config.NewSettings("secret").APIKey() instead of poking at a field the package never intended to expose.

Mistake 2: An exported function that returns an unexported type

This compiles, but it produces an awkward, hard-to-use API: callers can receive a value of the type but cannot name the type anywhere in their own code (for a variable declaration, a struct field, a function parameter, and so on).

// Wrong — New returns a type callers cannot name
package config

type settings struct {
	APIKey string
}

func New() settings {
	return settings{APIKey: "abc123"}
}

A caller can write cfg := config.New() using type inference, but the moment they try var cfg config.settings or write a function that takes a config.settings parameter, the build fails with cannot refer to unexported name config.settings. The fix is simply to export the type so it becomes a first-class part of the package’s API:

// Right — the returned type is exported too
package config

type Settings struct {
	APIKey string
}

func New() Settings {
	return Settings{APIKey: "abc123"}
}

As a rule of thumb: if an exported function returns a type, or takes one as a parameter, that type should almost always be exported too — otherwise you have created a public API that outside code cannot fully use.

Best Practices

  • Keep your exported surface small and intentional — export only what callers genuinely need; everything else should stay lowercase so you can freely refactor it later.
  • Write a doc comment for every exported identifier, starting with its name (// Greet returns...), since these comments become your package’s documentation on pkg.go.dev.
  • Use unexported fields plus exported constructor functions and methods (as in the Counter and Settings examples above) to enforce invariants instead of trusting callers to set fields correctly.
  • If an exported function accepts or returns a type, export that type too — an unexported type in a public signature is a well-known API smell that linters will flag.
  • Remember that fields must be exported individually for reflection-based tools (encoding/json, encoding/xml, ORMs, template packages) to see them — exporting the struct type alone is not enough.
  • Don’t export something just to make testing easier from another package; instead, put white-box tests in the same package (a _test.go file with package foo, not package foo_test) so they can reach unexported identifiers directly.

Practice Exercises

  1. Write a package-style example (in a single package main file, using a comment to note where you’d split it into a real package) with an exported Stack type backed by an unexported slice field, exposing exported Push, Pop, and Len methods. Verify from main that you can use the stack but never touch the underlying slice field directly.
  2. Take the User struct from Example 3 and add a second unexported field, password string. Marshal a User value to JSON and confirm the output still only contains Name. Then add an exported Email field and confirm it appears too.
  3. Predict, without running it, what error message Go would give for fmt.println("hi") (lowercase p) — then explain in one sentence why the real, exported fmt.Println works but this doesn’t.

Summary

  • Go visibility has no keywords — an identifier starting with an uppercase letter is exported (public across packages); starting with lowercase, it is unexported (private to its package).
  • The rule applies to functions, types, variables, constants, struct fields, and methods alike.
  • Visibility is per-package, not per-file: all files sharing one package clause can see each other’s unexported identifiers.
  • Exporting a struct type does not export its fields — each field’s own casing decides its visibility.
  • Unexported fields are invisible even to reflection-based libraries like encoding/json, which is why they silently disappear from marshaled output.
  • Use unexported fields with exported constructor and accessor methods to build encapsulated, well-designed APIs; avoid exporting a function that returns or accepts an unexported type.