Skip to content

Rust Traits: The Solver Underneath Them Just Got Replaced

Rust Traits: The Solver Underneath Them Just Got Replaced

I keep a scratch crate on my laptop called whytho. It exists for one reason. Every few months I hit a trait bound that Rust refuses to accept, I can’t explain why, and I go there to shrink the failing thing down until the compiler says something I can act on. Nine times out of ten the answer is that I wrote it wrong. The tenth time I never find out, because “maybe the compiler is wrong” is not a conclusion you reach at 11pm on a Tuesday. You reach for a Box<dyn Trait> and go to bed.

It turns out I had more tenth times than I thought.

On 21 August the Rust team switched the next-generation trait solver on by default in nightly. Four years of work, and in their own words, the largest single change to the compiler since its initial release. It replaces how the compiler proves where-clauses and normalises associated types. That sounds like compiler-team housekeeping until you notice what it means: every trait bound you have ever written is now resolved by different code.

Your syntax doesn’t change. What the compiler accepts changes, in both directions.

What a trait solver is doing while you wait

Most of us treat trait resolution as a thing that either works or produces four screens of red text. It helps to know what’s underneath.

Say you write this:

fn print_all<T: IntoIterator>(items: T)
where
    T::Item: std::fmt::Debug,
{
    for item in items {
        println!("{item:?}");
    }
}

When you call print_all(vec![1, 2, 3]), the compiler has a small logic problem to solve. It has to prove Vec<i32>: IntoIterator. Then it has to work out what <Vec<i32> as IntoIterator>::Item actually is, which is called normalisation, and lands on i32. Then it has to prove i32: Debug. Each of those proofs can spawn more proofs, and the whole thing is a search with backtracking, caching, and cycle detection.

That search is the trait solver. You only ever meet it in two situations. It fails and hands you an error that doesn’t point at anything you can change, or it succeeds slowly and your build takes four minutes.

The old solver grew organically over more than a decade. It worked, mostly. It also accumulated behaviour nobody designed on purpose, and a set of known-broken corners that everyone learned to write around. The new one was built from scratch with the intent of replacing it wholesale.

The bug I have been writing around without noticing

Here is the smallest example in the announcement, and it stopped me:

fn foo(b: bool) -> impl Sized {
    if b {
        // The old implementation errored here.
        foo(false) + 1
    } else {
        0
    }
}

That errors on the current stable solver. It compiles with the new one.

I have never written exactly that function. But a recursive function that returns impl Trait is not an exotic shape. Tree walkers do it. Parser combinators do it constantly. And the reason I’ve never hit this specific failure is probably that I hit it once years ago, decided the recursion was the problem, restructured the function, and forgot. That’s the annoying thing about compiler bugs in a language people trust: you blame yourself and refactor around them silently.

The bigger change is subtler and affects code you didn’t write. It’s about associated types that reference bound lifetimes, the for<'a> kind:

trait OtherTrait {
    type Assoc<'a>;
}
impl OtherTrait for u32 {
    type Assoc<'a> = &'a u32;
}

trait Trait {}
impl<T: OtherTrait> Trait for (T, for<'a> fn(<T as OtherTrait>::Assoc<'a>)) {}

fn impls<T: Trait>() {}

fn main() {
    // The old implementation failed to prove
    // the where-bound of `impls`.
    impls::<(u32, for<'a> fn(&'a u32))>();
}

Nobody types for<'a> fn(<T as OtherTrait>::Assoc<'a>) into an editor on purpose. But the crates you depend on do, and the announcement names two of them: bevy and minijinja both needed patches because the old solver’s inference here was wrong in a way their code had come to rely on.

The team counts more than 200 open GitHub issues fixed by this change, and they describe that as an underapproximation. Two hundred issues is not a rounding error. That’s a decade of people filing “this should compile” and being told, correctly, that it’s a known limitation.

The part that will actually bite you

This is nightly only. Stable is untouched today, and stabilisation is months away.

That sounds reassuring right up until you remember what your CI does. If you run a nightly job for cargo +nightly fmt, or a clippy lint that only exists on nightly, or a -Z flag for some build detail, then your CI moved to the new solver the moment it picked up a fresh nightly. You did not opt in. Nobody asked you.

