How to Migrate from Rust Edition 2018 to 2021

You can migrate from Rust 2018 to 2021 by updating the `edition` field in your `Cargo.toml` files to `2021` and then running `cargo fix --edition` to automatically resolve most syntax changes.

The edition shift

You open a project you wrote six months ago. cargo build works fine, but cargo update pulls in a new dependency that demands Edition 2021. Or maybe you're joining a team and their Cargo.toml says edition = "2018", and you want to modernize the codebase. The compiler doesn't scream errors yet, but the ecosystem is moving. You need to shift your code to match the current standard without breaking everything.

Rust handles this with editions. An edition is a snapshot of the language's syntax and semantics. It lets the language evolve without breaking existing code instantly. Moving from 2018 to 2021 is a controlled upgrade. The toolchain provides a fixer that handles the mechanical changes, but you still need to understand what shifts underneath so you don't miss semantic changes.

What an edition really is

An edition is a contract between your code and the compiler. When you set edition = "2018", you're telling the compiler to apply the rules that were current in 2018, even if you're compiling with Rust 1.80. This means your 2018 code keeps compiling years later, even as the language adds new features and tightens rules.

Editions allow Rust to introduce breaking changes gradually. A change that would break 2018 code can be added to 2021. Code written for 2021 adopts the new behavior. Code written for 2018 stays safe in its time capsule. This is why you can update your Rust toolchain without fear of breaking your old projects. The edition field locks the rules.

The jump from 2018 to 2021 is relatively smooth. Most changes are syntax adjustments or stricter inference. The compiler can automate the syntax parts. You handle the semantic parts.

The migration workflow

The process has three steps. Update the configuration, run the automated fixer, and review the results.

Start by updating Cargo.toml. If you have a workspace, update the root manifest and any member crates that override the edition. The edition setting is per-crate, so every crate in your workspace needs the correct value.

[package]
name = "my-project"
version = "0.1.0"
# Bump the edition to 2021 to adopt new defaults and syntax.
edition = "2021"

[dependencies]
# Dependencies don't need edition changes; they use their own Cargo.toml.
tokio = { version = "1", features = ["full"] }

Once the configuration is updated, run the fixer. cargo fix --edition scans your source code for patterns that changed between editions and rewrites them. It understands the abstract syntax tree, so it won't break your logic. It changes syntax, not semantics.

# Run the edition fixer to apply automatic syntax updates.
cargo fix --edition

The fixer modifies files in place. It prints a summary of changes. Review the diff. The tool is reliable, but it's good practice to verify the output matches your intent. Convention dictates running cargo fix before committing edition changes so the repository stays clean.

After the fixer finishes, address any remaining warnings or errors. The fixer handles syntax, but some changes affect behavior. You'll see warnings about async inference or must_use attributes. These require manual attention.

Trust cargo fix for the mechanics, but read the warnings. The edition shift changes how the compiler interprets your code, not just how it looks.

Code changes in practice

The 2021 edition introduces a few specific changes. The most common ones involve async blocks and the #[must_use] attribute.

In 2018, the #[must_use] attribute was applied to several Result and Option methods, including unwrap(). If you called unwrap() and didn't use the result, the compiler warned you. In 2021, #[must_use] is removed from these methods. The warning disappears.

fn check_status() -> Result<(), std::io::Error> {
    Ok(())
}

fn main() {
    // In 2018, this line triggered a warning because unwrap() was #[must_use].
    // The compiler thought you might have forgotten to handle the result.
    // In 2021, this is silent. The value is dropped intentionally.
    check_status().unwrap();

    // If you want the warning back, you can add #[must_use] manually
    // or use a helper function that enforces the check.
    // Most codebases accept the silence for simple unwrap calls.
}

This change reduces noise. unwrap() is often used in fire-and-forget contexts where you know the result is Ok. The warning was helpful for beginners but annoying for experienced code. The 2021 edition assumes you know what you're doing when you call unwrap().

If your code relied on the warning to catch bugs, you might miss issues now. Review places where you called unwrap() or map() without using the result. Add explicit checks if the value matters.

Silence is not always golden. Verify that dropped results are intentional, not bugs waiting to happen.

The async inference shift

The 2021 edition tightens inference for async blocks. In 2018, the compiler was more permissive about inferring types inside async contexts. In 2021, the rules are stricter. You might need to add type annotations where the compiler used to guess correctly.

This change prevents subtle bugs where the inferred type didn't match your intent. The compiler now requires more information to resolve future types.

use std::future::Future;

// In 2018, the compiler might infer the return type loosely.
// In 2021, you often need to specify the concrete future type
// or use a trait object if the inference fails.
async fn fetch_data() -> String {
    // Simulate an async operation.
    "data".to_string()
}

fn main() {
    // If you store the future in a variable, you might need an annotation.
    // let future = fetch_data(); // Might fail inference in 2021.
    let future: impl Future<Output = String> = fetch_data();
    // The annotation helps the compiler resolve the type immediately.
}

If you encounter errors like E0277 (trait bound not satisfied) or E0308 (mismatched types) in async code after the migration, check your return types. Add explicit annotations where the compiler complains. The fixer might not catch these because they depend on your specific logic.

The stricter inference is a feature, not a bug. It catches type mismatches early. Adapt your code by adding annotations where needed.

Don't fight the inference rules. Add the type annotation and move on.

Pitfalls and warnings

The migration is mostly smooth, but a few pitfalls exist. cargo fix handles syntax, but it doesn't change your logic. If your code relied on 2018-specific behavior, you need to update it manually.

One common issue is impl Trait in return positions. The 2021 edition adjusts how impl Trait interacts with lifetimes and variance. You might see errors where the compiler can no longer infer lifetimes correctly.

fn get_ref<'a>(s: &'a str) -> impl std::fmt::Display + 'a {
    // In 2018, this might have compiled with loose lifetime inference.
    // In 2021, the lifetime bounds must be explicit.
    s
}

fn main() {
    let text = "hello";
    // The compiler rejects this if lifetimes don't align.
    // E0597 might appear if a temporary value doesn't live long enough.
    let display = get_ref(text);
    println!("{}", display);
}

Another pitfall is dependencies. Some crates might have breaking changes between versions that align with the edition shift. If cargo build fails after the migration, check your dependency versions. Update crates that require 2021 features.

Run your full test suite after the migration. cargo test catches logic errors that the compiler misses. The 2021 edition is backward compatible in most cases, but stricter type checking can expose latent bugs in complex generic code.

Review compiler warnings carefully. Warnings often indicate semantic changes. A warning about unused variables might mean the variable is no longer needed due to inference changes. A warning about trait bounds might mean a dependency updated its API.

Treat warnings as errors during migration. Fix them all before committing.

Decision matrix

Use cargo fix --edition when you have updated the edition field and need to apply syntax rewrites automatically. Use manual review when cargo fix leaves warnings about semantic changes like must_use behavior or async inference. Use cargo test when the fixer finishes to verify logic integrity across the codebase. Reach for dependency updates when cargo build fails with version conflicts after the edition switch. Pick explicit type annotations when the compiler rejects async blocks due to inference failures. Trust the borrow checker when it flags lifetime issues in impl Trait returns; the 2021 rules are stricter for a reason.

The edition is a promise to the compiler, not a magic wand. Update the config, run the fixer, review the warnings, and test thoroughly. Your code will be modern and safe.

Where to go next