What does moving a Go package under an internal/ directory let its author refuse to promise?
answer
- two tools, different scales
- one hides names, the other hides packages
- importable only from the parent's subtree
- you may export freely in there
- enforced by the build, not by a README
basics
~10 sEverything in it. Code under internal/ is importable only from the subtree rooted at internal/'s parent, so your own packages share it while no other module can reach it. Nothing there is API.
solid answer
~50 sUnexported identifiers hide names inside one package; `internal/` hides whole packages from everyone outside your own subtree. Code under `example.com/migrate/internal/sqlfile` is importable by any package rooted at `example.com/migrate`, and by nobody else — an outside module that tries gets a build error, not a warning. That difference is what makes it useful for keeping a surface small: you can split a big package into several well-named packages, export identifiers freely between them so the code stays readable, and still promise outsiders nothing at all. It is the answer to "I need to share this helper between three of my packages, but I refuse to support it for strangers." The practical shape of a Go library is a thin top-level package holding the entry points other people call, with most of the real code sitting under `internal/`, free to be renamed, resplit or deleted without breaking anyone's build.
code
text · 7 linesmigrate/
go.mod // module example.com/migrate
migrate.go // package migrate - the entire promised API
internal/
sqlfile/ // importable only from example.com/migrate/...
plan/
exec/go deeper
Know that a directory named internal/ is special: packages inside it can be imported only from the surrounding subtree, and the build enforces that.
Be ready to contrast it with lower-case identifiers — one hides names within a package, the other hides whole packages from outside — and to say why libraries put most of their code there.
Show the design consequence: you can split code into many readable packages with exported names and still promise outsiders nothing, and you can restructure any of it later without coordination.
Own the placement as policy. Decide how deep internal/ sits, what promotion out of it requires, and how the team answers a partner asking to import something that lives behind that line.
## Two different tools Go gives you two ways to keep code out of your public API, and they work at different scales. - **Lower-case identifiers** hide *names* from other packages. Fine-grained, but it stops at the package boundary: anything your other packages need to use must be exported, and once exported it is visible to the whole world. - **An `internal/` directory** hides *whole packages* from other modules. Inside your own subtree the code can export as much as it likes; outside it, the package cannot even be imported. That second tool exists precisely because the first one forces a bad trade. Without `internal/`, splitting a package into `parse`, `plan` and `apply` for readability means exporting `parse.File`, `plan.Step` and friends — and every one of those becomes something a stranger can import and depend on. With `internal/`, the split costs you nothing in promises. ## What the boundary is A package whose import path contains an `internal/` element may be imported only by code in the directory tree rooted at `internal/`'s **parent**. So `example.com/migrate/internal/sqlfile` is importable from `example.com/migrate` and anything beneath it, and from nowhere else. A different module that writes that import path fails to build; the go command reports that use of an internal package is not allowed. It is a hard rule enforced at build time — not a lint, not a convention, and not something a build flag turns off. The placement is a dial, not a switch. Put `internal/` at the module root and the whole module shares it. Put it deeper — `example.com/migrate/cmd/internal/...` — and only that subtree can reach it. That is how a repository containing several commands keeps helpers private to one of them. ## What it lets you refuse Once code is under `internal/`, you have said, structurally rather than in prose: - **No compatibility promise.** Rename the package, change every signature, merge two packages, delete one — no external build breaks, because no external build was allowed to reference it. - **No documentation obligation.** The package is not part of the API surface anyone reads to use your library. - **No support obligation.** Nobody can file a bug about behaviour they were never able to call. - **No accidental dependence.** This matters most in a big organisation, where "please don't import that" in a README is not enforcement. The compiler is. And note what it does *not* cost you: readability. Inside `internal/`, use exported names, doc comments and clean package APIs exactly as you would anywhere else. Exporting a type from an internal package promises nothing, because the set of packages that can import it is one you control. ## What it costs - **It cuts both ways.** A sibling module you also own cannot import your internal packages either. If two of your modules genuinely need the same helper, one of them has to promise it, or the helper moves into a third module whose whole purpose is being depended on. - **It can hide the wrong thing.** If callers keep asking for something that lives under `internal/`, the answer is not to tell them no forever; it is to design a narrow exported entry point for the need they actually have, and keep the implementation internal. - **It is not a security boundary.** Anyone can copy the source, and the restriction is about imports, not about reading. It manages promises, not secrets. ## The shape it produces A well-kept Go library ends up looking like a very thin outer layer over a large private one: ``` migrate/ go.mod migrate.go // the whole promised API: a handful of names internal/ sqlfile/ // parsing the .sql files in a directory plan/ // ordering and checksums exec/ // applying statements ``` Someone reading `go doc example.com/migrate` sees the entry points and nothing else. Someone reading the repository sees well-factored packages. And you, six months later, can restructure everything under `internal/` on a Tuesday afternoon with no coordination. ## The habit When you add a package, ask who is allowed to call it. If the honest answer is "my own code, for now", it goes under `internal/`. Promoting it later — moving it out and exporting a deliberate API — is a decision you can make once a caller with a real use case exists. Going the other way, taking back something already exported, is the expensive direction.
- Should types inside a package under internal/ be exported, or kept lower-case too?Export them. Your own packages have to import them, and exporting inside `internal/` promises nothing to outsiders because they cannot import the package at all. Write it with the same care as a public API for readability; keep the freedom to change it.
- Where do you put internal/ if only one command in the repository should see a helper?Deeper. The restriction is relative to `internal/`'s parent directory, so `cmd/migratectl/internal/tui` is reachable only from `cmd/migratectl` and below. Root-level `internal/` shares with the whole module; a nested one narrows it.
- A team asks to import one of your internal packages. What do you do?Do not just move it out. Find the use case, then design a narrow exported entry point for that need and keep the implementation internal. Promoting the internal package wholesale converts every one of its symbols into a promise you did not intend to make.
It is a staff-only corridor: your own rooms all open onto it, and the door to the street simply does not exist.
saying these in an interview costs you the question
- Thinks internal/ is only a naming convention linters check
- Believes a build flag or vendoring can bypass the restriction
- Says identifiers inside internal/ must be lower-case
- Treats internal/ as a security or secrecy boundary
- Assumes another module you own can still import it