How to use async with file IO in Rust

Use the tokio crate's fs module and await file operations to perform non-blocking file IO in Rust.

When one thread isn't enough

You're building a web server. A request comes in asking for a user profile. The profile lives in a JSON file on disk. You open the file, read it, parse it, and send the response. One request works fine. Now a thousand requests hit at once. Each one tries to read a file. The OS has to wait for the disk to spin up or the SSD controller to respond. Your thread sits there doing nothing, waiting for hardware. The thread is blocked. The other nine hundred requests pile up in a queue. Your server looks dead. You need a way to say "go read this file, and while you're at it, handle the other requests."

The ticket system

Async file IO works like a ticket system at a busy deli. In a blocking world, you stand at the counter and stare at the sandwich maker until your turkey club is ready. You can't order a drink, you can't check your phone. You just wait. In the async world, you hand your order to the clerk, get a ticket, and walk away. You can check your phone, help a friend, or even start making your own sandwich. When the clerk finishes your order, they call your number. You pick up the sandwich and move on. The key is that you aren't stuck waiting.

In Rust, the "ticket" is a Future. The "clerk" is the OS or the runtime. tokio::fs gives you the tools to hand off file operations and get a ticket back instead of freezing your thread. Async IO turns waiting time into working time.

Minimal example

The tokio crate provides async versions of standard file operations. You mark your function as async and use .await to pause execution until the IO completes.

use tokio::fs::File;
use tokio::io::AsyncReadExt;

/// Reads a file asynchronously and returns its contents as a String.
/// This function yields control to the runtime while waiting for disk IO.
async fn read_file(path: &str) -> Result<String, std::io::Error> {
    // Open the file. This returns a Future, not the file itself.
    // The runtime schedules the open operation and yields control back.
    let mut file = File::open(path).await?;

    let mut contents = String::new();
    // Read the entire file into the string.
    // This yields whenever the OS needs to fetch more data from disk.
    file.read_to_string(&mut contents).await?;

    Ok(contents)
}

The runtime handles the waiting. Your code just describes the work.

What happens under the hood

When you call File::open(path), you aren't opening the file yet. You're building a plan. The function returns a Future. This is a state machine that knows how to perform the open operation. When you write .await, you're telling the runtime: "Run this future until it's done, or until it needs to wait for something."

The runtime takes the future, asks the OS to start the file open, and then immediately moves on to run other code. Your function pauses right at the .await. It doesn't consume a thread. It just sits in memory as a small state machine. When the OS finishes opening the file, it notifies the runtime. The runtime sees the notification, finds your paused function, and resumes it right after the .await. The file variable now holds the open file handle.

The async fn desugars to a struct with an enum inside. The enum tracks which .await point you're at. When you poll the future, the match statement runs the code up to the next .await. If the operation isn't ready, it returns Poll::Pending and stores a waker. The waker tells the runtime how to wake this future. This is why async code is zero-cost. There's no thread switching. Just a function call and a state check.

tokio::fs uses the OS's async primitives. On Linux, it uses io_uring or epoll. On macOS, it uses kqueue. On Windows, it uses IOCP. The crate abstracts these details so you write the same code everywhere.

The future is just a paused function. The runtime is the conductor.

Realistic concurrent reads

Async IO shines when you have multiple independent operations. You can start reading several files at once and collect the results.

use tokio::fs;
use tokio::io::AsyncReadExt;

/// Reads multiple files concurrently and collects their contents.
/// Returns a vector of results, preserving the order of the input paths.
async fn read_configs(paths: Vec<&str>) -> Vec<Result<String, std::io::Error>> {
    // Create a future for each file read.
    // These futures are created instantly; none of the IO has started yet.
    let mut handles = Vec::new();
    for path in paths {
        // Clone path to move into the async block.
        // The async block takes ownership of its captures.
        let path = path.to_string();
        
        // Spawn a new task for each file.
        // This allows the reads to overlap completely.
        let handle = tokio::spawn(async move {
            // Open and read the file.
            // If the file doesn't exist, this returns an Err.
            let mut file = fs::File::open(&path).await?;
            let mut contents = String::new();
            file.read_to_string(&mut contents).await?;
            Ok(contents)
        });
        handles.push(handle);
    }

    // Await all handles concurrently.
    // The runtime interleaves the IO operations.
    // If one file is slow, the others keep making progress.
    let mut results = Vec::new();
    for handle in handles {
        // handle.await returns Result<Result<String, io::Error>, JoinError>.
        // We unwrap the JoinError because panics in the task are fatal here.
        results.push(handle.await.unwrap());
    }

    results
}

Concurrency isn't about making one file read faster. It's about reading ten files at once.

Convention: tokio::fs vs spawn_blocking

The community convention is to use tokio::fs for small files and high-concurrency scenarios. If you're reading a massive log file that takes seconds, tokio::fs might hold up the runtime's internal IO loop longer than necessary. In that case, wrap std::fs in tokio::task::spawn_blocking. This offloads the blocking call to a separate thread pool, keeping the async runtime free.

Convention: Entry point attribute

Always annotate your entry point with #[tokio::main]. This macro sets up the runtime. Without it, async fn main won't run. The community expects this attribute on the binary entry point.

Pitfalls and compiler errors

Using std::fs inside an async function compiles fine. It also kills your performance. std::fs::read_to_string blocks the current thread. If you run this on the tokio runtime, you block the worker thread. The runtime can't run other tasks on that thread. If you block enough threads, the runtime stalls. The compiler won't stop you. You have to choose the right tool.

You might try to pass a std::fs::File to a function expecting AsyncRead. The compiler rejects this with E0277 (trait bound not satisfied). std::fs::File implements std::io::Read, not tokio::io::AsyncRead. They are different types. You can't mix them without an adapter.

When spawning tasks, you move data. If you try to use a variable after moving it into tokio::spawn, you get E0382 (use of moved value). Clone the data or use Arc before the spawn.

Holding a lock across an .await is a deadlock risk. If you hold a Mutex and yield, another task might try to acquire the same lock. That task blocks. The runtime might be waiting for the first task to finish to make progress. Deadlock. Drop the lock before the .await.

The compiler catches type errors. It won't save you from blocking the runtime. You have to choose the right IO crate.

Choosing the right tool

Use tokio::fs when you're reading small files like configs, templates, or user avatars in a high-concurrency server. The overhead of the async machinery is negligible, and you get massive throughput by overlapping IO.

Use std::fs wrapped in tokio::task::spawn_blocking when you're processing large files that take seconds to read or parse. The async runtime has a limited number of worker threads. A long blocking operation in tokio::fs can starve other tasks. Offloading to the blocking thread pool protects the runtime.

Use plain std::fs when you're writing a CLI tool, a build script, or a single-threaded application. Async adds complexity. If you don't have concurrency, you don't need async IO.

Use tokio::io::copy when you just need to move data from a file to a stream or another file. It handles buffering and chunking for you. Writing a manual read loop is error-prone and slower.

Async IO is a tool for concurrency, not magic speed. Measure before you optimize.

Where to go next