When constants refuse to cooperate
You are building a configuration system for a game. You want to define the maximum health of a boss as a constant so the value is baked into the binary and available everywhere. You write a helper function to calculate the health based on a level multiplier. You assign the result to a const. The compiler rejects you with E0015: calls in constants are limited to...
You stare at the error. The function works fine when you call it in main. Why can't you use it to initialize a constant? You assumed constants were just values. You didn't expect the compiler to care about how you calculated them.
Rust treats constants as values that must exist before the program starts running. The compiler has to know the exact value at compile time. If you call a function to compute that value, the compiler needs to run that function during compilation. Most functions are not allowed to run during compilation. They might print to the console, read a file, or allocate memory in ways the compiler cannot verify. E0015 is the compiler telling you that your function is not safe to run in its mini-executor.
The compiler's mini-executor
Rust's compiler contains a small interpreter. It can evaluate certain Rust code while it is building your program. This interpreter runs in a restricted environment. It cannot touch the operating system. It cannot allocate memory on the heap in arbitrary ways. It cannot perform I/O. It must guarantee that the code terminates and produces a deterministic result.
When you write a const definition, the compiler tries to evaluate the right-hand side immediately. If the expression is a literal like 42 or "hello", it accepts it. If the expression calls a function, the compiler checks whether that function is allowed to run in the restricted environment.
Regular functions are not allowed. They might do anything. The compiler cannot prove they are safe. Functions marked with const are different. The const keyword is a promise. It tells the compiler that the function contains only operations the mini-executor can handle. The compiler audits the body of a const fn to verify the promise. If the function tries to call println! or use a non-const trait, the compiler rejects the function definition itself.
Minimal example
Here is the code that triggers E0015. The function calculate_health is a regular function. The compiler cannot run it at compile time.
fn calculate_health(level: u32) -> u32 {
// This function is fine at runtime.
// It does simple math.
level * 100
}
// The compiler tries to evaluate calculate_health(5) now.
// It sees a regular fn and stops.
const MAX_HEALTH: u32 = calculate_health(5);
The error message points to the call. It says calls in constants are limited to struct and enum constructors, const functions, and const traits. You are calling a non-const function. The fix is to mark the function as const.
// Adding const tells the compiler this function is safe to evaluate early.
const fn calculate_health(level: u32) -> u32 {
// Simple arithmetic is allowed in const context.
level * 100
}
// Now the compiler runs this during compilation.
// MAX_HEALTH becomes 500 in the binary.
const MAX_HEALTH: u32 = calculate_health(5);
The code compiles. The value 500 is baked into the binary. No function call happens at runtime.
What const fn actually means
Marking a function const changes how the compiler treats it. The compiler checks every operation inside the function. It ensures you are not using features that require runtime support. You cannot call non-const functions. You cannot use println!. You cannot use rand. You cannot dereference raw pointers without unsafe.
The rules for const fn have loosened over time. Rust 1.78 and later allow more operations in const contexts. You can now use loops, match statements, and many standard library methods. The compiler is getting better at evaluating code. However, the core restriction remains: the function must be pure and deterministic. It must produce the same output for the same input, every time. It must not have side effects.
Here is a surprising detail. A const fn can be called at runtime too. The const keyword does not force the function to run only at compile time. It allows the function to run at compile time. If you call a const fn in a regular context, the compiler generates a normal function call. You get the best of both worlds. The function works in constants, and it works in main. You do not need to write two versions of the function.
/// Calculates health based on level.
/// Works in const context and at runtime.
const fn calculate_health(level: u32) -> u32 {
level * 100
}
fn main() {
// This call happens at runtime.
// The compiler generates a function call.
let runtime_health = calculate_health(10);
println!("Runtime health: {}", runtime_health);
// This value is computed at compile time.
// No function call exists in the binary.
const COMPILE_HEALTH: u32 = calculate_health(5);
println!("Compile health: {}", COMPILE_HEALTH);
}
Convention aside: The community prefers const fn for pure calculations whenever possible. It gives the compiler more flexibility. It allows the value to be used in array sizes, enum discriminants, and other places that require compile-time constants. It also opens the door for the compiler to optimize more aggressively.
The string trap
The most common follow-up to E0015 involves String. You fix the function to be const, but now you get a different error. The function returns a String, and the compiler says String cannot be created in a const context.
const fn get_message() -> String {
// String::from is not const.
// It allocates memory on the heap.
String::from("Hello")
}
const MESSAGE: String = get_message();
This fails. String involves heap allocation and UTF-8 validation. The standard library does not provide a const constructor for String. You cannot create a String inside a const fn. The compiler cannot verify the allocation is safe in the restricted environment.
The solution is to use &'static str instead. String literals live in the binary data. They do not require allocation. The compiler can handle them easily.
/// Returns a static string slice.
/// Safe for const context.
const fn get_message() -> &'static str {
// String literals are &'static str.
// They are embedded in the binary.
"Hello"
}
const MESSAGE: &'static str = get_message();
This compiles. MESSAGE points to data embedded in the executable. If you need an owned String at runtime, you can convert it later.
fn main() {
// Convert to String when you need ownership.
let owned = MESSAGE.to_string();
println!("{}", owned);
}
Convention aside: Use &'static str for constants whenever you can. It avoids allocation entirely. It is faster and uses less memory. Convert to String only when you need to modify the text or pass it to an API that requires ownership.
Pitfalls and compiler errors
E0015 often masks deeper issues. The compiler might reject your const fn for reasons that are not obvious. Here are common pitfalls.
Panic in const context. If your const fn panics, the compiler stops. It cannot recover from a panic during evaluation. You get a hard error. The compiler will tell you that the constant evaluation panicked. This is useful. It catches bugs early. If your calculation divides by zero, you find out at compile time, not when the game crashes in production.
Non-const dependencies. Your const fn might call another function that is not const. The compiler rejects the chain. You must make every function in the call chain const. This can be tedious. You might need to mark helper functions const even if you only use them in one place.
Trait bounds. Some traits are not const. If your function uses a trait method that is not marked const, the compiler rejects it. You cannot call .clone() on a type unless the Clone implementation is const. Many standard library types have const implementations now, but not all. Check the documentation.
Infinite loops. The compiler must prove that your const fn terminates. If you write an infinite loop, the compiler rejects it. It detects loops that do not have a clear exit condition. This prevents the compiler from hanging during build.
Convention aside: Keep const fn bodies small and focused. Complex logic is harder to verify. If a function becomes too complicated, consider moving it to runtime. The compiler's const evaluator is powerful, but it is not a full runtime. It has limits.
Decision: when to use const, static, or runtime
You have several options for defining values. Each has a specific role. Choose based on how the value is used and when it needs to exist.
Use const when the value is a simple literal or the result of a pure calculation. Use const when you want the value inlined everywhere it is used. Use const when you need the value in places that require compile-time constants, like array sizes or enum discriminants. Use const fn to compute the value.
Use static when you need a single shared instance of a value. Use static when you need to take the address of the value and pass it around. Use static when the value is large and you do not want to duplicate it in memory. Use static with LazyLock or OnceCell when the value requires runtime initialization.
Use a runtime let when the value depends on dynamic data. Use a runtime let when the calculation involves I/O, randomness, or user input. Use a runtime let when the function cannot be made const. Move the logic into main or an initialization function.
Use const blocks when you need to compute a value locally without defining a named constant. Use const blocks to extract compile-time evaluation from a function body. This feature is available in recent Rust versions. It allows you to write let x = const { calculate() }; inside a function.