Toolchain Horizons: Exploring Rust Dependency-Toolchain Compatibility
I tested the top 100 Rust crates on crates.io for backwards compatibility. Then I removed every dependency from the TigerBeetle Rust client and backported it to Rust 1.39, from 2019.
Here’s why: last year I created the Rust client for TigerBeetle, the
financial accounting database. One morning, after weeks of work reducing
the client’s minimum supported Rust version, I found CI broken — a point
release of a dependency of a dependency had bumped its rust-version
from 1.61 to 1.68.
This annoyed me a lot, so I ran an experiment.
TigerBeetle has a client-server architecture, and provides client
libraries for most popular languages: Python, Java, Go, Node, .NET, and
Rust. Each of these is a bindings/FFI project that binds to the single
tb_client library, written in Zig, exposing a C ABI,
wrapped in the idioms of the embedding language. We share one core
across every language client so that as much of the client-side code
path as possible is covered by our deterministic simulation testing (the
VOPR).
The Rust client is less than two thousand lines of production Rust
code, some of it generated, some of it written by hand. It provides a
simple asynchronous API for use with async /
await; and it is runtime-agnostic, requiring no
dependencies on specific Rust async runtimes.
In use it looks like this:
use tigerbeetle as tb;
// Connect to TigerBeetle
let client = tb::Client::new(0, "127.0.0.1:3000")?;
// Create two accounts on the same ledger
let accounts = [
tb::Account {
id: tb::id(),
ledger: 1,
code: 1,
..Default::default()
},
tb::Account {
id: tb::id(),
ledger: 1,
code: 1,
..Default::default()
},
];
client.create_accounts(&accounts).await?;
// Transfer 100 units from the first account to the second
let transfers = [tb::Transfer {
id: tb::id(),
debit_account_id: accounts[0].id,
credit_account_id: accounts[1].id,
amount: 100,
ledger: 1,
code: 1,
..Default::default()
}];
client.create_transfers(&transfers).await?;It’s a small codebase with basic requirements but it does need some common dependencies.
The Rust ecosystem has a concept of the “Minimum Supported Rust Version” (MSRV) for its crates. It relates crates to the Rust compiler version, and it is separate from SemVer, Rust’s primary versioning scheme.
Some crates encode this information in their Cargo.toml
manifest’s optional rust-version
field. It is a best practice for crate maintainers to know and document
their minimum supported Rust version and test against that version in
their CI.
When I do the initial development of a new Rust crate, I don’t worry about the minimum Rust version; I save that work for just before publication, a process like:
Start with the oldest version I know I support.
Test against the previous version.
Fix the build.
This usually involves removing or replacing dependencies, and replacing newer language features with older ones or dependencies that fill that role. Open-coded polyfills are often involved.
Update CI to verify that as the minimum supported Rust version.
Do it again.
When I posted the initial pull request for the Rust TigerBeetle client in June 2025, I had not done this yet, and expected our minimum supported Rust version to be a recent one. Without any effort to support older releases, the client’s initial MSRV was Rust 1.81, published September 5, 2024.
About 9 months of supported toolchains. Not satisfactory, but not surprising.
TigerBeetle has a set of strict and opinionated coding guidelines, TigerStyle. They are focused on three pillars: safety, performance, and developer UX.
TigerStyle emphasizes fully understanding and owning your code and radically reducing dependencies. It has this to say about them:
TigerBeetle has a “zero dependencies” policy, apart from the Zig toolchain. Dependencies, in general, inevitably lead to supply chain attacks, safety and performance risk, and slow install times. For foundational infrastructure in particular, the cost of any dependency is further amplified throughout the rest of the stack.
In order to support older Rust toolchains — and as a matter of TigerStyle — one of my first tasks to land the Rust client was to judiciously remove crate dependencies.
At the start of the process the client’s dependencies were thus:
[package]
name = "tigerbeetle"
version = "0.1.0"
edition = "2021"
[dependencies]
bitflags = "2.6.0"
futures = "0.3.31"
thiserror = "2.0.3"
[build-dependencies]
anyhow = "1.0.93"
ignore = "0.4.23"
[dev-dependencies]
anyhow = "1.0.93"
tempfile = "3.15.0"To Rust programmers this is common stuff, dependencies most of us use. Most of these are easy enough to remove.
The futures crate is not easy to remove.
The futures crate is critical to the Rust ecosystem. It
was where the original futures implementation was prototyped, and
continues to maintain code that is so important that it remains all but
required for using async Rust. Most sizable Rust projects depend on the
futures crate. It is an official crate maintained by the
Rust project.
It’s a big dependency, as shown in this dependency graph from
cargo tree:
futures v0.3.31
├── futures-channel v0.3.31
│ ├── futures-core v0.3.31
│ └── futures-sink v0.3.31
├── futures-core v0.3.31
├── futures-executor v0.3.31
│ ├── futures-core v0.3.31
│ ├── futures-task v0.3.31
│ └── futures-util v0.3.31
│ ├── futures-channel v0.3.31 (*)
│ ├── futures-core v0.3.31
│ ├── futures-io v0.3.31
│ ├── futures-macro v0.3.31 (proc-macro)
│ │ ├── proc-macro2 v1.0.90
│ │ │ └── unicode-ident v1.0.14
│ │ ├── quote v1.0.37
│ │ │ └── proc-macro2 v1.0.90 (*)
│ │ └── syn v2.0.88
│ │ ├── proc-macro2 v1.0.90 (*)
│ │ ├── quote v1.0.37 (*)
│ │ └── unicode-ident v1.0.14
│ ├── futures-sink v0.3.31
│ ├── futures-task v0.3.31
│ ├── memchr v2.7.4
│ ├── pin-project-lite v0.2.15
│ ├── pin-utils v0.1.0
│ └── slab v0.4.9
├── futures-io v0.3.31
├── futures-sink v0.3.31
├── futures-task v0.3.31
└── futures-util v0.3.31 (*)
Notice that futures transitively depends on syn and proc-macro2.
These two are fundamental ecosystem crates in their own right, used when
implementing macros. They are not official crates but they are critical
and tightly related to the Rust compiler, tracking its lexical
structure, syntax and macro interfaces.
This is a sticky dependency, tough to eliminate from large Rust programs.
So I did my rework on the Rust client pull request, removing one dependency at a time, reducing the MSRV.
I had succeeding in reducing the MSRV to 1.63 after removing
dependencies on those crates which were simplest to remove:
ignore, anyhow, thiserror,
tempfile. Most everything but futures.
Then one day I resumed my work and found the Rust client no longer
built on our CI: the syn crate had published a point
release that broke our build. It wasn’t an accidental breaking change.
It was rust-version.
In syn version 2.0.107 its
rust-version changed from 1.61 to 1.68.
My work undone.
This is common in the Rust ecosystem — it is considered a reasonable
maintenance strategy to bump rust-version in a point
release. The reasons are nuanced. Since the original incident the same
CI-breaking scenario of a transitive dependency bumping
rust-version has affected TigerBeetle twice more, and not
both due to syn.
That was the moment of maximum annoyance, and when I decided to do an experiment to learn more about the state of crate-toolchain compatibility.
I tested the top 100 crates from crates.io by download count, the
most recent major releases of each, to find the oldest Rust version each
could compile with. For each crate I created a minimal project depending
on it, then binary-searched through Rust releases from 1.0 to 1.94 to
find the oldest toolchain for which cargo check
succeeds.
The test run for this blog post was conducted on April 8, 2026.
Caution: while I have iterated on this experiment and run it many times there are surely mistakes. Further, while there exist techniques to munge lockfiles etc. to achieve compatibility, this experiment is just letting cargo resolve how it wants and seeing what happens.
The chart below shows the results. Each bar represents a crate’s compatibility window — the span of Rust versions from its oldest compatible release to the present.
Good news first: I was surprised that old toolchains still install
and work, and that some crates actually do remain compatible with them:
super-kudos to autocfg, fnv, mime, version_check,
memoffset,
and scopeguard.
Big kudos to serde as well. A
lynchpin of the ecosystem, it is compatible back to Rust
1.31, from 2018. That is the toolchain that introduced edition 2018.
As a practical matter supporting Rust prior to edition 2018 is
unnecessary for any but the super-kudos crates mentioned above. Rust
2015 edition is very old Rust.
There are a large handful of crates hanging out with
serde in the “yellow” zone in the chart. That zone is the
epoch prior to the introduction of async /
await, in Rust
1.39, 2019. Curiously no crates landed directly on 1.39 for their
MSRV. As another practical matter this release is probably the hard MSRV
cutoff for any async Rust crates. futures-core is in this
zone, providing ongoing support for the entire async /
await epoch.
Through the remaining gradient of decreasing support we see other
scattered futures crates, and many other familiar crates, including
syn and proc-macro2. You can see for yourself
the crates at the puny end of the spectrum.
I’ll conclude with some examples of what it looks like to aggressively reduce dependencies and language features to increase toolchain compatibility.
The futures crate presents an interesting support
situation: it reexports other crates in the same family of futures
crates, and each of those subcrates seems to have their own support
levels, with futures-core, where the key traits are, having
great support; futures-executor, with its dependency on
syn, having poor support.
I said before it is “sticky” — used ubiquitously in the ecosystem, providing features that are unsafe, with tricky semantics, that should be implemented once with close scrutiny and reused.
That’s what dependencies are for!
But how can we get rid of it? Here are the 4 steps I needed to take.
The first step was to depend only on the specific subcrates I actually used. For the TigerBeetle client I needed:
futures-channelfor oneshot channelsfutures-executorforblock_onin testsfutures-utilfor theunfoldfunction onStreamExt
The block_on
function from futures-executor is used to run a future to
completion on the current thread. It’s commonly used in tests, examples,
and for scheduling non-I/O asynchronous work.
pub fn block_on<F: Future>(mut future: F) -> F::Output {
let mut future = unsafe { Pin::new_unchecked(&mut future) };
let waker = /* ... */;
let mut cx = Context::from_waker(&waker);
loop {
match future.as_mut().poll(&mut cx) {
Poll::Ready(result) => return result,
Poll::Pending => std::thread::park(),
}
}
}The tricky part is constructing the “waker”. I won’t go into detail here but it requires some unsafe code.
We use the unfold
function from futures-util to write one test case and one
doc-comment example, both demonstrating how to use the TigerBeetle
client API.
unfold’s function signature looks like this:
pub fn unfold<T, F, Fut, Item>(init: T, f: F) -> Unfold<T, F, Fut>where
F: FnMut(T) -> Fut,
Fut: Future<Output = Option<(Item, T)>>,It’s like a closure-to-iterator adapter but for streams. It converts repeated calls to a future-returning closure into a stream of futures.
Oneshot channels send a single value between tasks. The TigerBeetle client uses them internally to communicate with an internal I/O thread.
A oneshot channel is just a shared Option<T> to
transfer a value plus a signal to wake up the reciever. Plus it needs to
conform to the Future
trait.
Easy to implement naively.
After removing every crate dependency, I started removing language and standard library features to achieve even greater compatibility. Below is a summary of the features I had to remove.
| Rust | Date | Features |
|---|---|---|
| 1.56 | Oct 2021 | format
string captures, Path::try_exists,
const Mutex::new |
| 1.55 | Sep 2021 | rust-version
(stabilized 1.56) |
| 1.55 | Sep 2021 | Edition
2021→2018, TryFrom |
| 1.53 | Jun 2021 | CARGO_TARGET_TMPDIR
(stabilized 1.54) |
| 1.51 | Mar 2021 | IntoIterator
for arrays (stabilized 1.53) |
| 1.50 | Feb 2021 | const generics (stabilized 1.51) |
| 1.45 | Jul 2020 | array impls for lengths > 32 (stabilized 1.47) |
| 1.42 | Mar 2020 | associated
constants (u64::MAX)
(stabilized 1.43) |
| 1.41 | Jan 2020 | matches!
(stabilized 1.42) |
| 1.39 | Nov 2019 | todo!,
mem::take,
non_exhaustive
(stabilized 1.40) |
No details today, but I’ll do a followup post with more about every crate and every language feature I removed from the TigerBeetle Rust client as well as how I did it. I think it might be interesting to Rust historians.
I achieved compatibility with Rust 1.39, from November 2019. I’m not landing that code. It has too many tradeoffs I’m still uncomfortable with. The in-tree Rust client targets Rust 1.63, from August 2022. About 3.5 years of support.
People have asked me why we should support older toolchains. Some easy answers:
- Professional responsibility. Library authors should know and publish their supported toolchain range. The range itself matters less than having one.
- Trust. When users see effort to support older compilers, they know the maintainer is present and cares.
- Reality. Enterprise deployments, embedded systems, and distro packages often lag the latest toolchain by years.
But there’s a harder answer.
The Rust compiler is stable. The Rust crate ecosystem is not. Crate authors have strong incentives to adopt new features and break from the past. Based on this experiment, I estimate a roughly 2-year window in which any particular Rust compiler remains viable for a project that takes dependencies. After that, we’re all forced to upgrade — not by language changes, but by our crate neighbors.
We can widen that window slowly, but it requires individual crate authors to expand their toolchain horizons. TigerBeetle’s horizons? They’re wide.