set_var Is Unsafe and Requires Unsafe Block in Rust 2024: Fix It Properly
Rust · Intermediate · 6 min read · published
This article was written by Claude (Anthropic) and published automatically.
What this solves: After switching to Rust 2024, std::env::set_var stops compiling. Wrapping it in unsafe silences the error but can leave a real crash in place. Here's the proper fix.
What Changed
If you just moved a crate to Rust 2024 and hit error[E0133]: call to unsafe function stdenvset_var is unsafe and requires unsafe block, nothing is wrong with your toolchain. In Rust 2024, set_var is unsafe and requires an unsafe block, and so does std::env::remove_var.
The 2024 edition shipped with Rust 1.85. Both functions are still safe to call from crates on editions 2015–2021. They carry an internal deprecated_safe_2024 marker, so the unsafety only applies once your crate opts into edition = "2024".
The tempting fix is to wrap the call in unsafe { } and move on. That compiles. It also means you have just signed a promise that no other thread is running, and many codebases break that promise without anyone noticing.
The Old Way vs The New Way
Before (edition 2021). This compiles cleanly and can crash in production:
#[tokio::main]
async fn main() {
std::env::set_var("TZ", "UTC"); // worker threads already running
let client = reqwest::Client::new();
serve(client).await;
}
After (edition 2024). The unsafety is explicit, and the fix is structural rather than cosmetic:
fn main() {
// SAFETY: no threads have been spawned yet; the runtime is built below.
unsafe { std::env::set_var("TZ", "UTC") };
tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.unwrap()
.block_on(async {
let client = reqwest::Client::new();
serve(client).await;
});
}
The key difference is not the unsafe keyword. It is that #[tokio::main] is gone. That macro creates worker threads before your async body runs, so the first line of async fn main is already multi-threaded.
Why It Was Added
On POSIX systems, setenv and getenv are not thread-safe with respect to each other. That is true of glibc and most other libcs. setenv may reallocate the global environ array, and a concurrent getenv can then read freed memory.
Rust's standard library had an internal RwLock around its own env::var and set_var calls, so Rust-only access looked safe. But plenty of C code calls getenv behind your back:
getaddrinfoduring DNS resolutionlocaltime/mktimereadingTZ- OpenSSL reading config variables
dlopen
Those calls never take Rust's lock. The result was a safe Rust function that could cause a segfault. That is a soundness hole, and there are real bug reports of rare crashes in HTTP clients and logging setup tracing back to exactly this. The problem cannot be fixed inside Rust without libc's cooperation, so the honest answer was to make callers prove single-threadedness.
How It Works Underneath
The danger is a race on one process-global array:
sequenceDiagram
participant T1 as Thread 1 (your code)
participant ENV as libc environ array
participant T2 as Thread 2 (reqwest/tokio worker)
participant C as getaddrinfo (C code)
T2->>C: resolve("api.example.com")
C->>ENV: getenv("RES_OPTIONS") gets pointer into environ
T1->>ENV: setenv("TZ","UTC") (Rust lock held, irrelevant to C)
ENV->>ENV: realloc environ, old block freed
C->>ENV: continue scanning old pointer
Note over C,ENV: use-after-free, garbage or SIGSEGV
How the edition gate works:
- The functions carry
#[rustc_deprecated_safe_2024]. The compiler treats them asunsafe fnonly when the calling crate is on edition 2024. - That is why a dependency on 2021 can still call
set_varsafely. The race still exists there; it is just not flagged. - The
deprecated_safe_2024migration lint, whichcargo fix --editionruns, rewrites each call intounsafe { }and adds the comment// TODO: Audit that the environment access only happens in single-threaded code.That TODO is the actual work.
Should You Adopt It Yet
Rust 2024 is stable and production-ready, and you should migrate. The cost of this particular change is not the syntax. It is the audit.
Sort every call site into one of three buckets:
| Where the call is | What to do |
|---|---|
Before any thread exists (plain fn main, before building a runtime or spawning threads) |
Keep it, with a // SAFETY: comment saying why. |
Inside #[tokio::main], #[actix_web::main], a request handler, or a library function |
Unsound. Restructure it. |
| In tests | Unsound under the default parallel test runner. Remove the env dependency or isolate the test in its own process. |
Who should wait: if a large test suite leans on set_var for configuration, budget time to refactor config injection first. Blanket-wrapping everything in unsafe just to get green builds turns a compiler warning into a hidden liability.
Migration Notes
- Set the toolchain floor. Use
rust-version = "1.85"or newer, then runcargo fix --editionbefore changingedition = "2024"inCargo.toml. - Find every call site:
grep -rnE 'env::(set_var|remove_var)' --include='*.rs' .
grep -rn 'Audit that the environment access' --include='*.rs' .
- Replace test env mutation with injection. Have functions take a
Configrather than callingenv::vardeep inside. Keepfrom_env()at the edge of the program. - Child processes: use
Command::env/env_remove/env_clear. They modify only the child's environment and need nounsafe. - Async entry points: replace
#[tokio::main]with a syncfn mainthat sets env first and then builds the runtime, if you genuinely must mutate env at startup. - Related 2024 change:
unsafe_op_in_unsafe_fnnow warns by default. Code inside anunsafe fnmust still use explicitunsafe { }blocks, so expect extra warnings in FFI-heavy modules. - Don't rely on serialization crates alone. Helpers that take a global mutex around env changes in tests stop Rust tests from racing each other. They do not stop C code from reading env concurrently.
Key takeaway: Only mutate the environment before any thread exists, including tokio runtime workers; everywhere else, pass config explicitly or use Command::env for child processes.
Real-world challenge
Your team migrates a crate to edition = "2024" and runs cargo fix --edition. Everything compiles. The integration tests now contain dozens of unsafe { env::set_var(...) } blocks with an auto-inserted TODO comment. A week later CI starts failing occasionally with SIGSEGV inside getaddrinfo or with a corrupted environment string. The same tests fail on older commits too, just less often. What is happening, and how do you fix it?
Diagnosis. The edition migration didn't cause the crash. It exposed a crash that was already there. cargo test runs tests on parallel threads in the same process. One test calls setenv, which can reallocate environ. At the same moment another test's HTTP client resolves a hostname, and getaddrinfo calls getenv. That read can walk freed memory. cargo fix only added unsafe blocks plus a // TODO: Audit that the environment access only happens in single-threaded code. comment. It did not make the code safe.
Fix, in order of preference:
- Stop reading config from the process environment inside library code. Pass a config struct in.
pub struct Config { pub api_url: String }
impl Config {
pub fn from_env() -> Self { Self { api_url: std::env::var("API_URL").unwrap() } }
}
// tests build Config { api_url: server.url() } directly, with no set_var
- For code that spawns processes, set variables on the child only with
Command::new(bin).env("API_URL", url). This never touches the parent's environment. - If a test truly needs process env, isolate it in its own process. Options are
cargo nextest(one process per test) or a dedicated test binary undertests/that sets env before doing anything else.
Then grep for set_var and remove_var and delete every TODO comment you can justify. Each one that remains should state why no other thread exists at that point.