The Rust Engineering Handbook / Chapter 78
Const Evaluation, Build-Time Generation, and Metaprogramming Trade-offs
Place work in const evaluation, macro expansion, build generation, checked-in source, or runtime by information needs, reproducibility, diagnostics, and total cost.
Five implementations produce the same three-entry route registry:
- a literal
&[Route]in ordinary Rust; - a
routes!macro over tokens in the crate; - a
const fnthat computes keys for a literal table; build.rsreading a declared route specification and generating Rust intoOUT_DIR;- a service that reads configuration and constructs the registry at startup.
If the entries are already Rust data and never vary, the first implementation wins. If a type-level property can be calculated from constant inputs, const evaluation adds a checked relationship without adding a generator. If a compact Rust-embedded language removes real repetition, a macro may earn its grammar. If non-Rust input must be translated before compilation, a build script or external generator may be necessary. If operators must change the registry without rebuilding, only runtime configuration satisfies the requirement.
The output does not choose the mechanism. The earliest stage with all required information usually does, subject to one correction: use the least powerful adequate mechanism, because every earlier and more programmable stage adds a language, invalidation rule, diagnostic boundary, or execution surface to the build. Compile time is not free time, and generated source is still source your organization must understand.
Place the decision on two axes
First ask when the necessary information exists:
- Rust literals, types, and const parameters exist during compilation.
- Macro input exists during expansion and carries Rust tokens and spans.
- files, target metadata, native tools, and environment declared to Cargo exist while a build script runs on the host.
- repository-wide schemas and tools may be available to an explicit external generation step before Cargo.
- deployment configuration, secrets, current network state, and operator choices exist only at runtime.
Then ask what kind of output is required. A value can often remain a value. A type relationship may need const generics. Repeated syntax may justify a macro. Foreign input may require generated Rust or a data artifact. Mutable operational policy belongs in runtime state.
This matrix is a design filter, not a scorecard:
| Need | Start with | Escalate when | Main cost |
|---|---|---|---|
| fixed values and tables | literals, static, ordinary functions |
a checked compile-time relationship is required | source repetition |
| pure value from const inputs | const and const fn |
required operations are not const-evaluable on stable | compiler work and stable const limits |
| size or mode in a type | const generic | value must vary dynamically | monomorphization and type/API complexity |
| repeated Rust syntax | function, trait, macro_rules! |
parsing or item transformation needs procedural logic | grammar and diagnostics |
| declared non-Rust input for one package | build.rs to OUT_DIR |
generation must be reviewed or shared independently | host execution and invalidation |
| checked-in derived source | explicit external generator plus drift check | output is too large or platform-specific to review | synchronization and tool distribution |
| deployment-varying data | runtime parse and validation | startup latency or availability forbids it | runtime failure and operations |
Do not choose by prestige. A proc macro is not “more compile time” than const fn; it operates on a different representation at a different phase. A build script is not a general escape from const restrictions; it is host-executed package build code with filesystem and process access. Runtime code is not a failure when the requirement itself is dynamic.
Keep fixed data as data
Ordinary Rust is the baseline because rustfmt, rustdoc, name resolution, type checking, IDEs, code review, and diagnostics all understand it directly:
pub static ROUTES: &[Route] = &[
Route::new("GET", "/accounts", accounts),
Route::new("GET", "/health", health),
Route::new("POST", "/events", ingest),
];
For three entries, a generator has negative value. Even for hundreds of entries, a plain table may remain correct if it is the source of truth and maintainers edit it comfortably. A function can validate or transform data at runtime; a test can check global uniqueness. A build does not need to reject every possible business rule if a fast deterministic test provides the right evidence.
Choose const for a named value evaluated in a const context. Choose static for one storage location with a program-long lifetime. A static initializer is a const context, but const and static are not synonyms: using a const value can inline a value at each use, while referring to a static identifies the same storage. Mutable statics add unsafe synchronization obligations and are not a registry mechanism.
Large tables affect object size, relocation work, instruction and data cache behavior, and compile time whether handwritten or generated. Generation can reduce authoring effort without reducing the resulting binary. Measure the emitted artifact and runtime access pattern rather than crediting “compile time” with a performance win.
Use const evaluation for value relationships
Constant evaluation computes allowed expressions during compilation. Expressions in const contexts must be evaluable and are evaluated at compile time; an expression outside a const context may be optimized but is not guaranteed to be compile-time evaluated. That distinction prevents a common overclaim: marking a function const permits const-context calls, but ordinary calls retain ordinary function semantics.
The lab calculates a small stable key:
pub const fn route_key(path: &str) -> u64 {
let bytes = path.as_bytes();
let mut hash = 0xcbf2_9ce4_8422_2325_u64;
let mut index = 0;
while index < bytes.len() {
hash ^= bytes[index] as u64;
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
index += 1;
}
hash
}
pub const HEALTH_KEY: u64 = route_key("/health");
This computation needs only bytes and integer operations available in stable const evaluation. It runs in the compilation target’s environment, which matters for target-dependent values such as usize. It cannot open a route file, query a service, read an arbitrary environment variable, or allocate through ordinary runtime facilities. Those are not missing conveniences; they mark a different information and effects boundary.
A compile-time assertion can protect a fixed invariant:
const _: () = assert!(GENERATED_ROUTES.len() <= 16, "registry capacity exceeded");
If the condition fails, compilation fails near the assertion with the domain phrase. Use this for invariants that must hold for every built artifact and that can be expressed clearly on stable Rust. Do not turn every test into a const assertion. Complex failures are often easier to explain in a unit test with structured input, multiple diagnostics, and ordinary debugging.
Const evaluation can also expose expensive or nonterminating work to compiler limits. A huge generated lookup calculation may increase build latency and memory while producing a worse diagnostic than an explicit generator or test. Budget evaluated operations and resulting data. “No runtime work” is only one line in the total-cost ledger.
Stable capabilities evolve. Keep core examples within the declared stable and MSRV intersection. If a nightly-only const trait operation or generic const expression makes an elegant prototype, label it experimental and preserve a stable design. Do not make downstream users adopt nightly merely to move a calculation earlier.
Const generics encode values in types
Const generic parameters allow types and functions to vary over supported constant values. The lab’s capacity is part of the type:
pub struct FixedBatch<T, const N: usize> {
entries: [T; N],
}
impl<T, const N: usize> FixedBatch<T, N> {
pub const fn new(entries: [T; N]) -> Self {
Self { entries }
}
pub const fn len(&self) -> usize {
N
}
}
FixedBatch<Event, 16> and FixedBatch<Event, 32> are different types. That is useful when capacity changes representation, satisfies a protocol contract, or must be known by generic callers. It is needless complexity when capacity is merely a tuning value read from configuration. Encoding operational variability in types multiplies monomorphized code and forces configuration changes through rebuilds and API signatures.
Stable const generics do not permit every arbitrary computation wherever a const argument appears. The Reference places restrictions on expressions involving generic parameters, and the set of const-evaluable library operations changes over time. Design around the stable language you support, not around a nightly experiment. Often an associated const, a plain N, or a runtime check expresses the invariant adequately.
Review the cost in both dimensions. A const parameter can remove dynamic storage or checks, but many distinct values can create more monomorphizations, longer builds, larger binaries, and noisier APIs. Profile representative value sets. If all production callers use one size, a named concrete type or ordinary constant may communicate more with less machinery.
Macros should solve syntax problems
Use a macro when the output must be Rust syntax and ordinary abstractions cannot express the relationship without unacceptable repetition. A routes! invocation can own a small declarative grammar; a derive can own a type-local trait implementation; route_table! can own explicit aggregation. Those are syntax jobs.
Do not use a macro merely to force evaluation before runtime. A macro_rules! hash that recursively consumes literal characters is harder to maintain than a const fn. A procedural macro that reads a file hides build inputs from Cargo and executes a compiler plugin to do a build script’s job. A macro that generates a static table from values already present as Rust may be less clear than a const expression or ordinary table.
Macros have one diagnostic advantage when the problem is syntax: tokens carry spans. A malformed route key can be underlined at the call site. They also have a compatibility burden: accepted token grammar and emitted Rust form a public language. Use that machinery only when syntax ownership improves the API.
Generated expansion still consumes parsing, type checking, monomorphization, and code generation. Compact source can expand into thousands of items. Track expansion size and clean/incremental build time. A one-line invocation is not evidence of a small build artifact.
Build scripts translate declared package inputs
Cargo compiles and executes build.rs before compiling the package. The script runs for the host, even when the package targets another platform. Cargo supplies target information through environment variables such as TARGET and CARGO_CFG_*; using the build script’s own cfg! would describe the host and can make cross-compilation wrong.
The lab uses one declared input:
fn main() {
const INPUT: &str = "inputs/routes.txt";
println!("cargo::rerun-if-changed={INPUT}");
// Parse, validate, sort, and write routes.rs under OUT_DIR.
}
The script reads inputs/routes.txt, validates each line, sorts records into a canonical order, and writes routes.rs only under OUT_DIR. The library includes that compiler input:
include!(concat!(env!("OUT_DIR"), "/routes.rs"));
This is appropriate because the authoritative input is not Rust syntax and must become typed Rust before the package compiles. Sorting makes semantically equivalent input order produce a canonical generated table. Writing only when content differs avoids needless output churn inside the persistent build directory.
cargo::rerun-if-changed=inputs/routes.txt narrows invalidation. Without relevant rerun-if instructions Cargo conservatively watches package files, so unrelated edits can rerun the script. Declare each file and external environment variable that changes output. Do not declare target values that Cargo itself provides as though they were ambient user variables. If a generator walks a directory, understand that directory changes become inputs and can make incremental behavior broad or platform-sensitive.
OUT_DIR is not guaranteed empty between runs. Treat existing output as a cache candidate, not proof it is current. Generate output from complete declared inputs, replace atomically when practical, and do not depend on stale undeclared files. Do not modify package source or files in the Cargo registry. Source mutation breaks packaging assumptions, parallel builds, clean builds, and reproducibility.
A build script may access the filesystem, environment, subprocesses, and—unless the build environment prevents it—the network. That power is a supply-chain execution surface similar to a procedural macro. Keep dependencies small, run builds with restricted credentials and network where feasible, pin the lockfile, and review every native tool invocation. Never fetch “latest” schema or embed the current clock, random value, absolute workstation path, username, or secret into output.
Diagnostics are coarser than token-aware macro diagnostics. A script can exit with an error and print a precise file, line, field, and repair, but it does not own a Rust token span in the consumer source. That is acceptable because the user’s source is the external input file. If the error belongs to a Rust invocation, a macro may own it better.
External generation makes the step explicit
An external generator runs as a deliberate repository or release action rather than implicitly inside every Cargo build. It can read a schema, invoke specialized tools, generate several crates or languages, and produce reviewable checked-in source. This is often stronger than build.rs when generation is expensive, needs credentials, depends on tools unavailable to downstream users, or produces an API maintainers must review in pull requests.
Checked-in generated source creates a synchronization contract. Record the generator version and input digest, make output deterministic, and provide a drift check that regenerates into a temporary directory and compares bytes or a normalized representation. The update command may replace checked-in output; the verification command must not silently repair it. Code review should separate input changes, generator changes, and output changes enough that reviewers can understand causality.
Do not use build.rs to rewrite checked-in generated files. Cargo’s own guidance recommends an explicit generator and a test that compares regenerated output when derived source is meant to live in the repository. This keeps package builds read-only with respect to source and makes diffs visible.
External generation has operational costs: distributing the tool, pinning its runtime, licensing its dependencies, supporting platforms, and deciding who owns regeneration. A small static registry does not justify that system. A large protocol client generated from a versioned schema might.
Generated code is not automatically trustworthy. It can contain unsafe blocks, unbounded recursion, surprising allocations, unstable ordering, or embedded secrets just as handwritten code can. Run rustfmt, compiler checks, Clippy policy, tests, security review, and size budgets on the output. Review the generator as a compiler and the schema as source.
Runtime is correct when the world is dynamic
If routes depend on deployment configuration, tenant state, feature rollout, current credentials, or service discovery, compile-time generation cannot satisfy the requirement without rebuilding for each change or embedding stale data. Runtime parsing is the honest boundary:
let routes = RouteSet::parse(config.routes())?;
routes.validate_unique()?;
router.install(routes);
This adds startup work and a runtime failure mode. Budget them explicitly: bound input size, validate before serving, retain last-known-good configuration when appropriate, expose a configuration version, and make rollback possible. The benefit is that the operational change is visible and does not masquerade as source compilation.
Some data can be split. Compile a stable parser and type model, generate a built-in default table, then allow a validated runtime override. The boundaries must specify precedence and failure behavior. Avoid a build script reading a developer’s local deployment configuration; it produces artifacts whose behavior depends on an undeclared workstation state.
Runtime computation may also be cheaper in total. A rarely used table that costs milliseconds to construct may not justify minutes of generator maintenance or broad rebuild invalidation. Conversely, startup SLOs or embedded environments may justify generated static data. Measure the system that matters rather than assuming earlier is always faster.
Cache the function of declared inputs
Every generated artifact can be modeled as:
output = generator(version, configuration, declared inputs, target)
Reproducibility requires those arguments to be explicit enough that equivalent inputs produce equivalent output. Cache keys must cover the same function. If locale changes sorting, timezone enters timestamps, a directory walk changes order, a network response is unpinned, or an environment variable is read without declaration, the function has hidden parameters.
For const evaluation and macros, rustc and Cargo already track source and dependency inputs, though compiler and proc-macro versions remain part of reproducibility. For build scripts, rerun-if-changed and rerun-if-env-changed define when Cargo re-executes the script; they do not make nondeterministic output deterministic. For external generation, the repository must define its own input manifest and tool pinning.
Canonicalize only what semantics permit. Sorting route records is correct when registry order has no meaning. Sorting migration steps would be a defect if order is behavior. Normalize line endings or paths deliberately, and never emit absolute build paths into portable source unless the artifact contract requires them.
Test a clean build and an unchanged rebuild. Change one declared input and prove generation reruns. Change an unrelated file and confirm it does not. Build in a second directory with controlled timestamps and compare artifacts where bit reproducibility is promised. Record whether the target changes output. A cache hit is useful evidence only if the cache key covers every input.
Only after the inputs and invalidation rules are explicit is the complete stage map useful. Trace each candidate from the first phase that has its required information to the cache, diagnostic owner, and trust boundary it introduces.
Budget compilation like production work
Compile-time work consumes developer latency, CI CPU, memory, artifact storage, and supply-chain exposure. Track at least:
- clean and incremental wall time;
- generator or macro dependency build time;
- number and size of generated items and files;
- monomorphizations caused by const-generic values or generated types;
- cache hit and invalidation behavior;
- peak memory and parallelism;
- diagnostic time and actionability on invalid input.
Measure representative workspaces, not only the tiny fixture. A procedural macro’s dependencies may be shared in one workspace and duplicated across host/target or feature partitions in another. A build dependency can compile separately from a runtime dependency during cross-compilation. A large include! file can make rustc parse and check the same generated syntax in every affected build.
Set budgets with an owner and response. For example: a route-schema edit may add no more than two seconds to an incremental CI build; generated source may not exceed a reviewed size threshold; generator failures must name the input file and field; no generator may access the network in the hermetic build. Without an action, a metric is only a historical chart.
Optimization order matters. Remove unnecessary generation, narrow inputs, reduce dependencies and expansion, avoid redundant monomorphizations, then consider caching. Caching an oversized or nondeterministic generator hides the design problem until a clean build or cache eviction.
Redesign a registry with less machinery
Start from this proposed system:
build.rs scans src/ recursively,
parses every #[route] attribute,
fetches authorization policy from a service,
writes src/generated_routes.rs,
and a startup hook registers every generated handler globally.
Audit it in five passes.
Information: Route declarations are Rust source. Authorization policy is deployment data. Handler identity and ordering are available to an explicit list. The scanner duplicates Rust parsing and hides inputs from Cargo.
Required output: The router needs ordinary route descriptors and handler values. It does not inherently need new syntax, a source-tree rewrite, or global initialization.
Least-powerful designs:
- For a small stable set, write a literal table and test uniqueness.
- For type-local metadata with moderate repetition, derive
RouteSpecand aggregate explicitly withroute_table![Accounts, Health, Events]. - For an authoritative external route schema, run a deterministic generator into
OUT_DIRor check in output with a drift test, depending on whether downstream builds can reproduce the tool.
In all three, load authorization policy at runtime through a typed configuration boundary. Compose handlers explicitly. Do not fetch policy or mutate src/ during a package build.
Now choose one design for these three variants and justify the stage:
- An embedded appliance ships exactly eight protocol commands; command IDs must be compile-time constants and no allocator exists.
- A platform crate consumes a versioned OpenAPI document shared with other languages; client output must be reviewed and regenerated only by a pinned release tool.
- A multi-tenant gateway receives route and authorization updates every minute without restart.
For each, write:
source of truth and required information:
chosen mechanism and weaker rejected mechanism:
declared inputs, target dependence, and output location:
invalid-input diagnostic owner:
clean/incremental/runtime budget:
reproducibility and cache key:
positive, failure, drift, and operational evidence:
stable/MSRV or generator-version policy:
The exercise is complete only when the selected mechanism can be removed or simplified if its motivating requirement disappears. That reversibility tests whether machinery serves the system rather than becoming the system.
A stage-selection review card
- Keep fixed Rust values and tables as ordinary Rust until a specific relationship demands more.
- Use
constandconst fnfor stable, pure, target-evaluated value relationships; remember that ordinary calls are not guaranteed compile-time evaluation. - Use const generics only when a value belongs in the type contract and the monomorphization cost is justified.
- Use macros for syntax ownership, not as a generic way to move work earlier.
- Use
build.rsfor declared package inputs, write only toOUT_DIR, and specify rerun inputs and host/target behavior. - Use an explicit external generator when output must be reviewed, shared, or produced by specialized pinned tooling; pair checked-in output with a drift check.
- Keep deployment-varying facts at runtime and give runtime validation an operational policy.
- Treat reproducibility, invalidation, diagnostics, trust, clean/incremental cost, and artifact size as part of correctness.
- Prefer stable and MSRV-supported facilities; isolate nightly experiments from production contracts.
Sources and version notes
Verified on 2026-07-12 with Rust 1.97.0 and the Rust 1.85.0 MSRV. Stable const capabilities, generic-const expression restrictions, library const stabilization, compiler diagnostics, and Cargo behavior are version-sensitive and must be rechecked at publication and MSRV changes.
- The Rust Reference: constant evaluation
- The Rust Reference: const generics
- The Rust Reference: constant and static items
- The Cargo Book: build scripts
- The Cargo Book: build-script code-generation example
- Standard library
include!documentation
Macro input is a language rather than text substitution, and compile-time engineering is stage engineering. Once the stage is chosen, the next question is evidentiary: which tests can support the resulting system without treating types, generated code, or a successful build as sufficient proof?
Continue reading
Full table of contents