Rust Warning Unused Future Must Be Used and What It Hides
Futures are lazy: calling an async fn without .await does nothing. The unused_must_use warning is the compiler catching a missing await, a forgotten spawn, or a dropped JoinHandle.

The warning "unused implementer of Future that must be used" is one of the few Rust warnings that nearly always points at a real bug. It fires when you call an async fn and throw the result away without awaiting it. Because a future is only a description of work, the call does nothing: no code of the function body has run, no side effect has happened, and no error is reported. The program compiles, runs and quietly skips the step. This post shows the warning, the three ways the bug hides from it (let _, an underscore-prefixed binding, a stored-and-forgotten future), the clippy lint that catches the first, the places where a missing .await is intentional, and how to make the compiler stop the bug in CI. Everything compiled on Rust 1.99, edition 2024, with tokio 1.
The bug, and the warning that finds it
async fn save(n: u32) {
println!("saved {n}");
}
#[tokio::main(flavor = "current_thread")]
async fn main() {
save(1);
println!("done");
}doneCompiling it prints this warning (the program still builds):
warning: unused implementer of `Future` that must be used
--> src/main.rs:7:5
|
7 | save(1);
| ^^^^^^^
|
= note: futures do nothing unless you `.await` or poll them
= note: `#[warn(unused_must_use)]` (part of `#[warn(unused)]`) on by defaultThe output shows only "done". The call save(1) built a future and dropped it on the next line. The compiler's note is exact: "futures do nothing unless you .await or poll them". The warning is attached to the Future trait, which carries #[must_use], so it fires for every future you drop in statement position, including those from library functions and from adapters like join_all. The fix is the missing word: save(1).await;. If the work should instead run in the background, spawn it and say so (tokio::spawn(save(1));), an intention a reader can see.
Three ways it hides
The lint is silenced by several innocent-looking spellings, and each deserves a look because the same bug survives behind them.
async fn save(n: u32) {
println!("saved {n}");
}
#[tokio::main(flavor = "current_thread")]
async fn main() {
let _ = save(2);
let _pending = save(3);
let h = tokio::spawn(save(4));
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
drop(h);
println!("main finished");
}saved 4
main finishedOnly "saved 4" appears. Line by line: let _ = save(2); tells the compiler "I meant to ignore this", so rustc stays quiet, and it is exactly what a developer types to make a warning go away without understanding it. let _pending = save(3); binds the future to a name that starts with an underscore, which also suppresses the unused-variable warning, and the future is dropped at the end of its scope without ever being polled. Only the spawned one ran, because tokio::spawn submits the future to the runtime. Dropping the join handle does not cancel the task, as spawn and join explains.
The clippy lint for the first
Rustc cannot distinguish a deliberate let _ = from a lazy one, but clippy can, for futures specifically. The let_underscore_future lint flags the pattern when the value is a future:
warning: non-binding `let` on a future
--> src/main.rs:5:5
|
5 | let _ = save(2);
| ^^^^^^^^^^^^^^^^
|
= help: consider awaiting the future or dropping explicitly with `std::mem::drop`
= note: `#[warn(clippy::let_underscore_future)]` on by defaultIt does not catch the underscore-prefixed name (_pending), because that is an ordinary binding. Review for it by reading: a variable named with a leading underscore whose type is a future is a bug until proven otherwise.
Where a missing await is intentional
Not every future is meant to be awaited on the spot. These are the legitimate cases, and the code should make the intent visible:
- Composition. You build several futures and await them together:
let a = fetch(1); let b = fetch(2); let (x, y) = tokio::join!(a, b);. The futures are bound to names and used. - Racing.
select!takes futures by expression and polls them; the losers are dropped on purpose. - Handing off. Passing a future to
spawn,timeout, a stream adapter or a function that takesimpl Future. - Deferred start. Storing a future in a struct to poll later, as in the retry example in Pin and Box dyn Future.
In each, the future is consumed by something. The bug is a future that is consumed by nothing. A short, runnable check shows the difference between building futures eagerly and running them:
use tokio::time::{sleep, Duration, Instant};
async fn step(name: &str, ms: u64) -> String {
sleep(Duration::from_millis(ms)).await;
format!("{name} done")
}
#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
let t = Instant::now();
let a = step("first", 100);
let b = step("second", 100);
println!("built both at {:?}, nothing has run", t.elapsed());
let (x, y) = tokio::join!(a, b);
println!("{x}, {y} at {:?}", t.elapsed());
}built both at 0ns, nothing has run
first done, second done at 100msBoth futures were created at time zero and neither started its sleep until join! polled them, which then ran them together in 100 ms. Creating a future is free and effect-free; awaiting is what starts it. That asymmetry is the whole warning.
The same warning for streams, iterators and results
The principle is wider than futures. Lazy values in Rust are marked #[must_use] so the compiler can tell you when you built something and never used it: an iterator adapter (v.iter().map(...) as a statement does nothing), a stream, a Result (an ignored error), a Duration-producing method. A statement that is a pure expression and discards its value usually means a forgotten consumer. The iterator case is shown in higher-order functions, and the result case in Result explained. For streams the message reads "streams do nothing unless polled", and the cure is a while let Some(x) = s.next().await loop or collect.
Make the compiler stop it for good
The warning is on by default, so what lets the bug through is people ignoring warnings. Three guards, in increasing strength:
- Treat warnings as errors in CI:
RUSTFLAGS="-D warnings" cargo build, orcargo clippy -- -D warnings, which also enables the clippy lint above. The setup is in continuous integration for Rust. - Deny the specific lints for the crate:
#![deny(unused_must_use)]at the top ofmain.rs, or in the manifest's lints table (the lints table) so every crate in a workspace inherits it, together withclippy::let_underscore_future. - Write a test that observes the effect. A test that calls the function and then checks that the record was saved fails when the await is missing, whatever the compiler said. For code with I/O, run the test under
#[tokio::test(start_paused = true)]so it is fast and deterministic.
When you get the warning on something you cannot await
Occasionally the future is returned by a trait method in synchronous context (a Drop impl, a callback). The right response is not let _ =. Spawn it on a captured Handle (there is no reactor running shows how), send the work to a channel that a task drains, or restructure so the async step happens in an async function. If the work is genuinely fire-and-forget, tokio::spawn with a comment is both honest and visible. And when the task you spawn may fail, keep the join handle or log inside the task, since an error in a detached task disappears unless something reports it; the neighbouring problem of a task that never completes is in tasks that never finish.


