---
title: 'Package Layout and Visibility'
source: 'https://academia.sh/en/courses/go-fundamentals/package-layout-and-visibility'
course: 'Go Fundamentals'
language: en
updated: '2026-09-13T05:08:46+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Tests, by calling the compiler from within the program, whether an exported and an unexported name compile when called from another package'
    - "Shows that a struct's unexported field can be constructed from another package but not read"
    - 'Measures that two separate files of the same package share a lowercase name without any trouble, showing that the boundary of visibility is the package, not the file'
---

# Package Layout and Visibility

Whether an export is visible from one package to another is tied to the case of a single letter, shown by running the compiler from within a program across four trials: an uppercase function and a struct constructed only with its exported field compile; a lowercase function and an attempt to read a struct's unexported field from another package do not. The limiting measurement shows that two separate files of the same package share each other's lowercase name without any trouble at all — the boundary of visibility is not the file, it is the package.

The previous lesson's closing established that what a string is is stated by the language's
type system, but what it means is not. This lesson carries the same question outside the
source: who determines a name's visibility, the selection of a version, and the application of
a convention? The first stop is visibility. Go has no keyword to write for making an identifier
accessible to another package — there is no `public`, `private`, or similar declaration. The
only thing written is the name, and whether the **first letter of that name is uppercase or
lowercase** decides everything. This lesson's question is the same as the course's general
question: when you do not write this — that is, when you do not write the letter as uppercase —
what happens?

This question's place in the course's general framework is a little different. In the previous
topic, the unwritten thing was always a *value* — a variable given no initial value, a return
left unused. Here the unwritten thing is an **access decision**: not writing a name with an
uppercase letter. The answer, too, becomes dependent on position — the same lowercase name works
in a defined way inside its own package, but is rejected when called from outside the package.
While the rest of the course had a single class corresponding to a single outcome, in this lesson
the counterpart of the same unwrittenness changes **depending on where it is viewed from** — the
measurement, too, is built accordingly by calling the same name from two different positions.

## The Rule of Exporting: One Letter

**PT1.** Go carries no separate declaration syntax for visibility; whether an identifier is
visible from outside the package is determined solely by whether the first letter of the name is
uppercase (**exported**) or lowercase (**unexported**). **PT2.** This rule is not specific to a
single kind of identifier — a function, a type, a variable, a constant, and a struct's field all
pass through the same rule, looking at the same letter.

This resembles the design decision built in the previous lesson around the zero value: the
language **itself** determines the counterpart of something unwritten. The difference is in
direction: the zero value filled an unwritten *value*; the export rule fills an unwritten
*access declaration* — and when nothing at all is written besides the case of the letter, the
compiler always reads this as "not exported." The decision itself is not a choice, it is a
mandatory consequence of how the name is written.

The rationale for this design is not a mere shortcut. A separate visibility declaration (like
`public`/`private`) asks for a second decision for every identifier: choose the name, then choose
the visibility. Go removes this second decision by embedding it in the name itself — changing an
identifier's visibility means changing its name, and the two can never be separated from each
other. One consequence of this decision is that visibility is not a run-time check at all, it is
a syntax rule resolved entirely at compile time. The measurement below tests exactly this,
without performing a second run at all, only by calling the compiler — a visibility violation
appears not as an exception or an error value, but as the compilation itself stopping.

## The Area a Letter Covers

The same rule is applied silently to other kinds of identifiers built over the past three
lessons as well. In the previous topic, constants ordered with `iota` are exported if written in
uppercase and stay confined to their own package if written in lowercase — there is no separate
rule for the constant's **value**, the same rule applies only to its name. The name of a method
bound to a struct passes the same test too: even if its receiver (a concept built in the
Composite Types topic) is an exported type, if the method's own name starts with a lowercase
letter, that method can only be called from its own package. What a package offers to the
outside can therefore be read by looking at individual names: someone scanning a package's
source does not search for a separate declaration list to see which function, type, constant, or
method is accessible from outside — they only look at the first letters. This readability is not
a side effect, it is the rule's actual purpose: a package's public surface becomes information
that can be extracted from the source itself alone, with no other document to consult.

## Calling the Compiler with a Two-Package Setup

The procedure running since the course's first lesson expands here: the compiler is tested by
actually compiling the source, the error text is never read, only the compiled/did-not-compile
result is taken. That lesson's setup tested a single package; this lesson builds a **two-package**
setup, because the place where visibility is actually tested is the package boundary itself. The
routines below write a library package to a separate subdirectory, the calling side to the
module's root, and compile the two together.

