How to List Files in a Directory in Rust

Use the `std::fs::read_dir` function to iterate over directory entries, or `std::fs::read_dir` combined with `filter_map` to safely handle errors and extract paths.

When the filesystem hands you a stream, not a list

You're writing a CLI tool that scans a project for configuration files. Or a game engine loading textures from an assets/ folder. Or a backup script that needs to copy everything in ~/Documents. You point Rust at a directory and expect a clean list of filenames. The filesystem doesn't give you a list. It gives you a stream of entries. Some entries might vanish before you even look at them. Permissions might shift. The disk might fill up. Rust forces you to handle that reality for every single item.

std::fs::read_dir is the standard way to list directory contents. It returns an iterator, not a Vec. The iterator yields Result<DirEntry, io::Error> items one by one. This design keeps memory usage low and matches how operating systems actually work. Directories can contain millions of files. Loading them all into memory at once is wasteful and slow. Rust models the directory as a lazy stream. You ask for the next entry, and Rust asks the OS. The OS returns what it can, or it returns an error.

Treat the directory as a stream. Handle errors per entry. A deleted file shouldn't kill your whole scan.

How read_dir works under the hood

read_dir takes a path and returns a ReadDir iterator. The iterator holds an open handle to the directory. When you call next() on the iterator, Rust makes a system call to read the next directory entry. On Linux, this is readdir. On Windows, it's FindNextFile. The OS returns raw bytes. Rust parses those bytes and wraps them in a DirEntry.

Each DirEntry is wrapped in a Result. The Result exists because the OS state can change between iterations. A file might be deleted while you're looping. A permission might be revoked. The OS might return an I/O error. Rust requires you to handle the Result for each entry. You cannot ignore the error. If you try to unwrap the iterator directly, the compiler rejects you.

The DirEntry struct contains the filename and the full path. It also caches metadata if you asked for it, but read_dir does not fetch full metadata by default. Fetching metadata requires a separate system call per file. read_dir avoids that cost unless you explicitly call entry.metadata(). This optimization matters for large directories. Calling metadata() on every file can turn a fast scan into a slow one.

Trust the Result. The OS is lying to you less often than you think, but when it does, Rust catches it.

Minimal example: listing entries safely

This example opens a directory and prints the name of every entry. It handles the Result for each entry. If an entry fails to read, it prints an error and continues. This is the robust pattern. It survives files being deleted during iteration.

use std::fs;
use std::path::Path;

fn main() {
    // Point to the directory you want to scan.
    let dir_path = Path::new("./src");

    // read_dir returns an iterator of Results.
    // We use expect here because if the directory doesn't exist,
    // there's no point in continuing.
    let entries = fs::read_dir(dir_path).expect("Failed to open directory");

    for entry in entries {
        // Each entry is a Result because the OS state can change
        // between when you start iterating and when you get the item.
        match entry {
            Ok(entry) => {
                // entry.file_name() returns Option<&OsStr>.
                // Use to_string_lossy for safe display of non-UTF8 names.
                let name = entry.file_name().to_string_lossy();
                println!("Found: {}", name);
            }
            Err(e) => {
                // Handle the error for this specific entry.
                // This often happens if a file is deleted during iteration.
                eprintln!("Error reading entry: {}", e);
            }
        }
    }
}

Convention aside: expect is preferred over unwrap for the initial read_dir call. expect includes a message that appears in the panic output. unwrap gives a silent panic. The message helps debug why the directory couldn't be opened. Also, entry.file_name() returns Option<&OsStr>. Printing with {:?} shows Some("name"). Using to_string_lossy() extracts the string content safely. It replaces invalid UTF-8 sequences with the replacement character instead of panicking.

Handle the error per entry. A deleted file shouldn't kill your whole scan.

Performance: file_type versus metadata

A common mistake is checking entry.path().is_file() to distinguish files from directories. That call triggers a metadata lookup. The metadata lookup calls stat on the OS. stat is a system call. System calls are expensive. If you have 10,000 files, 10,000 stat calls can take seconds.

DirEntry provides a file_type() method. This method returns the file type without a separate system call. The file type is cached from the directory read. It's free. Use file_type() to check if an entry is a file, directory, or symlink. Use metadata() only when you need size, timestamps, or permissions.

