When the docs don't match the build
You're maintaining a library. You added a feature flag for an optional dependency, like a database driver or a serialization format. When users generate documentation without that feature enabled, they still see the functions related to it. They click through, find examples that won't compile, or get confused about why their code fails. The documentation promises an API that the current build doesn't provide.
You need the documentation to shift based on the feature set. You want to add warnings, hide items conditionally, or swap doc text depending on what's compiled. That's where #[cfg_attr] comes in. It lets you apply attributes conditionally, so your metadata can adapt to the build configuration just like your code does.
Conditional metadata, not conditional code
Rust has #[cfg] for conditional compilation. If the condition is false, the item vanishes from the binary. #[cfg_attr] is different. It stands for "conditional attribute." The item always exists. The attribute only appears if the condition is true.
Think of it like a sticker on a shipping box. The box is always there. The sticker changes based on the destination. #[cfg_attr] checks a condition and applies an attribute if it passes. If it fails, the attribute is never added. The compiler sees the result as if you had written the attribute manually when the condition held.
This matters because attributes control more than just documentation. They control linting, testing, linking, and trait implementations. #[cfg_attr] gives you fine-grained control over metadata without duplicating code or removing items entirely.
/// A high-performance hasher.
#[cfg_attr(feature = "experimental", doc = "Warning: Hash values may change between versions.")]
pub struct FastHasher;
The struct exists in every build. The warning text appears only when the experimental feature is active. Users building without that feature see a clean doc. Users enabling it get the context they need.
Convention aside: The community uses #[doc(cfg(feature = "x"))] to show the standard "Available on feature x" badge in rustdoc. Use #[cfg_attr] when you need custom text, warnings, or non-doc attributes. doc(cfg) handles the badge; cfg_attr handles everything else.
How the compiler processes it
The attribute system runs early in compilation. When the compiler encounters #[cfg_attr(cond, attr)], it evaluates cond immediately. If cond is true, the compiler replaces the cfg_attr with attr. If cond is false, it removes the cfg_attr entirely. The rest of the compilation pipeline never sees the conditional wrapper.
This means #[cfg_attr] is a pre-processor for attributes. You can stack them. You can nest conditions. You can apply it to any attribute, not just doc.
// Condition: feature "nightly" is enabled.
// Action: Apply doc attribute with nightly warning.
#[cfg_attr(feature = "nightly", doc = "Requires nightly Rust.")]
// Condition: feature "nightly" is NOT enabled.
// Action: Apply doc attribute with stable note.
#[cfg_attr(not(feature = "nightly"), doc = "Stable implementation.")]
pub fn hash_data(input: &[u8]) -> u64 {
// Implementation logic here.
input.iter().fold(0u64, |acc, b| acc.wrapping_add(*b as u64))
}
The function compiles in both cases. The documentation text swaps based on the feature. The compiler injects exactly one doc attribute per build. Rustdoc renders whatever attribute is present.
Ah-ha reveal: #[cfg_attr] works with any configuration predicate, not just features. You can use target_os, debug_assertions, test, or complex combinations with all() and any(). The condition syntax is identical to #[cfg].
Realistic scenario: Optional dependencies
Libraries often expose functionality only when optional dependencies are present. You might have a serde feature that enables serialization. The struct exists without serde, but the Serialize trait implementation requires it. You want the docs to reflect this clearly.
/// A configuration value that can be serialized.
#[cfg_attr(feature = "serde", doc = "Implements `Serialize` and `Deserialize` when the `serde` feature is enabled.")]
#[cfg_attr(not(feature = "serde"), doc = "Serialization is disabled. Enable the `serde` feature to use `Serialize` and `Deserialize`.")]
pub struct ConfigValue {
pub key: String,
pub value: String,
}
// The trait impl is gated by cfg, not cfg_attr.
#[cfg(feature = "serde")]
impl serde::Serialize for ConfigValue {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
// Serialization logic.
serializer.serialize_str(&self.value)
}
}
The struct documentation adapts. The trait implementation uses #[cfg] because the code itself must vanish without the feature. This is the standard pattern: cfg_attr for metadata, cfg for code.
Convention aside: Keep cfg_attr blocks close to the item they modify. Don't scatter them. If you have multiple conditional attributes, group them together. It makes the build configuration easier to audit.
Pitfalls and compiler behavior
#[cfg_attr] does not gate the item. If you use it to add doc(hidden), the item still compiles and is accessible in code. It only hides from documentation. If you want the item to disappear from the API, use #[cfg].
If you forget this distinction, users might see docs for items that exist but behave differently, or they might find items in code that the docs claim are hidden. The compiler won't stop you. It trusts your attributes.
Another pitfall: Doc tests. If you include code examples in conditional doc text, those tests run only when the feature is enabled during doc test execution. If you run cargo test --doc without the feature, the test is skipped. This is usually what you want, but it can hide bugs if you assume tests run unconditionally.
/// A utility function.
#[cfg_attr(feature = "advanced", doc = "Advanced users can chain this with `transform`.")]
pub fn process(input: &str) -> String {
input.to_uppercase()
}
If advanced is off, the doc text is gone. The function remains. If you had a doc test inside that conditional text, it would also vanish. Make sure your test coverage accounts for feature combinations.
If you try to use a feature-gated item without the feature, the compiler rejects you with E0432 (unresolved import) or E0425 (cannot find value). #[cfg_attr] doesn't cause these errors. It only affects metadata. The errors come from missing code, which requires #[cfg].
Don't use cfg_attr to hide code. Use cfg. Metadata changes don't change the binary. Code changes do. Pick the tool that matches your goal.
Decision: When to use what
Use #[cfg(feature = "x")] when the code itself must not exist in the binary. The function, struct, or module should vanish completely if the feature is disabled. This reduces binary size and prevents users from calling unavailable APIs.
Use #[cfg_attr(feature = "x", doc = "...")] when the code exists regardless, but the documentation needs to change based on the feature. The API is stable, but the context shifts. Add warnings, notes, or feature-specific instructions.
Use #[cfg_attr(feature = "x", doc(hidden))] when you want to hide an item from docs only under specific conditions, but keep it compiled. This is rare. Usually, you hide items with cfg or doc(hidden) unconditionally.
Use #[doc(cfg(feature = "x"))] when you want the standard rustdoc badge showing feature availability. This is the idiomatic way to indicate optional features in documentation. It adds a visual indicator without custom text.
Use #[cfg_attr(not(feature = "x"), ...)] when you need to apply attributes when a feature is disabled. This is useful for default warnings or fallback documentation.
Use #[cfg_attr] with target_os or debug_assertions when platform or build mode affects metadata. For example, add a note about performance characteristics in debug builds, or document platform-specific behavior.
Trust the distinction. cfg removes code. cfg_attr modifies metadata. Mixing them up leads to confusing APIs and wasted time.