The learning curve discourse around Rust has always been about the borrow checker. Lifetimes, ownership, the wall of compiler errors on a first project — these are the stories developers tell when explaining why Rust was hard to adopt. What gets talked about less is that most of this complexity is optional for most programs.
A recent article on hamy.xyz makes this argument directly: writing Rust in a “high-level” style, using owned types and liberal cloning instead of wrestling with references and lifetime annotations, gets you most of what Rust offers at a fraction of the cognitive cost. That framing is right, but the more interesting question is why the tradeoff works so well, and exactly where it breaks down.
What “High-Level Rust” Actually Means
The core of the approach comes down to a few concrete choices:
- Use
StringandVec<T>instead of&strand&[T]for struct fields - Call
.clone()when the borrow checker complains instead of restructuring - Use
Arc<T>for shared ownership instead of explicit lifetime annotations - Use
anyhowfor error handling instead of custom error enums
None of these are novel or controversial. The Rust book itself teaches .clone() before it teaches lifetimes, for exactly this reason. The community has largely converged on this approach for application code; it shows up in most production Rust you will find, including the internals of projects like Tokio and Axum.
The interesting part is the performance math behind why this works.
The Numbers That Make It Defensible
A String::clone() on a 20-character string costs roughly 15-30 nanoseconds: a malloc, a memcpy, and that is it. An Arc::clone() is an atomic fetch-add, clocking in around 2-5 nanoseconds. These are real costs, but they need to be compared against what actually dominates application runtime.
A DNS lookup takes roughly 1 millisecond. A database query over a local network takes 0.5-5 milliseconds. A file read on a cold filesystem takes hundreds of microseconds. Against these numbers, a 30ns string clone is noise. If your function does any I/O at all, the allocations in the surrounding code are not in your budget to worry about.
The performance gap between “pragmatic Rust” and “zero-copy Rust” is typically 1.5-2x in real application workloads. The gap between pragmatic Rust and equivalent Python is 20-50x. Spending engineering time to close the smaller gap before the larger one is already captured makes no sense.
If you want a better return on investment than eliminating clones, swap the allocator:
# Cargo.toml
[dependencies]
mimalloc = { version = "0.1", default-features = false }
// main.rs
#[global_allocator]
static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc;
mimalloc from Microsoft typically reduces allocation overhead by 20-40% on allocation-heavy workloads with a single line of configuration. That is a better trade than refactoring owned types into borrowed slices throughout your codebase.
The Struct Ownership Decision
The place where this choice has the most downstream impact is in struct definitions. When you put a &str in a struct, the compiler needs to know that the referenced memory outlives the struct. This requires a lifetime parameter, which then propagates through every function that constructs or accepts that struct type.
// This decision propagates lifetimes to every function that touches Config
struct Config<'a> {
host: &'a str,
port: u16,
}
// This version has no lifetime, is Clone, Send, and Sync with no extra work
#[derive(Clone)]
struct Config {
host: String,
port: u16,
}
The String version costs one heap allocation when a Config is constructed. For a configuration struct created once at startup, this cost is immeasurable. The benefit is that the struct can be cloned and passed anywhere without any lifetime constraints, can be stored in other structs without propagating lifetime parameters, and can be sent across thread boundaries without issue.
The lifetime-annotated version has its place: high-performance parsers that return slices into their input without copying, zero-copy deserialization with serde’s Deserialize<'de>, and similar workloads where allocation is genuinely the bottleneck. Outside of those cases, owned types win on every dimension that matters for shipping software.
There is also a middle ground for library authors: Cow<'_, str>, which defers the allocation and borrows when no modification is needed. For application code, it is usually overkill. Use String and move on.
Async Rust Makes the Case Stronger
The argument for owned types over borrowed references becomes considerably more compelling in async Rust. Any value that crosses an .await point and gets moved into a spawned task must satisfy a 'static bound, because the compiler cannot statically verify how long the task will live. Borrowed references with non-static lifetimes do not satisfy this bound.
The practical result is that Arc<T> is the standard pattern for sharing state in async Rust, endorsed by the Tokio documentation and used throughout the ecosystem:
use std::sync::Arc;
use axum::{extract::State, routing::get, Router};
#[derive(Clone)]
struct AppState {
db: Arc<Database>,
config: Arc<Config>,
}
async fn handler(State(state): State<AppState>) -> String {
let user = state.db.get_user(1).await.unwrap();
format!("Hello, {}", user.name)
}
The State(state) extractor in Axum clones the AppState per request. Each clone increments two atomic reference counts. The actual Database and Config are shared behind Arc and are not copied. This is correct, efficient, and readable, and it required no lifetime annotations.
Trying to use lifetime-annotated references across .await points in spawned tasks is one of the more painful experiences in Rust. The async ecosystem chose Arc for good reason.
Error Handling: The Right Tool Per Layer
Error handling is where the pragmatic approach has the clearest architectural rationale, not just a performance argument. The anyhow and thiserror crates, both by dtolnay, divide the problem cleanly.
anyhow is for application code: binaries, CLI tools, services. It wraps any error type with a human-readable context chain and surfaces that chain on display.
use anyhow::{Context, Result};
async fn load_user(id: u64) -> Result<User> {
let row = db.query_one(id)
.await
.context("querying user from database")?;
User::from_row(row).context("deserializing user row")
}
The {:#} format specifier on an anyhow::Error prints the full chain:
deserializing user row: querying user from database: connection refused
thiserror is for library code: crates that others depend on. It derives typed error enums that callers can match on, and it generates exactly the boilerplate you would write by hand with no runtime overhead.
use thiserror::Error;
#[derive(Error, Debug)]
pub enum ParseError {
#[error("unexpected token at position {pos}: {token}")]
UnexpectedToken { pos: usize, token: String },
#[error("input ended unexpectedly")]
UnexpectedEof,
#[error("io error: {0}")]
Io(#[from] std::io::Error),
}
The pattern most production Rust uses is: library crates expose thiserror enums, application code wraps them with anyhow. The library surface stays typed and matchable; the application code stays ergonomic. Using anyhow in a library forces your error type on your consumers, which disrupts their error handling patterns.
Box<dyn std::error::Error> sits below both on the ergonomics scale. It works for quick scripts and main() functions, but it heap-allocates error values and strips type information. anyhow::Error is one pointer wide and gives you context chains, backtraces, and thread safety; it is a strict improvement over Box<dyn Error> for the same use cases.
Where the Approach Breaks Down
Writing Rust this way has real limits, and knowing them is as useful as knowing the defaults.
When you are writing a parser, serializer, or any code that processes large amounts of data in a tight loop, allocating on every item is measurable and worth eliminating. The winnow and nom parser combinator libraries exist to enable zero-copy parsing, returning &str slices into the input rather than allocating new strings. At that point, lifetimes are earning their keep.
When you are writing embedded code or code in an allocator-constrained environment, the heap-allocation model changes entirely. Crates like heapless provide stack-allocated data structures for these contexts, and the entire String/Vec approach does not apply.
When you are building a library with a public API, using String in function signatures can force unnecessary allocations on callers. Accepting impl AsRef<str> or impl Into<String> gives callers flexibility without adding complexity on your side:
// Forces callers with a &str to allocate
fn process(input: String) -> String { ... }
// Callers pass whatever they have
fn process(input: impl AsRef<str>) -> String {
let s = input.as_ref();
s.to_uppercase()
}
And when profiling actually identifies a hot path, the owned-types version is a good starting point for optimization because the structure is clear and the data model is established. Refactoring from String to &str is mechanical once you know where to look.
The Actual Learning Curve
The hard parts of Rust are conceptual, not syntactic. Ownership and borrowing as mental models take time to internalize regardless of whether you use lifetimes or Arc. What changes with the pragmatic approach is that you can build working, correct software while internalizing those concepts, rather than needing to fully internalize them before shipping anything.
The compiler error messages are genuinely good. When the borrow checker rejects something, it usually tells you the fix. If the fix it suggests is .clone(), that is not a workaround; it is a valid response to a real ownership question.
The iterator API is idiomatic Rust regardless of whether you use owned types or borrowed references, and it is worth learning early. It does not require lifetime expertise and it composes well:
let word_counts: HashMap<String, usize> = text
.split_whitespace()
.fold(HashMap::new(), |mut map, word| {
*map.entry(word.to_string()).or_insert(0) += 1;
map
});
The .to_string() allocation belongs there. The HashMap owns its keys, and that is correct. This is not a compromise.
The premise of the original article is simply correct: writing high-level Rust still gives you memory safety, fearless concurrency, Result-based error propagation, and the performance characteristics that make Rust worth using. You are trading some allocation efficiency for dramatically lower complexity, and for most programs — web services, CLI tools, Discord bots, data pipelines — that is the right trade. The ceiling is still there whenever you need it.