```go
// tools/tools.go — shared routines that call the compiler from within the program
package tools

import (
	"os"
	"os/exec"
	"path/filepath"
)

// TwoPackagesCompile writes librarySource to the "trial/library" subpackage and
// mainSource to the temporary module root, then compiles them: visibility between the
// two packages is tested by a real compiler call. The error text is not read, only success.
func TwoPackagesCompile(librarySource, mainSource string) bool {
	dir, err := os.MkdirTemp("", "trial")
	if err != nil {
		return false
	}
	defer os.RemoveAll(dir)
	os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module trial\n\ngo 1.24\n"), 0o644)
	os.MkdirAll(filepath.Join(dir, "library"), 0o755)
	os.WriteFile(filepath.Join(dir, "library", "library.go"), []byte(librarySource), 0o644)
	os.WriteFile(filepath.Join(dir, "main.go"), []byte(mainSource), 0o644)
	return run(dir)
}

// SamePackageTwoFilesCompile writes two files of the same package (one defining, one
// reading) to a single temporary module's root directory and compiles them.
func SamePackageTwoFilesCompile(fileA, fileB string) bool {
	dir, err := os.MkdirTemp("", "trial")
	if err != nil {
		return false
	}
	defer os.RemoveAll(dir)
	os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module trial\n\ngo 1.24\n"), 0o644)
	os.WriteFile(filepath.Join(dir, "a.go"), []byte(fileA), 0o644)
	os.WriteFile(filepath.Join(dir, "b.go"), []byte(fileB), 0o644)
	return run(dir)
}

func run(dir string) bool {
	cmd := exec.Command("go", "build", "./...")
	cmd.Dir = dir
	cmd.Env = append(os.Environ(), "GOTOOLCHAIN=local")
	return cmd.Run() == nil
}
```

`TwoPackagesCompile` writes a library source to a subpackage named `library`, and the source
calling it to the module's root — the only link between the two is the `import` line. The
compiler (appearing in the code block only as the argument of the `exec.Command("go", "build",
...)` call) runs against a completely new, empty directory every single time — the remnant of a
previous trial never affects the next one. Every trial sets up its own temporary module, because
otherwise a file left by one trial could muddy the next trial's result; a fresh directory
guarantees that what is measured is only the two sources written at that moment.
`SamePackageTwoFilesCompile` asks a different question: what happens if two files belong to the
**same** package? Outside these two routines the `tools` package knows nothing at all — which
name is "library," which is "main," which letter is uppercase, all of that stays outside this
package, in the source texts the calling side constructs. The `GOTOOLCHAIN=local` environment
variable is given in both routines as well, so the inner compiler invocation does not try to
download its own toolchain from the network, only uses what is already installed.

## Five Trials

The measurement below writes the same function and the same struct in several forms and tests
them between two packages: one function name, two spellings of a struct literal, and one field
read.

```go
// main.go — the rule of exporting and the boundary of a package are not the boundary of a file
package main

import (
	"fmt"

	"lesson/tools"
)

func show(question string, compiled bool) {
	fmt.Printf("%-53s %v\n", question, compiled)
}

func main() {
	libraryUpper := "package library\n\nfunc Add(a, b int) int { return a + b }\n"
	mainUpper := "package main\n\nimport \"trial/library\"\n\nfunc main() { _ = library.Add(2, 3) }\n"
	show("Add (uppercase), called from another package", tools.TwoPackagesCompile(libraryUpper, mainUpper))

	libraryLower := "package library\n\nfunc add(a, b int) int { return a + b }\n"
	mainLower := "package main\n\nimport \"trial/library\"\n\nfunc main() { _ = library.add(2, 3) }\n"
	show("add (lowercase), called from another package", tools.TwoPackagesCompile(libraryLower, mainLower))

	libraryStruct := "package library\n\ntype Record struct {\n\tName   string\n\tsecret int\n}\n"
	mainStructNamed := "package main\n\nimport \"trial/library\"\n\nfunc main() { _ = library.Record{Name: \"x\"} }\n"
	show("Record{Name: \"x\"}, constructed with the named field", tools.TwoPackagesCompile(libraryStruct, mainStructNamed))

	mainStructPositional := "package main\n\nimport \"trial/library\"\n\nfunc main() { _ = library.Record{\"x\", 0} }\n"
	show("Record{\"x\", 0}, constructed positionally", tools.TwoPackagesCompile(libraryStruct, mainStructPositional))

	mainStructRead := "package main\n\nimport \"trial/library\"\n\nfunc main() { r := library.Record{Name: \"x\"}; _ = r.secret }\n"
	show("r.secret, read from another package", tools.TwoPackagesCompile(libraryStruct, mainStructRead))

	fmt.Println()
	fileA := "package library\n\nvar value = 42\n"
	fileB := "package library\n\nfunc Read() int { return value }\n"
	show("value (lowercase), read from another file of the same package", tools.SamePackageTwoFilesCompile(fileA, fileB))
}
```

```
Add (uppercase), called from another package          true
add (lowercase), called from another package          false
Record{Name: "x"}, constructed with the named field   true
Record{"x", 0}, constructed positionally              false
r.secret, read from another package                   false

value (lowercase), read from another file of the same package true
```