Check file_type() before metadata(). Syscalls add up fast.

Realistic example: filtering and sorting

This example collects all Rust source files from a directory. It filters by extension, skips unreadable entries, and sorts the results alphabetically. It demonstrates the functional iterator style and the convention for handling Result streams.

use std::fs;
use std::path::Path;

/// Collects all Rust source files from a directory.
/// Skips entries that fail to read and ignores non-.rs files.
fn collect_rust_files(dir: &Path) -> Vec<String> {
    // Open the directory. Panic if we can't start.
    fs::read_dir(dir)
        .expect("Directory must exist")
        // filter_map handles the Result<DirEntry, Error>.
        // It keeps Ok entries and discards Err entries.
        .filter_map(|entry| entry.ok())
        // Filter by extension.
        // extension() returns Option<&OsStr>, so we map and check.
        .filter(|entry| {
            entry.path().extension()
                .map(|ext| ext == "rs")
                .unwrap_or(false)
        })
        // Extract the filename as a String.
        // to_string_lossy handles non-UTF8 filenames gracefully.
        .map(|entry| entry.file_name().to_string_lossy().to_string())
        // Collect into a Vec to enable sorting.
        .collect()
}

fn main() {
    let mut files = collect_rust_files(Path::new("."));
    
    // read_dir order is not guaranteed.
    // Sort alphabetically for consistent output.
    files.sort();

    println!("Found {} files:", files.len());
    for f in files {
        println!(" - {}", f);
    }
}

Convention aside: filter_map(|entry| entry.ok()) is the idiomatic way to discard errors in an iterator of Results. It's cleaner than chaining filter and map. It reads as "keep the Ok values, drop the Err values". Also, to_string_lossy() is the standard way to convert OsStr to String for display. It never panics. It handles filenames with invalid UTF-8 by replacing bad bytes with . This is safer than to_str(), which returns None for invalid UTF-8.

Collect before you sort. The iterator cannot be sorted in place.

Pitfalls and gotchas

Order is undefined. read_dir returns entries in the order the filesystem stores them. That order depends on the OS and filesystem. It is not alphabetical. It is not insertion order. If you need sorted output, collect into a Vec and sort. Trying to call sort on the iterator directly fails with E0599 (no method named sort found for struct ReadDir).

file_name can return None. entry.file_name() returns Option<&OsStr>. It returns None for paths like . or .. or absolute paths on some systems. If you call .unwrap() on file_name(), you risk a panic. Always handle the Option. Use to_string_lossy() or match on the result.

Symlinks behave differently. entry.path().is_file() follows symlinks. If you have a symlink pointing to a directory, is_file() returns false. If you have a symlink pointing to a file, is_file() returns true. entry.file_type() does not follow symlinks. It returns FileType which includes is_symlink(). If you want to skip symlinks, check file_type().is_symlink(). If you want to treat symlinks as their targets, use path().is_file().

Errors per entry are real. Network drives and active project directories often trigger errors during iteration. A file might be locked or deleted. Ignoring the Result and using unwrap() causes panics. Use filter_map(|e| e.ok()) to skip errors, or match on the Result to handle them explicitly.

Path ownership. entry.path() returns a PathBuf, not a &Path. The PathBuf owns the path. This is because the entry constructs the path by joining the directory path with the filename. The path is allocated. If you need a borrowed path, use entry.path().as_path().

Start with std. Add crates only when the patterns get heavy.

Decision: when to use read_dir versus alternatives

Use std::fs::read_dir when you need to list the immediate contents of a single directory and want zero dependencies. Use std::fs::read_dir combined with filter_map when you want to skip unreadable entries without stopping the iteration. Use entry.file_type() when you need to distinguish files from directories and want to avoid the cost of a separate metadata syscall. Use the glob crate when you need pattern matching like **/*.rs or recursive searching across subdirectories. Use the walkdir crate when you need robust recursive traversal with symlink handling and depth control. Reach for plain read_dir for simple scripts; reach for walkdir for production tools that scan project trees.

Start with std. Add crates only when the patterns get heavy.

Where to go next