So if something broke in your nightly pipeline this week and you have been staring at your own diff trying to work out what you did, this is a reasonable place to look first. The team keeps a pinned issue tracking known breakage, and the honest framing from the announcement is that this is a big change with a non-trivial amount of breakage, most of it intentional.

You can turn it off per project. Drop this in .cargo/config.toml:

[build]
rustflags = ["-Znext-solver=coherence"]

Or set RUSTFLAGS=-Znext-solver=coherence for a one-off run. Reach for that if you’re blocked and shipping matters more than debugging today, but file the issue on your way past.

Compile times, and the number people will quote at you

The headline number floating around is that datafusion compiles more than 8x faster with the new solver. There is also a chess implementation written in Rust’s type system that hangs forever on the old solver and finishes in about a minute on the new one, which is a delightful thing to exist.

Please don’t extrapolate from either.

Rémy Rakic compared both implementations across the top 20,000 crates on crates.io. The finding that matters for you and me is the boring one: nearly all the crates they tested performed effectively the same on both. The dramatic outliers exist at both ends, and the sample was deliberately biased toward them, because outliers are what you investigate when you’re fixing performance. Several crates that used to take more than twice as long are now only slightly slower, which is progress, not victory. jana’s write-up on the performance work has the detail if you want the graphs.

So the realistic expectation is: your build takes about as long as it did. Your job is to check it didn’t get meaningfully worse, and report it if it did. Same speed with two hundred fewer bugs is a trade I’ll take every time.

Why they burned four years on this

The announcement is upfront that the payoff is mostly in the future, which I appreciate. The old implementation had to go before several other things could land.

Removing it unblocks Type Alias Impl Trait and Return Type Notation. RTN is the one I care about. It lets you put a bound on the return type of a method, which is the missing piece behind every async-trait headache where you need the returned future to be Send and the language gives you no way to say so. That’s why half of us are still boxing futures or pulling in a macro crate to paper over it. I wrote about how the ecosystem quietly picks your runtime for you in the piece on Rust async runtimes, and this is the type-system half of the same frustration.

It also opens the door to new implicit default trait bounds like Move and Forget, and lets the team close the remaining soundness holes in the type system. That last one is the quiet motivator. A soundness hole in a language whose entire pitch is “this compiles, therefore it’s safe” is not a bug you leave open forever.

Working out whether the solver is your problem

The opt-out flag has a name worth reading twice. -Znext-solver=coherence doesn’t disable the new solver entirely, it restricts it to coherence checking, which is the job of deciding whether two impls could ever overlap. That subset has been running on everyone’s compiler for a while already. What changed in August is that the new solver now handles the rest of type checking too, and the flag walks you back to the previous arrangement rather than to some pristine old world.

Practically, that gives you a clean two-state test. Run your build normally on the latest nightly. If it fails, add the flag and run it again. Same failure both times means the solver isn’t involved and the bug is yours. A failure that disappears with the flag means you have found either genuine breakage worth reporting, or a piece of your code that was leaning on inference the old solver should never have allowed.

Telling those two apart is the harder part, and I don’t have a shortcut for you. The announcement is clear that most of the breakage is intentional: incorrect type inference being removed, undesirable behaviour going away. So the honest first question isn’t “what did the compiler break” but “was my code ever actually valid”. The bevy and minijinja patches are useful reading for exactly that reason. They aren’t workarounds for a compiler regression, they’re fixes to code the old solver had been too permissive about.

One caution on caching. If you’re bisecting this and the results look inconsistent, clear the target directory between runs. Incremental artefacts built under one solver configuration and reused under another will produce results that waste an hour of your evening. Ask me how I know.

What I’d do this week

Five minutes, honestly:

rustup update nightly
cargo +nightly check --workspace --all-targets

If it’s clean, you’re done, and you’ve contributed a data point by not filing anything. If it breaks, check the pinned issue for your crate before opening a new one. If it isn’t listed, open one, because that’s the entire reason they flipped this on early rather than at stabilisation.

One more thing worth knowing: the team says they haven’t spent much time on error messages for the new solver yet. So a confusing diagnostic is also a legitimate report, not just a thing to grumble about. If you’ve ever complained that a trait error was unreadable, this is the window where complaining is useful. I keep notes on this kind of tooling friction across the projects I work on, and the errors here are genuinely worse than stable right now.

Which is fine. That’s what nightly is for.