Have you been contemplating a switch to the Rust 2024 edition for your project, but are wondering how to go about it?
This is a description of my experience upgrading to the 2024 edition in a very large workspace. The workspace has close to 400 crates, and more than 1500 rust dependencies. It has a few years of history, and has been touched by a lot of developers with varying experience levels. Everything works on rust 1.84 using the 2021 edition, with no clippy warnings using the default lint settings.
The usual advice for upgrading to the new edition goes like this:
- Run
cargo fix --edition
- Add
edition = "2024"
toCargo.toml
That's not how I'm going to approach the upgrade. My reasons are:
- It's too big a change to take on all at once. This is a production codebase, and we need to be able to fix regressions. One giant commit touching thousands of lines of code across hundreds of crates doesn't lend itself to easy debugging or bisecting.
- We don't want all of the automatically generated changes. In a lot of places we would prefer the shorter temporary lifetimes in
if-let
expressions, rather than transforming them tomatch
expressions. And most of our macros will work just fine with the newly-expandedexpr
definitions. - I want to be able to explain the changes made to other engineers. To do that, I need to understand them myself.
- I want to be able to make changes in stages. If I can detect and fix issues ahead of time, in a way that's compatible with both the 2021 and 2024 editions, I definitely want to do it that way. This also means that changes can easily be reverted if I break something.
- The bigger the change, the longer it's going to take to code review. I want to see meaningful reviews, and that means limiting the size of each change.
- Any project with a lot of engineers has a lot of concurrent work. It's probably a bad time when you need everyone to stop working while a huge change lands. If we can make this transition look like ordinary daily progress, then that's better for everyone.
- It's a learning experience for all of us. I learned a bunch of new things about the language by going through this upgrade in detail, and I get to share that knowledge with the team, and add meaningful comments and commit messages conveying our understanding of the code.
![Rust Logo](/images/rust_logo_hand_drawn_1b.png)
Q&A about the 2024 edition upgrade §
Should I update? Why? When?
For most organizations, the 2024 edition is probably something you can ignore if you want, until the point where you need the new features. We tend to upgrade very soon after new toolchains are released, and often new language features give us new abilities and new lints help us find latent bugs.
Should we migrate by language change, or by crate? Or both?
That's entirely up to you. If you have loosely coupled crates it might make sense to go crate by crate. We have one big workspace where crates are tightly coupled, so it's easiest for us to update the whole workspace at once. I split up work by each 2024 edition change, as the changes look fairly similar across all crates, and our decision about how to handle the transition can be applied consistently to all of our code.
Which changes should go first?
I didn't know this until I started experimenting, but after some experimentation I chose the following order:
- Update any crates that do code generation (e.g.
bindgen
orcxx
), as proc-macros that write code will produce confusing lint output and can breakcargo fix
. - While on the 2021 edition, enable the rust-2024-compatibility lints one at a time, adding changes as needed.
- Upgrade to Rust 1.85 or newer, to get support for the 2024 edition. Fix any lint warnings from the new toolchain.
- If you don't want to reformat all your code, add
style_edition = "2021"
to rustfmt.toml in your workspace. - Set
edition = "2024"
inCargo.toml
. This can be slow-rolled, one crate at a time if necessary. I just did them all at once, since by that point most work was already complete. - Fix up any errors and warnings that appear after the edition switch.
Are there any downsides to the one-lint-at-a-time approach?
If you just don't care about the details, and just want to get the transition over with, it's probably easier to just cargo fix --edition
, set edition = "2024"
and then fix any remaining errors or warnings. If your project is small, the number of changes might also be small. It's possible to try this once and then throw away the changes if you don't feel comfortable.
I should also note that the lints won't catch everything, and not all lint warnings can be resolved by cargo fix
. For example, exported macro_rules
macros that are only used in other crates won't trigger lint warnings. And some RPIT over-captures can't be automatically fixed, if the captured input lifetimes come from an argument-position impl Trait
. These may result in compilation errors once the edition switch is applied.
Upgrading code generating tools §
Our workspace uses bindgen
and cxx
to autogenerate some FFI interface code. I found out quickly that cargo fix --edition
doesn't do well when these tools are not generating code that's compatible with the 2024 edition. Fortunately for us, the upgrade was pretty straightfoward: upgrade bindgen
to 0.71 or newer, and then upgrade cxx
to 1.0.130 or newer (along with cxx-build
and cxx-gen
).
Note that even an updated bindgen
won't necessarily resolve every issue: for example bindgen 0.71.1
doesn't emit unsafe
blocks in unsafe fn
s, which may trigger a rustc lint. Unless this changes in the future, the easiest fix is to ignore that lint where the bindings are included. include!
d bindings can be handled within a module like this:
mod bindings {
#![allow(unsafe_op_in_unsafe_fn)]
include!("bindings.rs");
}
Enabling rust 2024 compatibility lints. §
Our lint config wasn't being done at the workspace level yet, so I first needed to set that up. Lints can be added to a table in the workspace Cargo.toml
:
[workspace.lints.rust]
# Add lints here
And every workspace crate can reuse those workspace lints with:
[lints]
workspace = true
This is a lot of boilerplate; I wish there was a way to have workspace-level lints be automatically enabled without needing to touch so many files. It's also inflexible: workspace lint config can't be selectively overriden by crates' Cargo.toml
. You can still do it in the code, using #![warn(lint-name)]
in lib.rs
and main.rs
(repeat for each binary, example, integration test, etc.)
Now that workspace members are sharing the same lints table, we can start enabling new lints. For example:
[lints.rust]
# Enable warnings on patterns that need changes for the 2024 edition.
rust-2024-incompatible-pat = "warn"
You can find the entire list of compatibility lints here. If you want to enable them all at once, you can use the group name rust-2024-compatibility
, which enables all 16 lints at once.
Now cargo check
(or rust-analyzer) will show you the places where problems appear. The next task is to fix the code until the warnings are gone. This can be done by hand, using cargo fix
, or a mix of both. I used cargo fix
until I got a sense for what needed to change, and then I made my own changes to improve the readability of the resulting code.
Here are some of my notes about the changes that I found interesting:
Newly unsafe functions §
Lint:
deprecated-safe-2024
Rust edition guide: Newly unsafe functions
In the 2024 edition a few standard library functions have been made unsafe
to call. The hazards in these functions have been known for years, and finally a consensus was reached to use the edition boundary to mark them unsafe, which is a breaking change.
The most significant change is to std::env::set_var
. We found fewer than 10 places in our code calling this function. Most of them were testing hacks, and over the course of a week we were able to find people familiar with the affected crates and remove the set_var
. This was replaced with various other strategies, usually static variables to track global state or Command::env
when spawning other processes.
pub fn dangerous_env_games() {
// Don't do this; undefined behavior may result.
env::set_var("SECRET_TOKEN", "password!123");
}
The one tricky use of set_env
was in a build.rs
helper function, setting up a call to bindgen with LIBCLANG_PATH
set. I looked for a few hours, but I couldn't figure out how to replace this code, as bindgen::Builder
doesn't have a way to set environment variables on anything it might call. In the end we marked our bindgen wrapper function as unsafe
, and checked that all its callers (build.rs
in several crates) all ran single-threaded.
The other newly unsafe method that affected us was Command::pre_exec
. This function was used to call libc::setpgid
. Since setpgid
is listed as safe in the signal-safety
man page, I concluded that this usage was sound and added an unsafe
block. This had an extra twist: because of tail expression lifetime changes in rust 2024, this code no longer compiles:
unsafe {
Command::new(command).pre_exec(|| {
// Do something here, but be careful!
Ok(())
})
}
.status()
The Command
is dropped at the end of the unsafe
block, because before_exec
returns &mut Command
. In the 2021 edition, the temporary Command
instance can outlive the block, but that's no longer true in the 2024 edition. So we need to replace that code with something like:
let mut cmd = Command::new(command);
unsafe {
cmd.pre_exec(|| {
// Do something here, but be careful!
Ok(())
});
}
cmd.status()
Unsafe block needed inside unsafe fn §
Lint:
unsafe-op-in-unsafe-fn
Rust edition guide: unsafe_op_in_unsafe_fn warning
unsafe_op_in_unsafe_fn
is only a lint in the 2024 edition, but it's enabled by default. We honor clippy's default lints, but we want this one anyway, as it helps constrain unsafe
actions to the smallest possible scope. The changes were pretty straightforward:
- Enable the lint and look at where the warnings appear.
- For places where the warnings appear in auto-generated code, wrap the
include!
macro in a module with the lint#allow
'ed. There doesn't seem to be a way to getbindgen
to emitunsafe
blocks inside autogeneratedunsafe fn
s, but since this code isn't human-maintained, it's no worse to ignore the lint than to have machine-generated unsafe blocks. - Use
cargo fix
to add all theunsafe
blocks tounsafe fn
s. - Because the automatically-added blocks were generous in scope, reduce the scope of each
unsafe
block as needed. - Ideally, domain experts would step in to add safety documentation to each unsafe block. Most of these
unsafe fn
s already had their invariants documented, and usually the functions were short enough that that invariant was shared with the inner unsafe actions. In a few cases this was left as work for the future.
impl Header {
/// SAFETY: bytes must have 4-byte alignment.
pub unsafe fn from_bytes(bytes: [u8; 8]) -> Self {
// (I don't endorse this transmute. Use bytemuck or zerocopy or something similar.)
// In Rust 2024 this needs to be wrapped in an `unsafe` block.
mem::transmute(bytes)
}
}
The gen
keyword §
Lint:
keyword-idents-2024
Rust edition guide: gen keyword
As you might guess, the only places we were affected by this is in code that uses the rand
crate. I experimented with cargo fix
, but since that just papers over the issue with r#gen
, I upgraded our code base to the freshly released rand 0.9
.
This took a bit of work, but the Rust Rand Book contains a nice guide to updating, which made the job easier. Most of the changes are simple renames (thread_rng -> rng
, gen/gen_bool/gen_range -> random/random_bool/...
, from_entropy -> from_os_rng
, and SliceRandom-> IndexedRandom
. Additionally, in rand 0.9 there is no longer an impl rand::Fill
for usize
. We did use this in one place, but that code turned out to be unneeded so we were able to remove it.
Since rand 0.9
depends on getrandom 0.3
, I also upgraded that crate. This required changes to a few crates that can be built for webassembly targets, as there are feature and cfg changes required to keep those webassembly targets building.
Using rand 0.9
this soon after its release could cause type mismatch issues, if its types and traits appear in the public interfaces of other crates. Fortunately, none of these issues caused immediate problems in our workspace. Hopefully these other crates will add support for rand 0.9
fairly soon, so any problems we encounter will be short-lived.
Of course, rand 0.8
and getrandom 0.2
are still in our dependency tree due to other dependencies not being updated. In a workspace this big, this in not a solveable problem— there are always dependencies moving at different speeds, so duplicate dependency versions are an unfortunate reality.
/// Can I buy a real D256?
pub struct Die {
value: u8,
}
impl Die {
pub fn roll() -> Self {
// In the 2024 edition, `gen` is a keyword.
// rand 0.9 renames this to `random()`.
let value = rand::thread_rng().gen();
Self { value }
}
}
Match Ergonomics pattern changes §
Lint:
rust-2024-incompatible-pat
Rust edition guide: Match ergonomics reservations
I found this change tricky. I was starting off at a disadvantage, because I didn't know what "match ergonomics" or "binding mode" meant. So the warnings seemed confusing, and the cargo fix
changes didn't seem very ergonomic.
pub struct Name {
value: Option<String>,
}
impl Name {
pub fn is_short(&self) -> bool {
match &self.value {
Some(ref val) => val.len() <= 4,
None => true,
}
}
}
This prints:
warning: this pattern relies on behavior which may change in edition 2024
--> src/pattern.rs:8:18
|
8 | Some(ref val) => val.len() <= 4,
| ^^^ cannot override to bind by-reference when that is the implicit default
|
= warning: this changes meaning in Rust 2024
= note: for more information, see <https://doc.rust-lang.org/nightly/edition-guide/rust-2024/match-ergonomics.html>
= note: requested on the command line with `-W rust-2024-incompatible-pat`
help: make the implied reference pattern explicit
|
8 | &Some(ref val) => val.len() <= 4,
| +
If you weren't using rust before 2017, and want an introduction to these concepts, a good start is to read RFC 2005; this explains how tedious matching was in early versions of rust and how the "match ergonomics" transformations make patterns easier to use.
As far as I can tell, the rust 2024 edition pattern changes aren't an improvement yet— in some cases they may even be a temporary ergonomics setback. Hopefully, this is a temporary state, and sometime in the near future, better match ergonomics will be added to handle the most common cases. If you want a deep dive into match ergonomics progress, this video provides a fascinating explanation. Also, RFC 3627 explains the goal of the new ergonomics (which are incomplete, according to the tracking issue.)
My strategy was to first let cargo fix
apply automatic fixes. In every case I saw, it was adding an extra &
to disable the match ergonomics transformation. However, I noticed a lot of places where there was a better fix available. If the previous code had an unnecessary ref
in the pattern, then sometimes it could be fixed like this:
let x: &Option<T>;
match x {
// Original code. Uses rust2021 match ergonomics, which
// allows a useless `ref`.
Some(ref foo) => foo.stuff(),
// `cargo fix` will change it to this, disabling match
// ergonomics, using an explicit `&` and `ref`.
&Some(ref foo) => foo.stuff(),
// But really, all you need is this. It uses simpler syntax,
// which is allowed in both rust 2021 and 2024.
Some(foo) => foo.stuff(),
}
As a final twist, we had a macro_rules
macro applying a match
statement that was being a bit sneaky: it matched an ident
parameter, that could be either Option<T>
or &Option<T>
, using the syntax Some(ref x)
. This doesn't work in the 2024 edition for the &Option<T>
case, but once we untangled the problem we were able to resolve it by having the macro call Option::as_ref()
to create a temporary value that unifies the two cases.
UPDATE 2025-02-08: With the merge of #136577, nightly rustc will now suggest a better fix for Some(ref x)
. Just like my example above, it will choose the more concise Some(x)
. This change will be in rust 1.86, and may be backported to 1.85— I will update this text if it is. This can't be done in all cases, but it makes cargo fix
choose the most ergonomic syntax so this kind of manual review is probably no longer necessary.
Shorter if-let temporary lifetimes §
Lint:
if-let-rescope
Rust edition guide: if let temporary scope
In the 2024 edition, there will be a nice improvement to if-let
expressions, allowing temporary values to be dropped earlier. The edition guide provides a good explanation, so I won't repeat it here. However, I may editorialize a bit about the merits of applying the suggested changes.
I'm not enthusiastic about the suggested change from cargo fix
, because I don't think anyone who wrote this:
if let Some(x) = self.x.lock().as_ref() {
x.do_something();
} else {
log::error!("oops!");
}
wants to see that code transformed into this:
match self.x.lock().as_ref() {
Some(x) => {
x.do_something();
}
None => {
log::error!("oops!");
}
}
The difference may be trivial, but as the code gets more complex, every layer of nesting does damage to the readability of the code.
More importantly, it's probably unnecessary. The code change produced by cargo fix
is not really a "fix"— it's just expressing the maximally conservative view: "your code will behave exactly as before if you make this change." In many cases the new shorter 2024 lifetime will be fine. You might even want the new shorter lifetimes! In the code above, holding a mutex in the else
branch while logging an error is a bad idea: logging functions often do I/O, which can cause a thread to block. Ideally mutexes should be dropped before doing that I/O so that other threads can make progress.
If a shorter lifetime would break your code, then maybe that code is already so fragile that it wants a better fix. For example, if dropping a guard object results in unexpected actions, maybe that guard object isn't protecting the right resources.
While the lint warning text and cargo fix
suggested changes should provoke some discussion about the 2024 edition and how it will affect your code, I don't think these changes should be applied unless you're very conservative. If you're willing to take some chances, I think it's a better idea to just skip them, and fix any regressions as they're found. Those regressions were a hazard just waiting to jump out and bite you at the worst moment, so maybe it's best to just provoke them now and get it over with.
macro_rules expr changes §
Lint:
edition-2024-expr-fragment-specifier
Rust edition guide: Macro Fragment Specifiers
The automated fix is simple: it just replaces expr
with expr_2021
in macro_rules!
definitions. However, most trivial macros that take expr
arguments probably don't care about the subtle difference between rust 2021 and 2024. If nothing breaks without the changes, it's probably safe to skip them. If not, use expr_2021
.
return-position-impl-trait (RPIT) capture rules §
Lint:
impl-trait-overcaptures
Rust edition guide: RPIT lifetime capture rules
This is another change where it's probably best to use cargo fix
to apply the changes. The modification looks pretty straightforward (adding + use <>
, + use<'_>
, or + use <T>
) to places that return impl Trait
.
I haven't used the new + use
syntax yet much myself, so I only have a basic understanding of what's going on here. I'll just quote the edition guide:
In Rust 2024, all in-scope generic parameters, including lifetime parameters, are implicitly captured when the
use<..>
bound is not present.
So it makes sense that adding the new bound to cases where those new lifetime bounds would be unnecessary. It our code this was mostly ad-hoc implementations of IntoIterator
in generic functions, where some of the generic parameters had lifetimes that were irrelevant to the resulting iterator.
Rustfmt §
The only bit of work remaining is the rustfmt
formatting changes. I'd like to switch over to the 2024 style edition but I haven't figured out how to land that change without causing merge issues with concurrent PRs. Either I'll try to land the changes during a quiet weekend, or I may try to use rustfmt.toml
to disable the noisiest changes.
What's changing in rustfmt? The most noticeable change is that imports are sorted differently.
2021 rustfmt style edition:
// module, type, const
use some_crate::{detail::func, SomeType, SOME_CONST};
// alphabetic sort
use other_crate::{Thing16, Thing32, Thing8};
2024 rustfmt style edition:
// const, type, module
use some_crate::{SOME_CONST, SomeType, detail::func};
// natural sort
use other_crate::{Thing8, Thing16, Thing32};
It also comes with a lot of small fixes. For example, when a format string gets too long it used to cause rustfmt
to give up, so we had a few places where the code looked like this:
eprintln!(
"A long string that contains a bunch of {} parameters and a lot of other text that goes on for a while",
status
.data
.map(|more_data| more_data.to_string())
.unwrap_or("(unknown)".into()),
);
In the rustfmt 2024 style edition the call chain gets indented as you would expect:
eprintln!(
"A long string that contains a bunch of {} parameters and a lot of other text that goes on for a while",
status
.data
.map(|more_data| more_data.to_string())
.unwrap_or("(unknown)".into()),
);
Closing notes §
I think I'm all set for the rust 1.85 toolchain to arrive!
There are a few other changes that had extremely minor impacts on our code:
Removing explicit imports for things (e.g. Future
) that are now in the prelude can't be done until the 2024 edition is in use; after that it can go in any time.
The other two only affect one or two files; I have no problem bundling these changes in with the switch to rust 2024, once the Rust 1.85 toolchain is stable.
That's all for today; thanks for reading. Enjoy the Rust 2024 edition!
Feel free to get in touch with any comments or fixes, on the fediverse or on github: I've included some code examples here.