Minimize Visibility

Learn how to control access to your Rust code with visibility modifiers and why keeping items private by default leads to more maintainable and flexible APIs

When you build a Rust library or application, one of the most impactful decisions you make is what to expose and what to hide. Getting this right early prevents breaking changes, reduces coupling, and keeps your options open for future refactoring. The Rust module system provides precise tools for controlling visibility, and using them well is not just a technical detail — it is a habit that separates brittle codebases from ones that age gracefully.

Why Minimize Visibility

Every item you mark pub becomes part of a contract with the code that consumes it. Once something is public, changing its signature, behavior, or even its existence risks breaking downstream code. The fewer items that carry that burden, the freer you are to improve internals later.

This principle applies at every level: to modules, functions, structs, enum variants, and even individual fields. Keeping visibility as narrow as possible means you give yourself permission to iterate without fear.

The Principle of Least Privilege:

Visibility is not about security — it is about keeping the surface area of your API small so that future changes do not cause cascading breakage. Treat every pub as a commitment you intend to keep.

In practical terms, minimizing visibility:

  • Protects internal invariants by preventing external code from touching private data directly.
  • Reduces the number of things you must document and support as stable.
  • Allows you to restructure modules, rename helpers, or swap algorithms without a major version bump.
  • Makes it obvious which parts of the crate form the intended public interface.

Rust gives you a default that already leans in the right direction: everything starts private.

Default Privacy — Everything Is Private

In Rust, unless you explicitly add a visibility modifier, an item is private to the module where it is defined. This rule applies uniformly to functions, structs, enums, traits, constants, type aliases, and even nested modules.

mod database {
    // Private — only accessible inside `database` and its submodules.
    fn connect_to_db() -> String {
        "connected".to_string()
    }
    // Also private by default.
    struct Connection {
        host: String,
    }
    pub fn get_connection() -> Connection {
        Connection {
            host: connect_to_db(),
        }
    }
}
fn main() {
    // Error: `connect_to_db` is private
    // database::connect_to_db();
}

There are only two exceptions to the everything-is-private default:

  • Enum variants in a pub enum become public automatically, because exposing an enum without its variants would make it unusable.
  • Methods of a pub trait become public automatically for the same reason — a trait with private methods would not make sense as a public abstraction.

Even in those cases, the items that contain them (the enum or the trait itself) must be marked pub for the effect to matter.

A private item can be accessed from the module it lives in and from any descendant modules. That is why unit tests, which typically sit inside a mod test submodule, can exercise private implementation details without any extra ceremony.

The pub Keyword and Its Scope Variants

Adding pub makes an item visible beyond its immediate module. The basic pub gives unrestricted visibility — any code that can see the parent module can see the item. Rust also offers scoped forms of pub that limit how far that visibility reaches. Understanding each variant helps you pick the smallest appropriate scope.

pub — Unrestricted Visibility

A bare pub modifier says: "I am accessible to anything that can reach my module." This is the default choice for items that belong to your crate's public API.

pub mod api {
    pub fn list_users() -> Vec<String> {
        vec!["alice".into(), "bob".into()]
    }
}

External crates that depend on your library can call your_crate::api::list_users. Note that pub on the module is required too; if api were private, list_users would remain hidden regardless of its own pub.

pub(crate) — Crate-Wide Visibility

This is the most useful scoped variant. pub(crate) makes an item accessible anywhere inside the current crate but not to external consumers.

pub mod engine {
    // Public API.
    pub fn run_query(sql: &str) -> Vec<String> {
        let plan = build_query_plan(sql);
        execute_plan(plan)
    }
    // Crate-internal helper — not part of the public API.
    pub(crate) fn build_query_plan(sql: &str) -> QueryPlan {
        // parsing and optimization logic
        QueryPlan { steps: vec![] }
    }
    fn execute_plan(plan: QueryPlan) -> Vec<String> {
        // ...
        vec![]
    }
    pub(crate) struct QueryPlan {
        steps: Vec<String>,
    }
}

Here build_query_plan and QueryPlan are shared across the crate but hidden from anyone linking against it. This is ideal for internal utilities, shared data structures, and helper functions that multiple modules need but that do not make sense as a stable public contract.

pub(super) — Visibility to the Parent Module

pub(super) restricts visibility to the parent module and its submodules. It is particularly useful when a deep module hierarchy needs to selectively open some items to the level above without exposing them to the entire crate.

mod network {
    mod tcp {
        pub(super) fn handshake() {
            // visible to `network` and any sibling modules of `tcp`
        }
    }
    mod http {
        pub fn send_request() {
            // Can call `super::tcp::handshake()`
            super::tcp::handshake();
        }
    }
}

Because a plain mod my_module is effectively visible to its parent by default, pub(super) is most relevant on items nested deeper.

pub(in path) — Visibility to a Specific Ancestor Module

This form allows you to name an ancestor module exactly. The path must consist of module identifiers that are ancestors of the current module, starting with crate, self, or super.

pub mod outer {
    pub mod inner {
        // Visible to everything inside `outer`.
        pub(in crate::outer) fn helper() {}
    }
    pub fn caller() {
        inner::helper(); // allowed
    }
}
fn outside() {
    // Error: `helper` is not visible here
    // outer::inner::helper();
}

This is the most surgical visibility tool, but it is also the least commonly needed. It becomes valuable when reorganizing code into helper submodules without wanting to grant full pub(crate) access.

