Private by Default: The One Rule That Keeps a Crate Coherent
Call a function from a sibling module and you get error[E0603]: function parse is private. Nothing marks it private — that is the default. In Rust every item (function, struct, field, variant, constant, module) is private to its defining module, visible from that module and everything nested inside it, and unreachable everywhere else until you say pub:
mod billing {
struct Invoice { // private: only code inside `billing` can name it
pub id: String, // field is public even though the struct is not...
total: u64, // ...this one is not
}
impl Invoice {
fn new(id: String) -> Invoice { // private method: constructors stay internal
Invoice { id, total: 0 }
}
pub fn total(&self) -> u64 { self.total } // public accessor: data stays private
}
mod taxes { // nested module: still private to `billing`
pub fn rate() -> f64 { 0.19 }
}
}
fn main() {
let i = billing::Invoice::new("x".into()); // ERROR: struct `Invoice` is private
}
The visibility rule is one sentence: an item may be named from anywhere its least visible enclosing item allows — and pub raises that ceiling to the crate root's reach. Concretely: a private item is reachable from its own module and all descendant modules; pub makes it reachable from wherever the parent module is reachable, pub(crate) from anywhere in the crate, pub(super) from the parent, and so on. pub is not a global "everyone" switch — it is a promise that the path through the containing modules also leads outward.
Notice the shape of the example: the struct is opaque, its data private, one accessor public. That is the idiomatic layering — modules are the boundary where you decide what is a contract and what is an implementation detail, and Rust enforces the line at compile time instead of by convention. A crate that exposes internal structs publicly cannot refactor them later without a breaking change; one that keeps them private can rewrite freely.
Granularity matters: visibility attaches to every item, not just modules. Struct fields are private individually (so a public struct can still hide its representation), enum variants inherit the enum's own visibility (you cannot mark one variant public and another private), methods are private individually — an impl block being public says nothing about its members — and a private struct can never appear in a public function's signature without tripping the private_interfaces lint. That last rule is the compiler telling you your API leaks a type users cannot name, which is a design error rather than a style preference: either publish the type, or return something users can construct themselves.
Two consequences that trip beginners:
- Privacy is about paths, not about data. A private field can still be read through a public accessor, and a public function can return a private type only if that type is nameable where it is used (otherwise you get
private type in public interfacewarnings/errors). Design the accessor surface deliberately. - Siblings are strangers. Two modules inside the same crate cannot see each other's private items unless one is nested inside the other. If
billingandauditneed to share a helper, either make itpub(crate)or move it to their common parent — and that negotiation is exactly where your crate's real structure appears.
Interview framing: "what does pub mean?" — it raises visibility to the reach of the containing module chain; the default is private, so the compiler enforces encapsulation, and pub(crate)/pub(super) let you stop short of the public API. Say "private to the module, public along the path" and you have answered the question precisely.
mod and the File Tree: Turning a Directory Into a Module
A module declared with mod name; expects a file, and the compiler walks the filesystem from the crate root to find it:
src/
├── main.rs // crate root of the binary: `mod config;` and `mod billing;`
├── config.rs // module `config`
└── billing/
├── mod.rs // module `billing` — contents of the folder
├── invoice.rs // billing::invoice
└── audit/
└── mod.rs // billing::audit
In main.rs:
mod config; // looks for src/config.rs (or src/config/mod.rs)
mod billing; // looks for src/billing/mod.rs
fn main() { config::load(); billing::invoice::charge(); }
The layout mirrors the module tree one-to-one: every mod adds a path segment, every file becomes a segment, and nesting on disk is nesting in the type system. The crate root is special — for a binary it is src/main.rs, for a library src/lib.rs — and it is the only file that does not need to be declared by anything; everything else is introduced by a mod line.
Two equivalent layouts exist for a folder module, and you should pick one and stay with it: the mod.rs style (billing/mod.rs + billing/invoice.rs) puts the folder's contents in a file named after the folder, while the inline style keeps billing.rs at the parent level and puts children in billing/ (billing.rs + billing/invoice.rs). The inline style avoids a mod.rs sitting in a directory with no sibling of the same name, and many teams prefer it because a module's "definition file" and its "children directory" live side by side. Both compile to the same module tree; neither is more correct.
Child files declare their own children with mod audit; inside billing/mod.rs, so the chain of declarations is the chain of paths. Missing files produce a clear error — file not found for module invoice`` — and a mismatch between folder and declaration is the most common first-week stumble when you move a file with your file explorer and forget the mod line.
Private-by-default still applies per file: everything in invoice.rs is scoped to module invoice, so a helper function there is invisible to mod.rs unless marked pub (or pub(crate)/pub(super)). That gives each file a clean internal scope where you can name things plainly — fn validate() in two different files never collides, because paths disambiguate them — which is the practical payoff of the whole system: files get to be private namespaces by default, and the public API is whatever you explicitly chain together.
A detail that connects layout to visibility: a path is only as public as its weakest segment. A binary crate with mod billing; in main.rs builds fine with everything private inside, but a library must write pub mod billing; in lib.rs before any external crate can even name mylib::billing::Invoice — pub on the function alone is not enough if the route to it is closed. The same applies at every level down, which is why cargo doc output is a direct readout of your mod/pub chain: if an item is missing from the docs, a pub is missing on the way there.
And one layout that saves repetition: src/lib.rs and src/main.rs are two separate crate roots in the same package, so a binary that wants the library's modules declares use mycrate::billing; (the library is automatically a dependency of its own binary) rather than re-declaring mod billing; and compiling the file twice. Unit tests live at the bottom of each file in #[cfg(test)] mod tests { use super::*; } — a child module of the module under test, which is exactly the "descendants can see private items" rule paying rent.
tests/ and examples/ are extra roots: tests/integration.rs is its own crate that can only see your library's pub items — a built-in check that your visibility choices expose what tests (and users) actually need.
pub(crate), pub(super), and Drawing the Real API Boundary
pub is a promise to the outside world, and most code inside a crate should never make it. The restricted visibility forms let you be precise about who may see an item without hand-writing paths everywhere:
pub mod api { // the module itself is part of the crate's public surface
pub fn handler() { telemetry::record(); } // ERROR: telemetry is not public
pub(crate) fn internal_hook() { ... } // visible anywhere in this crate, not outside
}
mod telemetry {
pub(crate) fn record() { ... } // crate-wide, hidden from users
pub(super) fn flush() { ... } // visible only to api's parent (the crate root)
pub(in crate::api) fn trace() { ... } // visible only inside the `api` subtree
}
The ladder, from narrow to wide:
| Form | Reachable from |
|---|---|
| (nothing) | the defining module and its descendants |
pub(super) |
the parent module |
pub(in crate::a::b) |
that path's subtree |
pub(crate) |
anywhere in the current crate |
pub |
wherever the containing modules are public — i.e. downstream users |
pub(in path) accepts only ancestor paths of the current module — you cannot grant visibility sideways into a cousin subtree, because the rule is deliberately "down and inward, never across." That constraint is what keeps the visibility graph a tree you can reason about.
The practical discipline: start every internal helper as private, promote to pub(crate) only when a second module genuinely needs it, and reserve plain pub for the items you would document. A crate where everything is pub has no API — it has a pile of implementation details with guaranteed compatibility obligations attached. Reviewers of Rust code look for exactly that smell: pub on a struct that exists only to be manipulated by sibling modules should be pub(crate); pub on an enum variant's payload field that callers only read through a method should not exist at all.
pub also pairs with a second tool: re-export. pub use crate::telemetry::record; inside api makes record reachable as api::record while its real definition stays where it belongs — the standard way to flatten a deep module tree into a friendly public surface:
// lib.rs
pub mod engine; // real structure stays detailed
pub use engine::Config; // users type `mylib::Config`, not `mylib::engine::config::Config`
Re-exports let you move internal code without breaking users, which is the same argument for privacy expressed as versioning: hidden things are refactorable, exported things are contracts.
Reviewers of Rust code look for exactly that smell: pub on a struct that exists only to be manipulated by sibling modules should be pub(crate); pub on an enum variant's payload field that callers only read through a method should not exist at all.
A worked before/after makes the discipline concrete:
// Before: everything public, nothing is a decision
pub struct Engine { pub state: State, pub hooks: Vec<Hook> }
impl Engine { pub fn tick(&mut self) { ... } pub fn debug_dump(&self) -> String { ... } }
// After: the contract is three items; the rest is internal
pub struct Engine { state: State, hooks: Vec<Hook> } // fields private
impl Engine {
pub fn tick(&mut self) { ... } // documented API
pub(crate) fn debug_dump(&self) -> String { ... } // for internal tooling only
}
Downstream users lost nothing they could sanely call (state was an internal invariant anyway) and the crate gained the freedom to change every demoted item in a patch release.
Interview framing: "pub(crate) vs pub" — reach differs (whole crate vs downstream users) and so does the commitment: the compiler enforces the first as an internal boundary, semver enforces the second as an external one. Follow with the ladder and pub use, and you have covered visibility, structure, and API evolution in one answer — the three things the next topic, paths, stitches together at every call site.
Quiz
1. A function is declared in `src/billing/invoice.rs` with no visibility modifier, and `src/main.rs` calls it. What happens?
2. What does `pub(crate)` mean, and how does it differ from `pub`?
3. You have `src/app.rs` containing `mod parser;`. Where does the compiler look for the parser module?
4. Why mark a widely-used internal helper `pub(crate)` instead of `pub`?
Flashcards
Question
What is Rust's default visibility, and what exactly does `pub` change?
Click to reveal answer
Answer
Private: an item is nameable only from its defining module and that module's descendants. `pub` raises visibility so the item is reachable from wherever its *containing modules* are reachable — the path outward must itself be public at every step.
Question
What does `pub(crate)` mean, and what does `pub(super)` mean?
Click to reveal answer
Answer
`pub(crate)` — visible anywhere inside the current crate, invisible to downstream users. `pub(super)` — visible only in the parent module. There is also `pub(in crate::a::b)` for an ancestor subtree; `pub(in ...)` may only name ancestors, never sideways.
Question
How does `mod name;` map to files, and what are the two folder layouts?
Click to reveal answer
Answer
The declaration resolves to `name.rs` or `name/mod.rs` under the *parent module's* directory: crate root (`src/main.rs` / `src/lib.rs`) loads `src/name.rs`, and a module at `src/app.rs` loads its children from `src/app/name.rs`. Modern layout: `app.rs` + `app/` for its children; older layout: `app/mod.rs` + `app/`. A missing file yields "file not found for module `name`".
Question
What does `pub use` do, and why is it the standard way to shape a public API?
Click to reveal answer
Answer
It re-exports an item under a new, shorter path (`pub use engine::Config` → `mylib::Config`) while the real definition stays in its internal module. Users get stable paths; internals stay movable without breaking semver.
Question
What can code in `tests/*.rs` see, and why does that matter?
Click to reveal answer
Answer
Each file under `tests/` is a separate crate that links against your library, so it sees only the `pub` (and re-exported) surface — exactly what downstream users get. That makes integration tests a free audit of whether your visibility choices expose the intended API.
Revision Notes
Key Takeaways
- 1. Private is the default and the boundary is the *module*, not the crate or the file — siblings cannot see each other's internals unless a common parent exposes them
- 2. `pub` does not mean "public to the world" in isolation; it means "reachable from wherever my container is reachable," so visibility compounds along the whole path
- 3. The restricted forms (`pub(super)`, `pub(in path)`, `pub(crate)`) keep internal refactors cheap: no semver commitment, no downstream coupling
- 4. `mod` declarations are the filesystem mapping — `x.rs` or `x/mod.rs` resolved relative to the declaring file — so moving files without updating `mod` lines is the classic breakage
- 5. `pub use` re-exports flatten a deep internal tree into a stable, short public path, letting internals move without breaking users
Interview Tips
- • "\"What does `pub` mean?\" — visibility compounds along the module chain; say \"private to the module, public along the path,\" then give the ladder ending at `pub(crate)` vs `pub`"
- • "\"Everything in my crate is pub — is that fine?\" — no: every `pub` is a semver contract; demote internals to `pub(crate)`/`pub(super)` and keep `pub` for the documented surface, using `pub use` for stable paths"
- • "\"How do modules map to files?\" — `mod x;` finds `x.rs` or `x/mod.rs` relative to the declaring file's directory; show both folder styles and the \"file not found for module\" error"
- • "\"What can my integration tests see?\" — only the library's `pub` surface, because `tests/*.rs` is a separate crate; mention it as a free audit of your API boundary"
Cheat Sheet
Modules and Visibility
Default: everything is private to its defining module and its descendants. pub raises the ceiling along the module chain.
Ladder:
| Written | Reach |
|---|---|
| (none) | this module + descendants |
pub(super) |
the parent module |
pub(in crate::a) |
that ancestor subtree (ancestors only, never sideways) |
pub(crate) |
the whole crate |
pub |
anywhere the containing modules are public — downstream too |
Files ↔ modules:
src/main.rs crate root (binary) src/lib.rs crate root (library)
src/config.rs mod config; src/billing/mod.rs mod billing;
src/app.rs + src/app/parser.rs mod app; → mod parser; (or app/mod.rs)
mod x; → x.rs or x/mod.rs under the parent module's directory (crate root → src/, module app → src/app/). Missing file = file not found for module.
API shaping:
pub mod engine; // structure
pub use engine::Config; // flatten: mylib::Config
pub(crate) fn helper() {} // internal, no semver commitment
#[cfg(test)] mod tests { ... } // private test module inside the file
Rule of thumb: private → pub(crate) when a second module needs it → pub only for documented, semver-tracked API. Integration tests in tests/ see exactly your pub surface.