The hamy.xyz post about high-level Rust captures the current state well: own everything, use Arc<Mutex<T>>, reach for anyhow, derive what you can. These patterns work. What the article does not cover is that they work now in a way they genuinely did not in 2016. Writing comfortable application Rust is the product of specific language changes and deliberate ecosystem development, and tracing those changes is worthwhile for anyone who has encountered the “Rust is hard” claim and wondered where it still comes from.
The Borrow Checker Is Not What It Was
The biggest ergonomic improvement in Rust’s history did not ship as a new feature; it shipped as a smarter analysis of existing code. Non-Lexical Lifetimes (NLL), stabilized in Rust 2018, replaced lexical borrow scoping with dataflow analysis. Before NLL, the borrow checker considered a borrow to extend until the end of its enclosing block, regardless of whether the borrowed value was used again after an earlier point. The result was rejections that were technically correct under the old rules but practically unnecessary.
The canonical example was a HashMap lookup followed by a conditional insert. Under the old rules, the borrow from get() extended past the insert() call even if the looked-up value was never used afterward. The fix required restructuring the code or adding explicit scoping blocks. After NLL, the borrow ends at its last actual use in the control flow graph, and the common patterns simply compile.
A substantial part of the “the borrow checker rejects correct code” reputation was built on this pre-NLL behavior. Documentation and forum posts from 2015 to 2017 describe workarounds that are no longer necessary. Developers who encounter those posts are reading about a harder version of the language than the one that ships today.
The Polonius project, a Datalog-based rewrite of the borrow checker currently under development, extends this further. It handles patterns NLL still rejects, including returning references from conditional branches that reference different struct fields. Programs that require lifetime annotations or structural workarounds today will eventually compile unchanged under Polonius.
Async Took Five Years to Stabilize Properly
The timeline of async Rust explains a separate layer of friction. Rust 1.39 (November 2019) stabilized async/await syntax, making async code syntactically reasonable. The tokio runtime provided the executor, channels, and filesystem adapters the standard library does not include.
But async functions in traits remained unsupported on stable Rust for the next four years. Writing a trait with async methods required the async-trait proc macro, which automatically desugars each async function to Pin<Box<dyn Future + Send>>. This approach works and adds an extra heap allocation per call; it also introduces type complexity that makes error messages harder to parse. For application writers, it was an inconvenience they worked around constantly, and a common source of confusing Send bound errors.
Rust 1.75 (December 2023) stabilized async functions in traits. Four years after async/await syntax, the foundational feature for async abstraction arrived on stable. Codebases that had accumulated async-trait imports could begin removing them. Error messages for Send bound violations became somewhat more direct without the macro layer.
The practical implication is that learning resources about async Rust written before 2024 describe a harder workflow. Developers who learned async Rust in 2020 or 2021 and found it difficult were not wrong; the experience genuinely was worse then.
The Crates That Fill the Gaps
Several things that feel like language features in high-level Rust are crates that reached near-universal adoption. Understanding this matters because the ecosystem part of Rust’s ergonomics story is as important as the language changes.
serde is the clearest example. The combination of #[derive(Serialize, Deserialize)] with serde_json handles essentially all JSON marshalling in a Rust application:
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
struct EventPayload {
channel_id: String,
event_type: String,
data: serde_json::Value,
}
let payload: EventPayload = serde_json::from_str(&body)?;
let json_bytes = serde_json::to_vec(&payload)?;
serde is downloaded roughly 300 million times per month on crates.io and appears in the dependency tree of nearly every production Rust application. It is not part of the standard library, but it functions as infrastructure. The language did not ship with zero-boilerplate serialization; the ecosystem delivered an equivalent that became a default assumption.
anyhow and thiserror address the error handling ergonomics gap that std::error::Error deliberately left open. Idiomatic Rust error handling before these crates matured meant writing custom error enums, implementing Display and Error for each, and wiring conversions with From implementations. For application code where errors are shown to users rather than programmatically matched, this was significantly more work than equivalent Python or Go. The crates compress it:
use anyhow::{Context, Result};
async fn read_config(path: &str) -> Result<Config> {
let content = tokio::fs::read_to_string(path)
.await
.with_context(|| format!("could not read config file: {path}"))?;
let config: Config = toml::from_str(&content)
.context("config file contains invalid TOML")?;
Ok(config)
}
No custom error type, no impl blocks. The error chain output includes every with_context string in order. The community convention is thiserror in libraries (where callers match on error variants) and anyhow in applications (where errors are read by humans). Both have been stable for years; the pattern is well-understood.
clap v4 brought derive-based CLI argument parsing, eliminating the builder API for most use cases:
use clap::Parser;
#[derive(Parser, Debug)]
#[command(about = "Fetch GitHub repository stats")]
struct Args {
#[arg(short, long)]
repo: String,
#[arg(short, long, default_value_t = 10)]
limit: usize,
#[arg(long)]
json: bool,
}
Argument schemas validate at compile time. Help text generates automatically from field names and doc comments. Required fields enforce their contract without runtime Option checking. Python’s argument parsing ecosystem went through argparse, click, and typer over more than a decade to reach comparable ergonomics; Rust’s derive approach arrived and stabilized within the span of a few crate releases.
What Still Has Not Improved
Two things remain genuinely difficult regardless of code style.
Compile times have not improved proportionally with other ergonomic gains. The 2025 State of Rust Survey shows compile times as the most frequently cited complaint for the third consecutive year. A clean build of a medium Rust project takes 30-90 seconds; Go compiles in under a second. The mold linker reduces link times by 2-5x on Linux, sccache provides a 60-80% reduction on cache hits, and incremental compilation handles most edit-run cycles reasonably. None of this closes the gap with Go, and writing high-level code does not help; the slow part is the compiler frontend and linker, not the complexity of the patterns.
Async edge cases in application code require understanding that tutorials rarely front-load. Holding a MutexGuard across an await point fails to compile because MutexGuard is not Send; the pattern is to acquire, do the work, drop the guard explicitly, then yield. The Send bound errors that surface in async Rust require understanding thread-safety markers and the executor’s requirements. These are learnable, but the error messages are among the harder ones to interpret without prior context.
The Reputation Lag
The “Rust is hard” reputation reflects real experience that is now partially historical. Pre-NLL Rust was harder. Pre-AFIT async Rust was more awkward. The combination of language editions and crate maturity between 2018 and 2024 changed what everyday application Rust looks like. The difficulty that remains, primarily compile times and async edge cases, is genuine but narrower than the reputation implies.
The hamy.xyz framing of getting 80% of the benefits with 20% of the pain is reasonably accurate for Rust as it exists today. It was less accurate for 2016 Rust, and that older experience remains the basis of much of what developers read when they research whether to learn the language. The gap between the reputation and the current state is worth knowing, especially for developers who tried Rust before 2018 and moved on.