Last Thursday Rust 1.85 was released, and with it, edition 2024 has dropped. The new edition is significantly larger than the two editions that preceded it, and contains many small but significant quality of life improvements to the language. In this post, I’d like to explain what an edition is, and summarize all the changes that were made to the language I love. If you need the details, I recommend reading the edition guide, but for a general overview, read on.

What is an edition?

Rust has, at the time when 1.0 was being finalized, made a promise: code that compiles with any 1.x version of the compiler, should compile without hassle with a later 1.y version of the compiler. Unless behaviour was obviously a bug, or code should keep compiling. This is a beautiful ideal, but it also limits what changes you can make. This is where editions come in.

An edition in Rust is effectively a compatibility mode for the compiler. It is a way to make certain changes “not exist” for older code, while allowing improvements for newer code. Code compiled in older editions can be freely mixed with code with newer editions and vice versa. This makes it easier to upgrade to newer compiler versions, as you can be generally sure that code will continue to compile and work as expected.

As a simple example, the first ever edition, in 2018, made async a keyword. That meant that variables could no longer be called async, but that was deemed worth it in order to enable asynchronous programming. Crucially, code that existed before that time, could now use what is called “edition 2015”, could continue to exist unchanged and new code could depend on existing libraries, while using newer features itself.

Of course, that means that code compiled with edition 2015 could not use async fn, but that functionality wouldn’t be released until late 2019 anyway. Edition 2024 offers a similar selection of small changes, fixing small pain points in the language that would otherwise break the backwards compatibility promise.

Core language

Most of the changes in Edition 2024 affect the core language constructs and as such will be the most visible, as they apply regardless of what standard library features you use. Luckily, only one is likely to require manual changes to your code base, if at all. Let’s walk through them.

Return-position impl Trait lifetime capture rules

Putting impl Trait in the position of the return type can make your code easier to read, but it can cause issues when lifetimes get involved.

1
2
3
fn numbers(nums: &[i32]) -> impl Iterator<Item=i32> {
  nums.iter().copied()
}

The above will fail to compile in edition 2021, as the type impl Iterator is assumed to be 'static, that is, it doesn’t borrow anything, while it instead borrows from the nums slice.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
   Compiling playground v0.0.1 (/playground)
error[E0700]: hidden type for `impl Iterator<Item = i32>` captures lifetime that does not appear in bounds
 --> src/lib.rs:2:3
  |
