The Short Answer Is Yes, with a Catch
Rust can enforce runtime contracts in development and erase them completely from release builds. The catch is that the language does not treat contracts as a first-class concept. You get the building blocks, but you have to wire them together yourself.
debug_assert! is the obvious starting point. It evaluates in debug builds and compiles to nothing in release. That works for simple invariants, but real contracts need preconditions, postconditions, and sometimes loop invariants with custom failure behavior. None of that comes out of the box.
This post shows a pattern that gets you actual design-by-contract semantics with literally zero overhead in release, no external crates required.
Why Contracts at Runtime Matter in Rust
Rust’s type system and borrow checker catch an enormous class of errors at compile time. They do not catch logic errors, boundary violations, or broken business invariants.
You can write a function that takes a NonZeroU32 and still divides by the wrong value. You can enforce slice bounds with the type system and still pass the wrong slice. Contracts fill the gap between “compiles” and “is correct.”
The objection is always performance. In systems programming, a release build is a promise. Extra branch instructions for runtime checks feel like breaking that promise.
The goal is to keep those checks where they help, during development and testing, and remove them from the binary your users run.
What Rust Gives You Out of the Box
Rust has two assertion families. assert! and assert_eq! run in all builds. debug_assert! and debug_assert_eq! run only when cfg(debug_assertions) is active, which is true for debug builds by default.
pub fn withdraw(balance: &mut i64, amount: i64) {
// Always runs. You pay for this in release.
assert!(amount > 0, "withdrawal must be positive");
assert!(amount <= *balance, "insufficient funds");
*balance -= amount;
}
pub fn withdraw_debug_only(balance: &mut i64, amount: i64) {
// Vanishes in release builds.
debug_assert!(amount > 0, "withdrawal must be positive");
debug_assert!(amount <= *balance, "insufficient funds");
*balance -= amount;
}
This is fine for ad-hoc checks. It is not fine for systematic contracts because:
- There is no built-in syntax for preconditions, postconditions, or invariants
- You cannot easily toggle contract levels per build profile
- Custom failure behavior, like logging instead of panicking, requires wrapping every call
Building Zero-Cost Contracts with Macros
The pattern that works is a small macro suite that expands to checks in debug and to nothing in release. You control the toggle with a custom cfg flag if you want contract checks in release tests, or you tie it directly to debug_assertions.
Here is a complete, working implementation:
// contracts.rs
/// Precondition check. Compiles to nothing in release.
#[macro_export]
macro_rules! requires {
($cond:expr $(, $msg:tt)*) => {
#[cfg(debug_assertions)]
{
if !$cond {
panic!(
concat!("Precondition violated: ", stringify!($cond))
$(, ": ", $msg)*
);
}
}
};
}
/// Postcondition check. Compiles to nothing in release.
#[macro_export]
macro_rules! ensures {
($cond:expr $(, $msg:tt)*) => {
#[cfg(debug_assertions)]
{
if !$cond {
panic!(
concat!("Postcondition violated: ", stringify!($cond))
$(, ": ", $msg)*
);
}
}
};
}
/// Invariant check for data structures. Compiles to nothing in release.
#[macro_export]
macro_rules! invariant {
($cond:expr $(, $msg:tt)*) => {
#[cfg(debug_assertions)]
{
if !$cond {
panic!(
concat!("Invariant violated: ", stringify!($cond))
$(, ": ", $msg)*
);
}
}
};
}
Usage is straightforward:
use contracts::{requires, ensures, invariant};
pub struct Account {
balance: i64,
}
impl Account {
pub fn new(initial: i64) -> Self {
requires!(initial >= 0, "initial balance cannot be negative");
let acct = Self { balance: initial };
ensures!(acct.balance >= 0);
acct
}
pub fn withdraw(&mut self, amount: i64) {
requires!(amount > 0, "amount must be positive");
requires!(
amount <= self.balance,
"amount must not exceed balance"
);
self.balance -= amount;
ensures!(self.balance >= 0, "balance must stay non-negative");
invariant!(self.is_consistent());
}
fn is_consistent(&self) -> bool {
self.balance >= 0
}
}
In a debug build, every check runs and panics with a clear message. In a release build, the macros expand to empty blocks. The optimizer sees nothing and generates no instructions.
Verifying That the Release Build Is Actually Free
It is not enough to believe the macros. You should check the assembly.
Build the withdraw function in release mode and inspect the output:
rustc --edition 2021 -C opt-level=3 --emit asm contracts.rs
With the macros using cfg(debug_assertions), the release assembly for Account::withdraw contains no panic branches, no string constants, and no extra comparisons beyond the arithmetic itself. The contract checks are completely absent.
If you see panic-related symbols in the output, you have a bug in the macro or you are building with debug-assertions = true in your release profile.
The Profile Gotcha You Will Hit
Cargo allows enabling debug assertions in release builds via Cargo.toml:
[profile.release]
debug-assertions = true
Some teams do this temporarily to catch issues in integration tests. If you use cfg(debug_assertions) as your contract toggle, this suddenly makes your release binary slower. That might be what you want for testing, but it is a surprise if you expected zero cost.
If you need finer control, use a custom feature instead of debug_assertions:
#[cfg(any(feature = "contracts", debug_assertions))]
Now contracts run in debug builds by default, and you can explicitly enable them in release with --features contracts when you need that extra safety for a specific test run or canary deployment.
Why This Is Not Eiffel-Style Design-by-Contract
Real design-by-contract languages can inherit contracts, compose them across module boundaries, and sometimes even prove them statically. Rust macros give you none of that.
What you get is pragmatic. The checks run at the call site. They panic on failure. They disappear in release. That is enough to catch a huge class of bugs during development without shipping the overhead to production.
It is also enough to document intent. A requires! block at the top of a function tells the next reader exactly what the author assumed, in executable form.
When You Should Keep Contracts in Release
Not every contract should vanish. Safety-critical invariants, external input validation, and security checks often belong in assert!, not debug_assert! or custom contract macros.
The rule is simple: if violating the condition means memory unsafety, data corruption, or a security vulnerability, keep the check in release. Use the zero-cost pattern for logic errors that should have been caught by tests.
// Keep this in release. Memory safety depends on it.
assert!(!ptr.is_null());
// Strip this in release. A logic bug, but not unsafe.
requires!(discount_rate <= 1.0);
The Pattern in One Crate
If you do not want to maintain your own macros, the contracts crate on crates.io implements almost exactly this pattern. It provides #[requires], #[ensures], and #[invariant] proc macros that attach to functions and struct definitions.
Under the hood, it uses the same cfg expansion to strip checks in release. The main benefit is nicer syntax. The main cost is a proc-macro dependency.
For teams that already depend on syn and quote, the crate is a good choice. For teams that want zero dependencies, the macro pattern above is twenty lines and no Cargo.toml changes.
Start with One Function
You do not need to annotate your entire codebase. Pick one function with a tricky invariant, add a requires! and ensures!, run your tests, and check the release assembly.
Once you see the checks disappear while catching a real bug in debug, the value becomes obvious. Contracts are not a framework to adopt. They are a habit to build, one function at a time.