Last Updated: May 22, 2026
Go has one directory name that the compiler treats specially: internal. Any package whose import path contains an internal directory is only importable by code rooted at the parent of that directory. It's an API-boundary mechanism baked into the toolchain, used by every large Go codebase to keep implementation details private to a module.
internal RuleThe rule itself is short. A package at path .../X/internal/Y is importable only by code under .../X/. Everything else gets a compile error. The compiler enforces this by walking the importing file's path and the imported package's path and checking that the importer lives somewhere under the directory that contains the internal segment.
There's no annotation, no keyword, no build tag. The directory name is the rule. Rename internal to internals or private and the protection disappears.
Here's the smallest example that shows the mechanic at work. A module called shop with one internal package and one regular subpackage:
The module declared in go.mod is github.com/store-co/shop. Three files might want to import github.com/store-co/shop/internal/pricing:
| Importer | Path under the module? | Allowed? |
|---|---|---|
shop/main.go | yes, under shop/ | yes |
shop/cart/cart.go | yes, under shop/ | yes |
Another repo's main.go | no | no |
The parent of internal/ is the shop/ directory. Anything under shop/ can import the package. Anything outside it cannot.
shop/internal/pricing/pricing.go:
shop/main.go:
shop/cart/cart.go can import it for the same reason; it's under shop/:
Now consider a second module, github.com/other-co/web, that tries the same import:
go build refuses:
That message is the most important error to recognize in this chapter. It appears any time an import path contains internal and the importer lives outside the allowed scope. Once you've seen it, the rule clicks: internal/ is a fence around a directory subtree.
The precise rule, written out: an import of a package whose path contains .../internal/... is allowed only from packages whose own path is under the directory immediately containing that internal/ segment.
Read that carefully. The scope is anchored at the directory that contains the internal/ directory, not at the module root. That distinction matters when internal/ is nested.
Take this layout:
shop/billing/internal/tax is the import path. The directory containing internal/ is shop/billing/. So only code under shop/billing/ can import the package.
| Importer | Allowed? | Reason |
|---|---|---|
shop/billing/billing.go | yes | under shop/billing/ |
shop/billing/sub/x.go | yes | under shop/billing/ |
shop/cart/cart.go | no | under shop/, not shop/billing/ |
shop/main.go | no | under shop/, not shop/billing/ |
If cart.go tries to import github.com/store-co/shop/billing/internal/tax, the build fails with the same error:
Nested internal/ directories shrink the visibility scope. The top-level internal/ is "internal to the whole module." A deeper internal/ is "internal to this subtree." You can mix them.
shop/internal/storage is shared infrastructure for the whole module. shop/api/internal/middleware is private to the api subtree. The cart directory under shop/ can use storage but not middleware. This is how you carve up a large module into zones with different blast radii.
The diagram shows two internal directories in the same module. The top-level internal/ is reachable from anywhere inside shop/. The deeper api/internal/ is reachable only from code under api/. Code in cart/ is rejected when it reaches across into api/internal/, because cart/ is not under api/. And anything outside the module, like other-co/web/, is denied at both levels.
internal/ ExistsBefore internal/, Go had only one visibility mechanism: capitalize the identifier to export it, lowercase it to keep it package-private. That works inside a single package. It does not work for sharing code across packages within the same module without also sharing it with the world.
Consider a module that splits its implementation across half a dozen packages. The packages need to call each other, so the helpers they share have to be exported (capitalized). But "exported" in Go means "exported to anyone who imports the package," including users of the module you didn't intend to expose anything to. The module's surface area balloons, and every internal helper becomes part of the public API by accident.
internal/ solves this by giving the build system a second axis of visibility. The package keeps the same export rules (capitalize to share across packages), but the internal/ directory restricts which outside packages can see those exports.
A small example. Suppose internal/pricing exports a Discount function that two parts of the module need:
shop/cart/cart.go and shop/api/handler.go both import and call pricing.Discount. Without internal/, the function would also be callable from any module that imports github.com/store-co/shop/pricing. With internal/, the function is callable from inside the shop module and nowhere else. The export inside the module still works, but the external surface doesn't grow.
That's the design trade-off. Lowercase hides from other packages. internal/ hides from other modules. The two operate at different granularities, and you use both in real code.
| Visibility tool | Hides from | Scope |
|---|---|---|
Lowercase identifier (discount) | every other package, including same module | one package |
internal/ directory | every module except the parent of internal/ | one subtree within a module |
Capitalized identifier in non-internal package (Discount in shop/pricing) | nothing | the world |
internal/ vs Lowercasing the IdentifierThese two mechanisms get confused because they both sound like "make this private." They aren't interchangeable.
Lowercasing is per-package. It prevents any other package from referencing the name, even one in the same module:
Inside pricing, compute is fine. Outside pricing, nobody can call compute. Not cart/, not api/, not even pricing_test (an external test package). That's appropriate for a true package-internal helper, like a state machine transition or a parser routine that only makes sense inside one file.
internal/ is per-subtree. It controls who can import the package at all, while leaving the exports inside that package available to all permitted importers:
Code in shop/cart/cart.go can call pricing.Discount. Code in another module cannot import shop/internal/pricing at all, so it never sees the function regardless of capitalization. The protection is at the import boundary, not the identifier boundary.
When to use which:
internal/.internal/ and export the names that callers need.internal/ deeper. shop/api/internal/middleware rather than shop/internal/middleware.There's no runtime cost. internal/ is a build-time check. The compiler verifies the import is allowed and emits the same code as for any other package.
Most Go modules of any size end up with an internal/ directory, often containing several subpackages. A common shape for a small e-commerce service:
cmd/shop/main.go is the package main that produces the binary. api/ and cart/ are higher-level packages that compose the lower-level services. Everything under internal/ is the implementation of those services, available to any package under shop/ but invisible to the outside world.
A module that's also published as a library will sometimes also have a top-level non-internal/ package that holds the public API:
Now external code can import "github.com/store-co/shop" and use whatever shop.go exports. The internal/ packages back the public API but cannot be imported directly.
You'll also see the convention pkg/ for "intended for external import." That's a community pattern, not a compiler rule. The toolchain doesn't treat pkg/ specially the way it treats internal/. Some teams use pkg/ to signal intent, others put public packages directly at the module root. Either is fine.
The public package and cmd/shop both sit inside the module's directory tree, so both can import from internal/. External code can import the public package, which is the intended consumption point, but the same external code is blocked from reaching the internal/ packages directly.
internal/A useful pattern when a module has accumulated too many exported names: move the package into internal/ and then re-export only what callers outside the module actually need.
Suppose the shop module started with a flat layout:
Five exported names. Some are used by other modules; some are only used internally but had to be capitalized so that shop/cart and shop/api could call them. Over time, external callers started depending on functions you never meant to publish.
The refactor: move pricing/ to internal/pricing/, then create a thin top-level pricing/ (or a section of shop.go) that re-exports only the truly public names.
After:
shop/pricing/pricing.go:
External callers continue to import github.com/store-co/shop/pricing and call pricing.Discount and pricing.Tax. They lose access to Compute, RoundCents, and Validate, which now live in internal/pricing and are reachable only from inside the shop module. The internal subtree of shop (the cart, api, and other packages) can call all five via internal/pricing.
The wrapping layer can also rename, narrow, or wrap return types. If internal/pricing.Discount returns a custom error type, the public pricing.Discount can return a plain error or wrap it in a documented public error type. The internal layer is free to change without breaking the external API as long as the wrappers keep their shape.
This refactor is non-trivial in a real codebase but mechanical: move files, update imports, write wrappers, and run tests. The hard part is deciding what counts as public. The internal/ directory makes that decision binding instead of advisory.
Internal packages are still regular packages. They can have _test.go files, and they can have external test packages (package foo_test) that live alongside them:
The external test file lives in the same directory but uses the _test suffix on the package name. It can import github.com/store-co/shop/internal/pricing because the test file is itself under shop/, which is the allowed scope for the internal package. External test packages have always been a tool for exercising a package through its public API; the internal/ rule doesn't change that.
Same rule as before: the test file is under shop/, the internal package is under shop/, the import is allowed. If you tried to write a similar external test file from a different module that imported shop/internal/pricing, the build would fail with the same use of internal package... not allowed message.
internal/ RuleThe internal/ rule has nothing to do with publishing. It's enforced by the build system based on the directory structure of whatever code is being compiled, regardless of whether the module is on GitHub, in a private repo, in a local directory, or unpublished entirely.
A module declared as module mycompany.local/shop with no upstream repo and no go get ever run on it follows the same rule. Its internal/ directory is just as private to it as a published module's would be. This is occasionally surprising for teams who treat internal/ as a publishing convention; it's a build convention.
A small consequence: if you create a module locally and try to import it from another local module that's not part of the same module, the internal/ boundary applies. The two modules are separate by definition (they have separate go.mod files), and the rule checks paths, not git remotes. Use a workspace (covered in the previous chapter) when you need cross-module imports during development; just remember that workspaces don't bypass the internal/ rule. Code in module A still can't import B/internal/... regardless of whether they're stitched together by a go.work file.
When the rule is violated, the build fails before any code generation. The exact message:
This error surfaces from go build, go test, go vet, and from editors running gopls in the background. The location reported is the file with the bad import; the message identifies the internal package that was off-limits.
A few common ways to trigger it:
internal/ (workspaces or replace directives don't change this).internal/ packages and your code imports one of them by accident.internal/ but missed updating an importer that lives outside the new allowed scope.internal/ was added inside a subtree, and another subtree was relying on importing from it.The fix in every case is the same: either move the importer into the allowed scope, or move the imported package out of internal/. There's no flag to disable the check.