The path must be an actual ancestor module:

The path you provide to pub(in ...) must resolve to a module that directly encloses the item, not one brought in via use. The compiler enforces this; if the path does not reference a genuine ancestor module, compilation fails.

pub(self) — Equivalent to Private

pub(self) means the same as writing nothing at all. It is sometimes seen in macro-generated code where a uniform pub(...) form is convenient, but in hand-written code it adds no value.

How Visibility Chains Work in Practice

A single pub on an item is not enough by itself. The entire path from the crate root down to that item must be visible. If any module in that chain is private, the item remains inaccessible regardless of its own visibility.

pub mod frontend {
    // Private module — invisible outside `frontend`.
    mod components {
        pub fn render_button() {}
    }
}
// Error: module `components` is private.
// frontend::components::render_button();

This is why re-exports with pub use are so important for hiding internal module structures. You can keep deeply nested helper modules private while re-exporting the items you want to be part of the public API at a higher, public module.

Using pub use to Hide Internal Modules

A direct consequence of the chain rule is that you can organize code into many small private modules and then lift the key pieces into the public interface with pub use.

// Private module — no external crate sees this name.
mod storage {
    mod file {
        pub fn load(path: &str) -> Vec<u8> { vec![] }
    }
    mod memory {
        pub fn load() -> Vec<u8> { vec![] }
    }
    // Re-export only the items you want to expose.
    pub use file::load as load_from_file;
    pub use memory::load as load_from_memory;
}
// External users see `storage::load_from_file` and `storage::load_from_memory`,
// but never know about `file` or `memory`.

This pattern is the backbone of a clean library interface. It allows you to change the internal module layout at any time without affecting downstream code, as long as the re-exported names stay the same.

Re-exports are your primary tool for API hygiene:

Keep implementation details in private modules and expose only what users need through pub use. This is not an advanced technique — it is the standard way to build a Rust library.

Re-exports also let you consolidate items from many private modules into a single public module, creating a flat and discoverable API without exposing the underlying directory structure.

Practical Guidelines for Choosing Visibility

A reliable workflow for deciding visibility is to start with the most restrictive option and loosen only when a real need arises.

  1. Begin with everything private. New functions, structs, and modules stay invisible until you have a reason to share them.
  2. When a sibling module needs access, use pub(super) or pub(crate). pub(crate) is the workhorse for code that is internal to the crate but shared across modules.
  3. Only use bare pub for the deliberate public API. Ask yourself: "If I change this, will it break a downstream user?" If yes, it deserves pub. If no, it stays more restricted.
  4. Struct fields should remain private by default. Expose them through public methods or constructors so you can validate values and change representations later.
  5. Use re-exports to keep internal module trees private. The actual module hierarchy should be an implementation detail.

These guidelines reflect what the Rust API checklist recommends: structs should have private fields, and everything that does not need to be part of the stable interface should be hidden.

A pragmatic way to think about visibility is to let each item only worry about its immediate parent. Mark something pub to export it one level up, and let the parent module decide whether to re-export it further. This keeps decisions local and composable.

mod scheduler {
    // Only responsible for exporting to its parent.
    pub struct Task;
    fn internal_heuristic() {
        // uses helper from parent
        super::utils::calculate_cost();
    }
}
mod utils {
    pub fn calculate_cost() {}
}
// The parent decides what to re-export.
pub use scheduler::Task;

Both styles produce valid Rust code, and the compiler does not enforce one over the other. The important thing is to make a conscious choice and to let the visibility annotations accurately reflect the intended scope.

Common Mistakes and Misconceptions

Visibility rules are simple in isolation but can interact in ways that trip up developers. Here are the most frequent pitfalls.

  • Assuming pub struct makes fields public. Struct fields follow their own privacy and remain private unless individually marked pub. This is a deliberate design that forces you to think about field-level access.
pub struct Config {
    pub timeout: u64,   // accessible outside
    retries: u32,       // private — only accessible from the same module
}
  • Forgetting that a private module hides everything inside it. If a module is not pub, even pub(crate) items inside are unreachable by sibling modules. The module's own visibility acts as a gate.
mod internal {
    pub(crate) fn useful() {}
}
// Error: module `internal` is private, so `useful` is invisible.
// internal::useful();
  • Thinking pub(in path) can target any module. The path must be an ancestor module. You cannot use it to grant access to a sibling or an unrelated part of the tree.
  • Confusing pub use with making a module public. pub use re-exports an item; it does not change the original module's visibility. If the original module is private, external code still cannot name it directly — but it can use the re-exported path.

Broken visibility chains cause silent dead code warnings:

If a pub(crate) function lives in a private module and nothing else in that module calls it, the compiler emits a dead_code warning. That warning often signals a deeper visibility design flaw — the function is unreachable because the module gate is closed.

  • Overusing bare pub for internal helpers. It is tempting to mark everything pub to quickly make code compile, but this permanently locks you into exposing implementation details. Use pub(crate) or re-exports instead.

Summary

Minimizing visibility is not about being stingy — it is about maintaining control over the promises your code makes. Rust's default of full privacy, paired with granular scoped pub forms, gives you the tools to build APIs that are both ergonomic for users and safe for you to evolve.

The pattern that consistently produces clean Rust libraries is:

  • Keep everything private until you have a specific reason to share it.
  • Use pub(crate) for cross-module internals.
  • Reserve bare pub for the stable public surface.
  • Hide the module structure behind pub use re-exports.