1 | fn numbers(nums: &[i32]) -> impl Iterator<Item=i32> {
  |                  ------     ----------------------- opaque type defined here
  |                  |
  |                  hidden type `Copied<std::slice::Iter<'_, i32>>` captures the anonymous lifetime defined here
2 |   nums.iter().copied()
  |   ^^^^^^^^^^^^^^^^^^^^
  |
help: add a `use<...>` bound to explicitly capture `'_`
  |
1 | fn numbers(nums: &[i32]) -> impl Iterator<Item=i32> + use<'_> {
  |                                                     +++++++++

The error message even has the fix: add + use<'_> to state that the lifetime of the slice is captured into the returned iterator, so the compiler may correctly reason about its use.

The change coming in edition 2024 is a change in default captures. Instead of capturing none of the lifetime by default, impl Trait will now by default capture all lifetimes. That means that with edition 2024, the example above will compile as-is. To revert to the previous behaviour, you can explicitly capture no lifetimes:

1
2
3
4
fn display(label: &str, ret: impl Sized) -> impl Sized + use<> {
    println!("{label}");
    ret
}

I’m not sure whether this change is an improvement; in my code it empirically introduces more unnecessary lifetimes than it removes workarounds. 'static lifetime by default seems like the more sensible choice. Nevertheless, the change has been made, so

References:

Changes to temporary scope

Edition 2024 comes with two small quality of life changes with respect to the lifetime of specific temporaries. These are situations you might have hit already, that have easy workarounds, but now they just work out of the box.

In edition 2021, when using if let, any temporaries created in the matching expression will live for the entirety of the if/else if/else branch. This can have some unexpected results:

1
2
3
4
5
6
7
8
9
fn get_cached_or_init(cache: &RefCell<Option<String>>) -> String {
    if let Some(value) = cache.borrow() {
        value.clone()
    } else {
        let value = complicated_string_gen();
        *cache.borrow_mut() = value.clone(); // PANIC! Already borrowed
        value
    }
}

Starting edition 2024, the temporary scope for the guard expression will end at the end of the if branch, and the above code will be valid as-is. If you need the guard to remain valid for the entire duration, you can use a match block instead.

Another small change has to do with the lifetime of temporaries in tail expressions. According to the 2021 temporary scope rules, a temporary created inside such a tail expression will live until the end of the next temporary scope, which is after local variables have been dropped. This can cause unexpected errors:

1
2
3
4
5
// Before 2024
fn f() -> usize {
    let c = RefCell::new("..");
    c.borrow().len() // error[E0597]: `c` does not live long enough
}

Example taken from the edition guide as I have hit this only once, ever, and couldn’t think of a simple use case. Nevertheless, the Borrow temporary generated by borrow() is here expected to live until the end of the function, but c is cleaned up before that, so it doesn’t work. In edition 2024, the temporary’s lifetime is shortened and it will compile as-is.

This may in some cases have side effects when used with temporary lifetime extension, but I expect this will be unlikely to occur in real code.

References:

Match ergonomics reservations

There is a small change to how ref, mut, and ref mut and friends may be used. In edition 2024, it is an error to use these capture modifiers in a non-explicit pattern, that is, a pattern using match ergonomics. In practice, this looks as follows:

1
2
3
4
5
6
7
8
9
10
11
12
13
fn f(opt: &mut Option<i32>) {
    if let &mut Some(ref mut val) = opt {
        // valid, val is &mut i32
    }

    if let Some(val) = opt {
        // valid, val is &mut i32, match ergonomics used
    }

    if let Some(mut val) = opt {
        // Valid in edition 2021, invalid in edition 2024
    }
}

The stated reason for this change is that the syntax is intended to be used for match ergonomics expansion in the future. I don’t know what this expansion would look like, so I can’t comment on the usefulness, but I will say that the current compiler interpretation is not that useful or predictable, so not a lot is lost.

References:

Changes to unsafe

unsafe in Rust is used to denote sections of code where the compiler cannot prove all the properties of the code that are required for the code to be well-behaved. Edition 2024 requires you to mark more sections as unsafe that previously did not require it.

The first change, is that extern blocks are now required to be marked as unsafe. Function and constant definitions in an extern block must match their counterparts across the FFI bridge, but the compiler cannot ascertain that they do. Marking the block unsafe should signal the developer that they must pay attention.

Rust added the ability for attributes to be unsafe as well. A few existing attributes now require being marked unsafe. The names used for #[no_mangle] and #[export_name] need to be globally unique, otherwise unexpected linking errors may occur. As such, they are now #[unsafe(no_mangle)] and #[unsafe(export_name)]. In addition, #[link_section] must now be unsafe, as it can be used to add symbols to a section that might not work, such as adding mutable data to a read-only section.

References:

No more references to static mut data

In Rust, the existence of a reference &T presumes (barring interior mutability) no writes will happen to the data being referenced. This has always been in conflict with static mut variables, as any thread anywhere may be writing to them at any point in time. Edition 2024 therefore makes it an error to have a reference to static mut data. This was previously a warning.

The primary solution to get around this, is by not having static mut data. Make sure your global data is covered by a mutex or similar. If you must have static mut, any access to it will have to go through the pointer APIs, and you will have to take responsibility for upholding Rust’s safety guarantees.

References:

Never type coercion

The Never type, !, is a type of which no value can ever exist. This is used in the language as the return value for functions that do not return, such as panic!(). It signals to the compiler and to a developer that a code path will never (heh) be taken. To ease type checking in these scenarios, the Never type will coerce to any other type.

Sometimes, the code doesn’t offer an unambiguous type to coerce to, in which case the fallback is used. Edition 2024 changes the fallback for this coercion from the unit type () to the Never type itself. This might cause minor issues for cases where a trait is implemented for () but not for !. The workaround is to explicitly coerce to the unit type in those situations by adding type annotations.

References:

Macro fragment specifiers

Macro fragment specifiers have, in my opinion, an unfortunate name. What it refers to is effectively the type of an argument of a macro_rules!-style macro.

1
2
3
4
5
6
macro_rules! my_print {
    ($val:expr) => { print!("{}", $val); };
}

// later
my_print!(1 + 2 + 3); // prints 6

Here, $val is captured, but only if it is an expression. my_print!(match) would not be accepted. Edition 2024 brings the :tt fragment specifier up-to-date with the new additions to the language, allowing it to match _- and const expressions as part of an :expr fragment. If you must maintain the previous behaviour, you can replace :expr with :expr_2021.

In addition, it is now an error to have macro fragments without type specifiers. Previously, this would be alright, as long as the variant with the missing fragment specifier wasn’t matched. Simply remove the variant without the specifier, or add an appropriate specifier.

1
2
3
4
5
6
7
// Compiles in edition 2021, compile error in edition 2024
macro_rules! broken_macro {
    () => { println!("Valid variant"); };
    ($without_specifier) => { unreachable!("Cannot match this"); };
}

broken_macro!();

References:

New reserved constructs

Edition 2024 adds a new reserved keyword, gen, in anticipation of the upcoming generators which may or may not be called coroutines. If you really want to continue naming your variables gen, you can use raw identifiers.

In addition, Rust now reserves one or more # in a row followed by a string literal, and two or more # in a row not separated by whitespace. There are no workarounds to this.

References:

Standard library

Aside from core language features, the standard library also has some backwards-incompatible changes.

Changes to the prelude

The prelude defines all the types you never have to import, like String. In edition 2024, Future and IntoFuture are added to the prelude. At this point, six years after the async keyword was introduced, this is unlikely to cause issues in regular code bases.

References:

Add IntoIterator to boxed slices

In all editions, Box<[T]> will now implement IntoIterator<Item=T>. This could be a breaking change, as previously, my_box.into_iter() would evaluate to &[T]::into_iter, which iterates by reference rather than by value.

To mitigate this, direct calls to Box::<[T]>::into_iterator will be hidden in previous editions, which maintains the previous behaviour. You might remember this is the same workaround that was chosen back in edition 2021, when IntoIterator was added to [T; N].

Train implementations cannot be edition specific, but this way, old code gets to keep working.

References:

Newly unsafe functions

Over the years, some functions have been added to the standard library that turned out to be problematic in certain cases. Despite attempts to make them safe and well-behaved in all circumstances, they fundamentally cannot be. Edition 2024 marks these functions as unsafe to signal that these functions require special care.

Two functions for modifying environment variables, std::env::{set_var, remove_var} have been the subject of a complicated range of CVEs I don’t want to get in to, but crucially, they cannot be used safely in a multithreaded environment. Rust tried to fix this by adding a RwLock internally that protects the internal state, but this turns out to still be unsafe when combined with the many libc functions that might be calling getenv in unrelated threads. As such, these functions are now unsafe and should only be used in single-threaded environments.

std::os::unix::process::CommandExt::before_exec is also unsafe now, in addition to being deprecated. pre_exec was introduced in Rust 1.37 as an unsafe replacement for it, but now before_exec will reflect its unsafe status.

References:

Cargo

The package manager/build system/holy grail received some minor breaking changes that will generally make life easier.

First, cargo got a new dependency resolver that will take the current Rust version into account. In previous editions, this resolver can be opted into by setting resolver = "3". From my initial testing, it doesn’t work as well as it could, mostly because crates claim supported rust versions they very much do not. I expect this to get better in the future, and to at the very least make it easier to test for rust version support and maintain backwards compatibility.

A minor change is that the naming of options has been made more consistent. Cargo.toml (and related) now consistently use kebab-case for all keys. Previously, snake_case was allowed for some (but not all) keys.

Finally, there is some clean-up with workspace dependencies: it is now an error to disable default-features for an inherited dependency when the workspace does not disable default-features. This never worked; the dependency would always have its default features enabled regardless as features in Rust are additive. Now the compiler will simply let you know.

References:

Rustdoc

While technically breaking changes, Rustdoc mainly received some nice quality-of-life improvements. In edition 2024, Rustdoc tests will be combined into a single binary as much as possible. This should generally improve compile times for the tests. It is not always possible to combine tests. compile_fail examples will always be compiled separately, and some other obvious breakage will be detected and avoided. If you need to enforce that an example is compiled separately, you can add the standalone_crate language tag to ensure that specific example will be compiled on its own.

1
2
3
4
5
/// Compiles example code
///
/// ```standalone_crate
/// println!("Hello, world!");
/// ```

A smaller, but more breaking change is that nested include!s in your documentation are now handled relative to the included document rather than to the original source location. That is, if your code uses something like #[doc=include_str!(../README.md)], any include_bytes!() that might be in your examples will now be handled relative to README.md.

Rustfmt

The biggest change to Rustfmt is that as of edition 2024, Rustfmt can have its own editions with backwards-incompatible changes now. By default, it will use the same edition for formatting as it will for compiling, but you can override this by setting style_edition in rustfmt.toml. This allows you to opt in to improved formatting, while not raising the minimum supported rust version. That said, let’s look at the breaking changes that are included.

Formatting fixes

Most of the changes to Rustfmt don’t involve sweeping changes to the resulting format, but rather fix circumstances where the formatter did not work correctly. The edition guide has examples for all changes, but I’ve tried to summarize them here.

  • Trailing comments after a line-end comment will no longer be aligned as if they were related.
  • Strings in comments will no longer be indented.
  • Long strings will no longer cause the formatter to stop formatting the expression that contains them.
  • impl block generics will no longer have a double-indent.
  • Some complicated fn blocks would previously get a strange indentation and no longer do. The complex fn blocks required to trigger this are still not very readable.
  • Nested tuple indexings will no longer get an extraneous space.
  • return, break, and continue statements inside a match block now consistently send with a semicolon.
  • Long array and slice patterns now wrap to avoid overflow.
  • The last expression statement in a block will now be formatted as a one-liner most of the time.
  • The formatting between a macro call and a function call is now more consistent.
  • Closures containing a single loop will now get a block to wrap the closure body. This is the only change that feels like a regression to me.
  • Empty lines in where blocks are now removed.
  • else clauses from a let else with an attribute are no longer formatted incorrectly.
  • Comment- and macro argument wrapping no longer has an off-by-one bug.
  • Comments with => inside a match block are no longer formatted incorrectly.
  • Having more than one inner attributes in a match block no longer causes extraneous indents.

All but one of these should only ever improve formatting in my opinion, thus working around this shouldn’t be necessary, but you can still ignore these improvements and avoid a diff in your code by opting for style edition 2021 now.

References:

Sort order changes

Rustfmt doesn’t sort things often in source code, but when it does, the resulting order makes sense. Edition 2024 makes two changes to this. First, raw identifiers, used when an identifier overlaps with a keyword, will now correctly sort in the position the identifier would, rather than sorting based on the raw identifier prefix r#. This should generally put the resulting list in a more “sensible” order.

In addition to that, the sort order was changed from lexicographical to an algorithm the guide refers to as “version-sort.” The main difference to this is that it will handle strings containing numbers better, so that NonZeroU8 will now sort before NonZeroU16 rather than after it, and lowercase characters will now sort after uppercase characters.

References:

To summarize

Rust 2024 is characterized not by big changes, but rather by a plethora of small ones. I expect for most code bases, very few changes will have to be made, if any at all, and most of the changes that do have to be made can be addressed by cargo fix.

To me, this seems like a healthy state for the language. Change keeps happening, but most of it can be relied on to remain recognizable to people in the future. The language isn’t stagnating, but it’s also not adding a torrent of new features and syntax any more. This kind of slow evolution should allow for greater adoption in the real world, as the world sees that you don’t have to spend half your time keeping up with all the new changes. The future of the language seems alright, and I’m here for it.

Acknowledgements

Content in this article summarizes and uses small sections of the 2024 Rust edition guide. I have attempted to provide clear attributes from sections that were directly taken. The contents of this book are a product of many contributors and is available under the MIT and Apache 2.0 licences.

Cover image “String rope rust chains” by Jacques from Pixabay.