You renamed the function and broke the world
You just published v0.1.0 of your library. It works. A user depends on it. A week later, you realize parse_input is a terrible name. It should be parse_data. You rename it, push the change, and bump the version.
The user tries to update. Their build fails. The compiler screams about an unresolved function. They can't compile. They're stuck on your old version, or they have to rewrite their code immediately. You didn't mean to break them, but you did.
This is the API stability problem. You want to improve your code, but you also want users to trust that updating won't destroy their project. Rust gives you tools to manage this tension. You don't just change things and hope for the best. You use versioning, attributes, and visibility modifiers to control the contract between your crate and the world.
The contract: Semver and the compiler
Rust relies on Semantic Versioning, or Semver. The version number Major.Minor.Patch isn't just a label. It's a promise about what changed.
- Major version bumps when you make a breaking change. Code written for
1.xwon't compile against2.0. - Minor version bumps when you add features without breaking existing code.
1.1is safe to update to from1.0. - Patch version bumps for bug fixes.
1.0.1is safe to update to from1.0.0.
The compiler enforces this contract. If you change a public function signature, the compiler rejects code that calls it. That rejection is a feature. It forces you to acknowledge the break. You can't accidentally introduce a breaking change and ship it as a minor update. The tooling catches you.
Think of your crate like a public park. The paths are your API. If you pave over a path to build a fountain, you can't just do it at 3 AM. You put up a detour sign. You tell people the old path closes next month. You build the new path first. Then you close the old one. Rust gives you the detour signs.
The warning sign: #[deprecated]
The primary tool for graceful evolution is the #[deprecated] attribute. It marks an item as obsolete without removing it. The compiler emits a warning whenever code uses the item, but the code still compiles. This gives users time to migrate.
/// Calculates the total cost.
///
/// Use `calculate_total` instead.
#[deprecated(since = "1.2.0", note = "Use `calculate_total` for better accuracy")]
pub fn get_total() -> u32 {
calculate_total()
}
/// The new, accurate way to calculate totals.
pub fn calculate_total() -> u32 {
42
}
The since field tells users when the deprecation started. The note field gives the migration path. When a user calls get_total, they see a warning pointing to calculate_total. They can fix it at their own pace. You keep the old function around for a few minor versions, then remove it in the next major release.
Convention aside: Always include an actionable note. "This is deprecated" is useless. "Use calculate_total instead" is helpful. The community expects the since version to match the version where you added the attribute. Don't lie about the version.
Deprecation is a promise, not a threat. If you deprecate something, you must provide a replacement or a clear reason why the functionality is gone.
Structs and the expansion problem
Functions are easy to deprecate. Structs are harder. Adding a field to a public struct is a breaking change. If users initialize the struct with a literal like User { name: "Alice" }, adding an age field breaks their code. The compiler emits E0063 (missing field in struct initializer).
You can't just deprecate a field and add a new one. You need a way to reserve the right to expand the struct later. That's what #[non_exhaustive] does.
/// A user in the system.
///
/// This struct is non-exhaustive to allow adding fields in future minor versions.
#[non_exhaustive]
pub struct User {
pub name: String,
}
impl User {
/// Creates a new user.
pub fn new(name: &str) -> Self {
User { name: name.to_string() }
}
}
When a struct is #[non_exhaustive], users can't construct it with a literal. They must use the constructor or a builder pattern. If you add a field later, existing code still compiles because it never relied on the struct's fields being exhaustive.
// This works fine even if `User` gains a `age` field later.
let user = User::new("Alice");
Convention aside: Put #[non_exhaustive] on every public struct and enum that might grow. It's the standard way to protect your API from future expansion. The community considers it best practice for any library that plans to evolve.
Treat #[non_exhaustive] as a shield. Put it on your structs before you publish, and you'll never have to break users to add a field.
Shrinking the surface: Visibility modifiers
Stability isn't just about handling changes. It's about reducing the surface area you have to maintain. Every pub item is a commitment. If you make something public, you can't change it without a major version bump.
Rust gives you granular control over visibility. Use it to hide implementation details.
pubexposes the item to the world.pub(crate)exposes the item only within the current crate.pub(super)exposes the item to the parent module.pub(in path)exposes the item to a specific ancestor module.
If a function is only used by other modules in your crate, mark it pub(crate). You can change its signature anytime without breaking downstream users. This shrinks the public API and makes refactoring safer.
// Only visible inside this crate. Safe to change in minor versions.
pub(crate) fn internal_helper() {
// implementation
}
// Visible to the world. Changes require a major version bump.
pub fn public_api() {
internal_helper();
}
Convention aside: Default to pub(crate). Only make something pub when you have a concrete reason for external users to need it. This is the "minimum public API" rule. It keeps your crate flexible and reduces the burden of stability.
Every pub is a debt. Pay it only when someone else needs it.
Hiding without breaking: #[doc(hidden)]
Sometimes you need an item to be public for technical reasons, but you don't want users to use it. Maybe it's a re-export for internal macros, or a trait that's part of a private implementation detail.
The #[doc(hidden)] attribute hides the item from documentation while keeping it public. Users can still use it, but they won't see it in the docs. This signals that the item is not part of the stable API.
/// Internal trait for serialization. Not part of the public API.
#[doc(hidden)]
pub trait InternalSerializer {
fn serialize(&self) -> Vec<u8>;
}
Use this sparingly. If users find and use hidden items, they'll be angry when you change them. #[doc(hidden)] is a way to keep the docs clean, not a way to break the contract. It's a signal, not a shield. If you change a hidden item, you're still making a breaking change for anyone who used it.
Convention aside: The Rust standard library uses #[doc(hidden)] for items that are public only because of macro hygiene or re-export constraints. Follow that pattern. Don't hide things just because you're unsure if they belong.
Nightly features and #[unstable]
If you're building a feature that's experimental, you can gate it behind a feature flag and mark it unstable. This prevents users on stable Rust from using it.
#![feature(my_new_trait)]
/// An experimental trait for future use.
#[unstable(feature = "my_new_trait", issue = "12345")]
pub trait ExperimentalTrait {
fn experiment(&self);
}
The #![feature(...)] attribute enables the gate in the crate. The #[unstable] attribute marks the item as nightly-only. Users must opt-in with #![feature(my_new_trait)] in their own crate, which only works on the nightly compiler.
If a user tries to use this on stable Rust, they get E0658 (unstable feature). The compiler blocks them. This lets you test APIs in the wild without committing to stability. Once the feature is ready, you remove the attribute and stabilize it.
Convention aside: The issue field should link to a tracking issue on GitHub. This is how the Rust team manages stability. Include an issue number so users can follow the progress and discuss the design.
Nightly features are a sandbox. Play there, but don't build production code on shifting sand.
Pitfalls and compiler errors
Managing stability trips you up in subtle ways. Watch for these patterns.
If you add a parameter to a public function, users get E0061 (not enough arguments). The compiler forces them to update. That's a breaking change. You must bump the major version or provide a default via a builder.
If you remove a public item, users get E0432 (unresolved import). The compiler can't find the symbol. You must deprecate first, then remove in a major version.
If you change a trait bound, users get E0277 (trait bound not satisfied). The compiler rejects types that no longer implement the trait. This is a breaking change.
If you forget to bump the version after a breaking change, you break the Semver contract. Users update expecting safety and get errors. Trust is hard to rebuild. Run cargo semver-checks in your CI. It compares your current code against the previous release and flags breaking changes you missed.
Convention aside: Treat cargo semver-checks as part of your release checklist. It catches things like adding fields to structs or changing function signatures that you might overlook. The community relies on this tool to maintain ecosystem stability.
Don't guess about breaking changes. Let the tool tell you.
Decision: when to use what
Use #[deprecated] when you have a replacement and want users to migrate gradually without breaking their builds. Use #[non_exhaustive] when you plan to add fields to a struct or variants to an enum in future minor versions. Use pub(crate) when the item is only needed by other modules in the same crate and should not be part of the public API. Use #[doc(hidden)] when the item must be public for technical reasons but should not appear in documentation or be used by downstream code. Use feature flags with #[unstable] when you're experimenting with a design and want to restrict usage to nightly Rust. Use semantic versioning to communicate the scope of changes: bump the major version for breaks, the minor version for additions, and the patch version for fixes.
Your version number is a promise. Break the promise, and you break the ecosystem.