Writing good Rust documentation: lessons from production
Documentation is often the last thing teams prioritize when shipping Rust services, yet it's the first thing that saves hours of debugging when someone new joins the team or when you return to code months later. We've learned across multiple production systems that investing in clear, runnable documentation isn't a nice-to-have—it's the difference between a library that developers adopt enthusiastically and one they avoid because "no one understands how to use it."
In this guide, we'll walk through the practical patterns we use to write documentation that actually helps users, drawing from real production scenarios where missing or poor docs caused real friction.
Using /// doc comments effectively
Doc comments in Rust are special: they generate HTML documentation that lives alongside your code and becomes the canonical reference. Unlike regular comments, doc comments are compiled and checked, which means they're harder to let rot.
The basics are straightforward—use /// for items and //! for module-level docs—but the patterns matter. We've found that good doc comments follow a structure: a one-line summary, a blank line, then more detailed explanation and examples.
/// Attempts to fetch a user by ID from the database.
///
/// This function queries the primary database replica and will return
/// an error if the network is unavailable or if the user does not exist.
/// It does not perform any authorization checks; callers must validate
/// permissions separately.
///
/// # Arguments
///
/// * `user_id` - The numeric ID of the user to fetch
/// * `pool` - A reference to the database connection pool
///
/// # Returns
///
/// Returns `Ok(User)` if the user exists, or `Err(DatabaseError)` if
/// the lookup fails. Common errors include `NotFound` and `ConnectionTimeout`.
///
/// # Panics
///
/// This function does not panic under normal circumstances.
pub async fn fetch_user(user_id: u64, pool: &PgPool) -> Result<User, DatabaseError> {
// implementation
}
Notice the structure: one-line summary, then sections for Arguments, Returns, and Panics. This format is convention in Rust docs and readers expect it. More importantly, it forces you to think clearly about what your function does, what it expects, and what can go wrong.
For modules, we document the purpose and often provide a brief usage example. In a production API client library, this looks like:
//! HTTP client for the internal order service.
//!
//! This module provides strongly-typed wrappers around the order microservice API.
//! Handles authentication, retries, and common error cases automatically.
//!
//! # Example
//!
//! ```
//! use order_client::{Client, Config};
//!
//! let config = Config::from_env()?;
//! let client = Client::new(config);
//! let order = client.get_order(12345).await?;
//! println!("{:?}", order);
//! # Ok::<(), Box>(())
//! ```
When docs are vague, new team members spend disproportionate time guessing. "Does this function retry on transient errors?" "What exactly is considered a timeout?" Clear, specific doc comments answer these questions once, permanently.
Examples that compile and run
This is the practice that has saved us the most time in production. Doc comments can include code examples, and Rust can compile and test those examples automatically via cargo test --doc. This means your examples are guaranteed to be correct and up-to-date.
In a JSON parsing utility we maintain, we started with vague prose:
/// Parses a JSON object into a strongly-typed config struct.
pub fn parse_config(raw: &str) -> Result<Config, ParseError> { }
Within weeks, developers were using it incorrectly. We added a concrete example:
/// Parses a JSON object into a strongly-typed config struct.
///
/// # Example
///
/// ```
/// use config::parse_config;
///
/// let json = r#"{"port": 8080, "debug": true}"#;
/// let cfg = parse_config(json)?;
/// assert_eq!(cfg.port, 8080);
/// # Ok::<(), Box>(())
/// ```
pub fn parse_config(raw: &str) -> Result<Config, ParseError> { }
That # Ok::<(), Box>(()) at the end is a hidden line—it lets the example return a Result without cluttering the visible code. This example now runs every time we run tests; if the API changes, the test fails immediately.
For more complex scenarios, we've built examples that simulate real workloads. In a caching layer, we documented usage with concurrency:
/// Inserts a value into the cache with an automatic expiration.
///
/// # Example
///
/// ```
/// use cache::Cache;
/// use std::time::Duration;
///
/// let cache = Cache::new();
/// cache.insert("key", "value", Duration::from_secs(60))?;
///
/// // Value is present immediately
/// assert_eq!(cache.get("key")?, Some("value"));
/// # Ok::<(), Box>(())
/// ```
pub fn insert<K, V>(&self, key: K, value: V, ttl: Duration) -> Result<(), CacheError> { }
The key insight: examples should be realistic, self-contained, and genuinely demonstrate the happy path. Developers copy them as templates; if your example is wrong, so will their code be.
Explaining errors and panics
Rust forces you to handle errors, but that doesn't mean users understand what to do when they see them. Good documentation explains what each error means and how to recover.
In a Kafka producer we maintain, we document error cases explicitly:
/// Publishes a message to a Kafka topic.
///
/// # Errors
///
/// Returns `ProducerError::BufferFull` if the internal batch buffer is at
/// capacity and the broker is not accepting new messages. Callers should
/// wait briefly and retry with exponential backoff.
///
/// Returns `ProducerError::BrokerUnreachable` if all broker replicas are
/// offline. This is typically unrecoverable within a single application;
/// monitoring and alerting should be configured.
///
/// Returns `ProducerError::InvalidSchema` if the message does not conform
/// to the topic's registered schema. Check the message structure against
/// the schema definition in the docs/schemas/ directory.
pub async fn publish(&self, topic: &str, message: &Message) -> Result<(), ProducerError> { }
This guidance prevents the common pattern of users just calling .unwrap() and crashing. They understand which errors are transient (retry), which are permanent (log and alert), and which are programmer errors (fix the schema).
For panics, be explicit about when they occur. Most of our production code doesn't panic, but when it does, users need to know:
/// Creates a new cache with the specified capacity.
///
/// # Panics
///
/// Panics if capacity is zero. Cache requires at least 1 slot.
pub fn with_capacity(capacity: usize) -> Self {
assert!(capacity > 0, "capacity must be greater than zero");
// ...
}
This tells callers: "You control whether this panics." They won't pass zero from an untrusted source. If they do, it's a bug in their validation, not a surprise failure.
Using cargo doc --open to validate documentation
Documentation only matters if it's readable. We run cargo doc --open regularly during development to see how our docs render. This catches formatting mistakes, broken links, and unclear prose before they ship.
The HTML output is also what users see. Spending 30 seconds to render and skim it prevents embarrassing formatting in production docs. We've caught:
- Code blocks that render with misaligned syntax highlighting because the language hint was wrong
- Links to internal modules that broke when we reorganized the crate
- Lists that rendered incorrectly due to markdown formatting errors
- Examples with typos that would confuse users immediately
In our deployment scripts, we add a pre-commit hook that runs cargo doc --all --no-deps and fails if there are warnings. This isn't about pedantry; in a library used by a dozen internal teams, doc warnings mean ambiguous code that will cause support requests.
Common mistakes
We've made these mistakes ourselves and have seen them in code reviews:
- Writing docs in the past tense or from the code's perspective: "This function will fetch a user" instead of "Fetches a user." Doc comments are imperatives—they describe what the function does from the caller's viewpoint.
- Over-documenting the obvious: "Returns a String" as a doc comment adds noise. Focus on *why* it returns a String, *when* it might be empty, and *what the caller should do with it*.
- Examples that don't compile: If you don't run
cargo test --docregularly, examples rot. By Day 67 of a real project, most doc examples in unmaintained code will be wrong. - Forgetting the audience: Docs written for your team of three might baffle a new hire or an external user. Assume less context than you think.
- Ignoring deprecation: When you change an API, update or remove old doc examples. Dangling examples cause resentment.
Key takeaways
- Use
///doc comments with a one-line summary, blank line, then structured sections (Arguments, Returns, Errors, Panics, Examples) - Include realistic, self-contained code examples that compile and run via
cargo test --doc - Explain what errors mean and how to handle them; help users distinguish transient failures from permanent ones
- Run
cargo doc --openduring development to validate formatting and readability - Treat doc comments as part of your API contract; broken or misleading docs undermine user trust
- Make
cargo test --docpart of your CI pipeline so examples stay current as the codebase evolves






Comments
Loading comments…