nanoexpr: math expression evaluator for embedded Rust
Parse and evaluate math formulas typed at run time, on a microcontroller, without a heap. nanoexpr is a tiny
no_std math expression parser and evaluator for Rust and C: it computes "2 * (3 + 4) ^ 2 / sqrt(16)" in a single
pass, with zero heap allocation, bounded stack use and errors that point at the faulty part of the input.
assert_eq!;
| At a glance | |
|---|---|
| Heap allocations | 0, checked by a test |
| Speed | about 270 ns to parse and evaluate a formula, at least 1.6 times faster than the other crates measured |
| Size | 6 KiB of WebAssembly for integers, 46 KiB with f64 and every math function |
| Targets | Any no_std target: Cortex-M, RISC-V (ESP32-C3), WebAssembly, desktops. Built in CI for each |
| Numbers | f32, f64, integers, calculator-exact Decimal, or your own type |
| Languages | Rust, and C or C++ through a CMake project |
| Dependencies | None, except the optional pure Rust libm |
Why nanoexpr?
Letting users type a formula is common: a calculator app, a sensor conversion set in the field (raw * 0.0125 - 40),
an alarm threshold, a spreadsheet cell, a game stat. Most expression crates are built for desktops: they need std,
allocate a syntax tree and strings on every call, and turn bad input into NaN or a panic. On a microcontroller that
means heap fragmentation, unpredictable memory use and crashes. nanoexpr is built for those constraints instead,
and is faster than the others even on a desktop.
Use it for:
- Calculator apps on handhelds, watches and embedded GUIs, with TI-style decimal numbers and a percent key.
- Sensor formulas and unit conversions configured without reflashing the firmware.
- Thresholds, scaling and business rules stored as text in a configuration file or a database.
- WebAssembly and plugins, where a small binary and no allocator matter.
Features
- No heap, ever. No
alloc, no syntax tree, no token buffer: the expression is computed while it is parsed. A test with a counting allocator checks it. - Bounded stack. Nesting is limited (32 levels by default, configurable) so hostile input cannot overflow the stack, and call arguments live in a fixed-size array.
- Any number type.
f32,f64, every primitive integer, or your own type (fixed-point...) through theNumbertrait. Integers report overflow instead of wrapping. - Calculator-exact decimals. The optional
Decimaltype computes in decimal like TI and Casio calculators: 14 significant digits and exponents up to ±99 by default, so0.1 + 0.2is exactly0.3. - Variables and functions from your code. The evaluator asks a
Contextfor each name it meets: implement the trait, pass(name, value)pairs, or wrap closures. Contexts compose as tuples. - Scientific functions included.
Mathprovidespi,e,sqrt,sin,cos,tan,exp,ln,log,min,max,clamp, rounding and more. - Errors you can show. A
Copyerror with a kind to match on (and translate) and the byte span of the input that caused it. Division by zero, domain errors and overflow are errors, not silentNaNor infinities. - Calculator-friendly syntax.
^and**are right-associative (2^3^2 = 512),-2^2 = -4, and%can be a percentage (200 * 15% = 30) instead of the remainder. - C and C++ bindings with a header and a CMake project, still allocation-free.
- No dependencies besides the optional
libm, and nounsafecode in the evaluator.
Usage
[]
= "0.1"
Variables and functions
use ;
let variables = ;
let functions = Functions;
let context = ;
assert_eq!;
assert_eq!;
For full control, implement Context on your own type:
use Context;
let sensors = Sensors ;
assert_eq!;
Errors
use ;
let error = .unwrap_err;
assert_eq!;
assert_eq!;
assert_eq!;
Calculator mode and limits
use ;
let calculator = new
.percent_sign
.max_depth;
assert_eq!;
assert_eq!;
The second generic parameter is the most arguments a call can take (8 by default).
Decimal numbers, like a pocket calculator
Binary floats cannot represent most decimal fractions, so 0.1 + 0.2 is 0.30000000000000004 as an f64.
Calculators compute in decimal instead. With the decimal feature, Decimal does the same: a decimal floating-point
number of 14 significant digits with exponents up to ±99 by default, as on TI-83 and TI-84 calculators. Each
operation rounds its exact result once, half to even, and checks against Python's decimal module show they match.
[]
= { = "0.1", = ["decimal"] }
#
Like a calculator, keep more digits than you show: formatting with a precision such as {:.10} rounds to that many
significant digits, which hides the rounding of intermediate results. Choose other sizes with the generic
parameters, such as Decimal<10, 99> or Decimal<18, 9999>.
Arithmetic, powers with integer exponents, abs, sign, floor, ceil, round, trunc, min, max and
clamp are computed in decimal. The other functions of Math, such as sqrt, sin or ln, go through f64, so
their results are accurate to about 15 digits, except very close to a zero of the function, such as sin(pi).
C and C++
The ffi directory of the repository holds C bindings
that evaluate double expressions, still without allocating. Variables and functions come from callbacks, and the
built-in math functions remain available:
static bool
double raw = 2150, result;
nanoexpr_error error;
nanoexpr_options options = ;
options.variable = variable;
options.user_data = &raw;
const char *input = "raw * 0.0125 - 40";
if
The header is ffi/include/nanoexpr.h.
With CMake, add the directory and link the nanoexpr target, which builds the static library with cargo:
add_subdirectory(nanoexpr/ffi)
target_link_libraries(app PRIVATE nanoexpr)
Without CMake, cargo build --release -p nanoexpr-ffi builds target/release/libnanoexpr_c.a (and a shared
library). cargo rustc --release -p nanoexpr-ffi -- --print native-static-libs lists the system libraries to link
with it. For a microcontroller, build without the standard library and link the archive into your firmware:
Without std the library brings its own panic handler, which loops forever, so build it with panic = "abort", the
default of bare-metal targets. The evaluator does not panic on any input.
Syntax
From the loosest to the tightest binding:
| Operators | Meaning | Associativity |
|---|---|---|
+ - |
Addition, subtraction | Left |
* / % mod |
Multiplication, division, remainder | Left |
-x +x |
Negation, unary plus | Prefix |
^ ** |
Power | Right |
x% |
Percentage, with PercentSign::Percentage |
Postfix |
( ) name(a, b) |
Grouping, function call |
Numbers are written 42, 3.14, .5, 5. or 1.5e-3. Names start with a letter or _ and continue with
letters, digits or _.
Footprint
Sizes of a WebAssembly module exporting one evaluation function, built with opt-level = "z", LTO and
panic = "abort":
| Configuration | Size |
|---|---|
i32, no Math |
5.7 KiB |
f64, no Math |
28.7 KiB |
f64 with Math |
45.9 KiB |
Most of the f64 size is core's float parsing, which any crate reading float literals pays for.
Comparison with other Rust expression evaluators
How nanoexpr compares with other expression evaluators on crates.io, as of their latest release in October 2026:
| Crate | no_std |
Heap allocation | Number types | Required dependencies | Latest release | License |
|---|---|---|---|---|---|---|
| nanoexpr | ✅ | None | f32, f64, all integers, your own |
None (libm optional) |
2026 | MIT |
| meval 0.2.0 | ❌ | Yes | f64 |
nom, fnv |
2018 | Unlicense / MIT |
| evalexpr 13.1.0 | ❌ | Yes | Integers, floats, booleans, strings, tuples | None | 2025 | AGPL-3.0 |
| fasteval 0.2.4 | ❌ | Yes | f64 |
None | 2020 | MIT |
| exmex 0.21.0 | ❌ | Yes | Floats, custom operator sets | regex, smallvec |
2026 | MIT / Apache-2.0 |
| exp-rs 0.2.0 | ⚠️ ARM only, needs alloc |
Yes, arena (bumpalo) |
f32 or f64, chosen at build time |
bitflags, bumpalo, critical-section, hash32, heapless, serde |
2025 | MIT / Apache-2.0 |
| maschen 0.2.1 | ✅ | None without std |
Not applicable: converts infix to postfix, does not evaluate | thiserror |
2024 | MIT |
And what each is best at:
| Crate | Pick it when you need |
|---|---|
| nanoexpr | Run-time math on a microcontroller or in WebAssembly, with predictable memory and errors to show users |
| meval | Quick f64 evaluation on a desktop, binding expressions to closures |
| evalexpr | A small scripting language: booleans, strings, assignment, tuples (mind the AGPL license) |
| fasteval | Compiling an expression once and evaluating it many times, fast, on a desktop or server |
| exmex | Symbolic derivatives and custom operators |
| exp-rs | An embedded evaluator with a C API and an allocator you control |
| maschen | Building your own evaluator on top of a shunting-yard |
nanoexpr parses each time it evaluates. To evaluate the same formula millions of times on a desktop, a crate that
compiles expressions first, such as fasteval or exmex, will be faster.
Performance
Each crate parses and evaluates three expressions from a string, once per call:
| Expression | Input |
|---|---|
| Arithmetic | ((5.0 / 8.0) + 18.0 ^ 0.5 * 10.0 ^ 5.0) / 23.0 |
| Functions | sin(0.5) * cos(0.5) + sin(1.5) / cos(1.5) (math::sin and math::cos for evalexpr) |
| Variables | x * x + 2.0 * x * y + y ^ 2.0 with x = 1.5 and y = 2.5 |
Median time per evaluation:
| Crate | Arithmetic | Functions | Variables |
|---|---|---|---|
| nanoexpr | 270 ns | 270 ns | 140 ns |
| meval 0.2.0 | 1340 ns | 1330 ns | 710 ns |
| evalexpr 13.1.0 | 2370 ns | 3310 ns | 2090 ns |
| fasteval 0.2.4 | 430 ns | 650 ns | 350 ns |
| exmex 0.21.0 | 3460 ns | 3570 ns | 6330 ns |
| exp-rs 0.2.0 | 3080 ns | 2940 ns | 3070 ns |
Heap use per evaluation, as allocations / bytes allocated / peak live bytes:
| Crate | Arithmetic | Functions | Variables |
|---|---|---|---|
| nanoexpr | 0 / 0 B / 0 B | 0 / 0 B / 0 B | 0 / 0 B / 0 B |
| meval 0.2.0 | 36 / 6106 B / 3296 B | 43 / 4594 B / 2424 B | 18 / 2404 B / 1794 B |
| evalexpr 13.1.0 | 80 / 7440 B / 3264 B | 136 / 8826 B / 4008 B | 76 / 5416 B / 2146 B |
| fasteval 0.2.4 | 12 / 9166 B / 9166 B | 22 / 10191 B / 10027 B | 11 / 8259 B / 8259 B |
| exmex 0.21.0 | 2 / 2606 B / 2606 B | 2 / 2601 B / 2601 B | 4 / 2591 B / 2591 B |
| exp-rs 0.2.0 | Not measured | Not measured | Not measured |
Size of a WebAssembly module exporting one function that evaluates a string with each crate, built with
opt-level = "z", LTO, panic = "abort" and symbols stripped. The baseline module only unwraps an Option, which
pulls in the standard library's panic and formatting code that every other module also contains:
| Crate | Size | Over baseline |
|---|---|---|
| Baseline | 15.1 KiB | |
| nanoexpr | 75.3 KiB | 60.2 KiB |
| meval 0.2.0 | 140.0 KiB | 124.8 KiB |
| evalexpr 13.1.0 | 192.7 KiB | 177.5 KiB |
| fasteval 0.2.4 | 128.1 KiB | 113.0 KiB |
| exmex 0.21.0 | 1015.2 KiB | 1000.1 KiB |
| exp-rs 0.2.0 | 172.5 KiB | 157.3 KiB |
These modules use std, unlike the no_std builds in Footprint.
Measured on an Apple M2 Pro with rustc 1.99.0, in October 2026. Every call parses the expression again: none is
compiled once and reused. fasteval, exmex and meval can compile an expression once and then evaluate it much
faster, so for a formula evaluated many times they can beat nanoexpr. Times vary by a few percent between runs. Heap
use is counted by a global allocator wrapper; exp-rs installs its own global allocator, so its heap use cannot be
measured that way. To reproduce, from the
comparison directory of the repository:
Feature flags
| Feature | Default | Effect |
|---|---|---|
libm |
✅ | Number for f32 and f64, Math and evaluate, through the pure Rust libm |
decimal |
The Decimal type, and Math for it. Enables libm |
Frequently asked questions
How do I evaluate a math expression from a string in Rust? Add nanoexpr and call
nanoexpr::evaluate::<f64>("1 + 2 * 3"). Use evaluate_with to provide variables and functions.
Does it work without std or an allocator? Yes. The crate is #![no_std], never uses alloc, and needs no
global allocator, so it runs on bare-metal microcontrollers.
Why is 0.1 + 0.2 not 0.3? With f64, no binary float can hold 0.1 exactly. Use the decimal feature and
Decimal to compute in decimal like a pocket calculator.
Can a user crash the device with a malicious formula? It is designed not to: evaluation never allocates, nesting depth is bounded, arithmetic is checked, and malformed, deep or overflowing input returns an error.
Is it a scripting language? No: it evaluates numeric expressions only. For strings, booleans and assignments, see the comparison below for crates that do more.