**PT3.** Two of five cross-package trials compile, three do not; what determines the split is,
every single time, the first letter of the name tried, or whether the spelling implicitly
touches that letter. **PT4.** `Record{Name: "x"}` — the literal constructed with the named field
— compiles: the `Record` struct having an unexported `secret` field does not prevent using the
struct itself or only its exported fields. **PT5.** The same struct fails to compile when
constructed positionally with `Record{"x", 0}`, that is, without writing the field name; this
spelling implicitly assigns a value to the second position, the `secret` field, and the compiler
rejects this as an assignment made from outside the package to an unexported field. **PT6.** The
`r.secret` read also fails to compile — the same instance of the same struct is rejected when a
different field is requested.

Reading the third and fourth lines together (the named literal, the positional literal) shows
that the export rule operates **per field** and is **sensitive to the spelling** used: even if a
struct is exported as a whole (`Record` is already exported here since it starts with an
uppercase letter), each of its fields is evaluated separately according to its own letter. The
named literal says it is writing only the `Name` field, and never touches `secret` at all — the
compiler accepts this and leaves `secret` its own zero value. The positional literal, on the
other hand, requires assigning a value to **every** field in order; the second position falls on
the `secret` field, and this, even though it is not written in the source text, is equivalent in
the compiler's eyes to writing to an unexported field from outside the package. Reading a field
of the record (`r.secret`) belongs to the same family: mentioning the name from outside the
package, whether reading or implicitly writing, is rejected either way. The compiler prints no
error text in any of the three cases — only compiled/did-not-compile is read — but the common
cause of all three is the same: mentioning, in a source text, a name not visible from outside the
package.

## The Boundary Is the Package Boundary, Not the File Boundary

**PT7.** Two separate source files of the same package, with no `import` at all between them,
directly share each other's unexported name: `a.go` defines the lowercase `value` variable,
`b.go` reads it directly, and both compile.

This is the lesson's limiting measurement. We said the visibility rule looks at the case of the
letter, but the answer to **what** this rule is applied against is not "the file." In the
measurement above, `a.go` and `b.go` look like two separate files, written in two separate
compilation units — but both carry the `package library` declaration, and this single line puts
both of them, in the compiler's eyes, into **one single unified namespace**. Because `value`
starts with a lowercase letter, it is never visible from outside the package at all, but it is
directly visible from any file of the same package, with no `import` written at all.

This distinction is often overlooked because in many languages the compilation unit is the file
itself, and the names a file sees are limited to that file's own declarations. In Go the
compilation unit is the **package**; how many files a package is split into is a decision made
purely for organizational convenience, and it never changes visibility at all. Splitting a
package into ten files versus keeping it in a single file gives **exactly the same** result in
terms of which names that package exports and does not export — the only difference is which
file the person reading the source looks at, not the set of names the compiler sees.

This has a practical consequence: splitting a package into multiple files is purely for
readability, and causes no loss of access at all. Real packages are usually split by topic for
this reason — one file carries the types, another the functions over those types, a third the
error values — and these files share each other's unexported names with no extra declaration
written at all. The measurement confirms this from the reverse direction too: there is no bridge
code at all carrying `value` between `a.go` and `b.go`, because no bridge is needed — the moment
the two share the `package library` line, they enter the same namespace. If the boundary of
visibility were the file, the cost of splitting a package into multiple files would be setting up
a new "internal API" every single time; in Go this cost does not exist at all, because the
boundary is already the package's boundary, not the file's.

This is not the same thing as scope (a concept built in the Programming Fundamentals course).
Scope determines *where* a name can be seen — at a block, function, or package level; the export
rule, by contrast, decides whether a name already visible at the package level can be seen from
**outside** that package. `value` belongs to the same package scope in every file of the package;
the export rule does not change this scope itself, it only determines whether that scope can
**cross** the package boundary.

## Summary

- Go has no separate declaration syntax for visibility; whether a name is exported is determined
  solely by whether its first letter is uppercase or lowercase.
- This rule works the same way for a function, a type, a variable, a constant, and a struct
  field; even if a struct is exported as a whole, each of its fields is evaluated separately
  according to its own letter.
- Two of five cross-package trials (an uppercase function, a struct literal constructing only
  the exported field) compile; three (a lowercase function, a positional literal's implicit
  hidden-field assignment, reading an unexported field) do not.
- The limiting measurement: the compilation unit is the package, not the file; two separate
  files of the same package share each other's lowercase name with no `import` at all.
- The compiler was again called from within the program in this lesson too, and only the
  compiled/did-not-compile result was read; the error text was never printed in any measurement.

## Next Step

In this lesson, where the boundary stands — in the package, not the file — was measured by
running it, and confirmed across six separate trials that visibility is resolved entirely at
compile time, just by looking at a single letter. But a package is not sufficient on its own; it
is often dependent on other packages, and the packages a module sees do not always sit inside
that same module — they come from a separate module and carry a **version**. Which line, in what
form, that version is written at in the source is most often never stated at all; sometimes it is
written directly, sometimes it is derived from a dependency's own dependency. The next lesson
takes up this question: when a dependency's version is not written, what does the module file
select in its place, by what rule is this selection made, and why is the selected version not
always the newest version available?
