Every language has its “boring” chapter about basic types, and every language treats that chapter as if it doesn’t matter much. Rust is different. I learned pretty quickly that Rust’s primitive types are where a lot of its safety guarantees actually begin. Integer overflow, floating-point precision, and even something as simple as a character type are handled with a level of deliberateness I hadn’t seen before switching to Rust. This article covers everything I’ve learned about Rust’s primitive types — integers, floats, booleans, and characters — from the fundamentals up to the details that actually matter in production code.
Integers: More Precise Than You’re Used To
Most languages give you a generic int and let the runtime or compiler decide the size. Rust makes you choose, and that choice is part of the type system.
Rust’s signed integer types are i8, i16, i32, i64, i128, and isize. The unsigned versions are u8, u16, u32, u64, u128, and usize. The number tells you the bit width; isize and usize are sized to match the pointer width of your target platform (64 bits on most modern systems).
fn main() {
let small: i8 = -120;
let medium: i32 = 42;
let large: u64 = 18_446_744_073_709_551_615;
let pointer_sized: usize = 100;
println!("{small} {medium} {large} {pointer_sized}");
}
If I don’t annotate a type, Rust defaults integer literals to i32, which is usually the fastest general-purpose integer type on modern CPUs.
Integer Overflow: Where Rust Gets Serious
This is the part that genuinely changed how I write numeric code. In debug builds, Rust panics on integer overflow:
fn main() {
let x: u8 = 255;
let y = x + 1; // panics in debug mode: "attempt to add with overflow"
println!("{y}");
}
In release builds (compiled with optimizations), Rust instead wraps around silently by default, using two’s complement wrapping — which is fast but can hide bugs. Because of that gap between debug and release behavior, Rust gives you explicit methods so you never have to guess:
fn main() {
let x: u8 = 255;
println!("{:?}", x.checked_add(1)); // None (no panic, no wrap)
println!("{}", x.wrapping_add(1)); // 0 (wraps)
println!("{}", x.saturating_add(1)); // 255 (clamps at max)
let (result, overflowed) = x.overflowing_add(1);
println!("{result} {overflowed}"); // 0 true
}
I use checked_* methods whenever user input or external data feeds into arithmetic, saturating_* when I want values clamped rather than wrapped (like a health bar in a game), and wrapping_* for things like hash functions where wraparound is the intended behavior.
Numeric Literals and Readability
Rust lets you use underscores as visual separators and suffixes to pin down a type inline:
fn main() {
let population = 241_499_431u64;
let hex_value = 0xFF_u8;
let octal_value = 0o77;
let binary_value = 0b1010_1010u8;
println!("{population} {hex_value} {octal_value} {binary_value}");
}
Floating-Point Numbers
Rust has two float types: f32 (single precision) and f64 (double precision, and the default). Both follow the IEEE 754 standard.
fn main() {
let price: f64 = 19.99;
let ratio: f32 = 0.5;
println!("Price: {price}, ratio: {ratio}");
}
Floats bring the usual precision caveats every language has:
fn main() {
let sum = 0.1 + 0.2;
println!("{sum}"); // 0.30000000000000004
}
This isn’t a Rust bug — it’s how binary floating-point representation works everywhere. In practice, I avoid == comparisons on floats and instead check that the difference is within a small tolerance:
fn approx_equal(a: f64, b: f64, epsilon: f64) -> bool {
(a - b).abs() < epsilon
}
For financial calculations, I don’t use floats at all — I reach for an integer-based representation (cents instead of dollars) or a crate like rust_decimal, because floating-point rounding errors are unacceptable when money is involved.
Useful Float Methods
fn main() {
let x: f64 = -4.7;
println!("{}", x.abs());
println!("{}", x.floor());
println!("{}", x.ceil());
println!("{}", x.round());
println!("{}", x.sqrt().is_nan()); // true, since x is negative
}
Booleans: Simple, But Strict
Rust’s bool type has exactly two values: true and false, occupying one byte in memory.
fn main() {
let is_logged_in: bool = true;
let has_permission = false;
if is_logged_in && !has_permission {
println!("Logged in but missing permission");
}
}
The strictness shows up in what Rust won’t let you do. Unlike C or JavaScript, integers don’t implicitly convert to booleans:
fn main() {
let n = 0;
// if n { } // compile error: expected `bool`, found integer
if n != 0 {
println!("n is nonzero");
}
}
This one small rule eliminates a whole category of “truthy/falsy” confusion bugs I used to run into constantly in dynamically typed languages.
Characters: Not What You’d Expect
This is the primitive type that surprised me the most. Rust’s char type is not one byte — it’s a 4-byte Unicode Scalar Value, capable of representing any character from U+0000 to U+D7FF and U+E000 to U+10FFFF.
fn main() {
let letter: char = 'R';
let emoji: char = '🦀';
let chinese: char = '中';
println!("{letter} {emoji} {chinese}");
}
Because char is always 4 bytes and represents a full Unicode scalar value, you never have to worry about a char being “cut off” mid-character the way you might with raw bytes in a UTF-8 string. This is a deliberate design decision that ties directly into how Rust handles strings safely.
Speaking of strings — this is the classic gotcha. You cannot index a String by integer position in Rust:
fn main() {
let s = String::from("héllo");
// let c = s[1]; // compile error
// Correct approach: iterate over chars
for c in s.chars() {
print!("{c}-");
}
println!();
}
Output:
h-é-l-l-o-
The reason indexing is disallowed is that String is stored as UTF-8 bytes, and a single char might occupy multiple bytes. Allowing s[1] to silently return a broken byte in the middle of a multi-byte character would violate Rust’s memory safety guarantees around valid UTF-8. Instead, Rust forces you to be explicit about whether you want bytes (s.bytes()), Unicode scalar values (s.chars()), or grapheme clusters (via an external crate like unicode-segmentation, since “true” human-perceived characters are more complex than Unicode scalars alone).
Type Casting Between Primitives
Rust doesn’t do implicit numeric conversions — I have to use as explicitly, or safer conversion traits:
fn main() {
let a: i32 = 300;
let b = a as u8; // truncates: 300 % 256 = 44
println!("{b}");
let c: i64 = 42;
let d: f64 = c as f64;
println!("{d}");
let e: char = 65u8 as char; // 'A'
println!("{e}");
}
as casts can silently truncate or lose precision, which is why for anything involving untrusted input I prefer TryFrom/TryInto, which return a Result instead of quietly truncating:
use std::convert::TryFrom;
fn main() {
let big: i32 = 300;
match u8::try_from(big) {
Ok(value) => println!("Converted: {value}"),
Err(e) => println!("Conversion failed: {e}"),
}
}
Memory Layout and Performance Notes
Primitive types in Rust have predictable, fixed sizes known at compile time, which is part of why Rust can put them on the stack without heap allocation. An i32 is always 4 bytes, a bool is always 1 byte, a char is always 4 bytes. This predictability is what lets the compiler generate extremely tight, efficient machine code, and it’s also why structs made entirely of primitives are so cheap to copy, pass around, and store in arrays.
If you’re optimizing memory layout (say, for a struct used millions of times in a hot loop), it’s worth knowing that field ordering can affect padding due to alignment rules. Tools like the #[repr(C)] attribute or crates like memoffset can help when you need precise control.
Best Practices I Follow
- Choose the smallest integer type that comfortably fits your value range, but don’t over-optimize prematurely —
i32/u32are fine defaults for most application code. - Use
usizefor anything related to indexing or collection sizes, since that’s what Rust’s standard library expects. - Never compare floats with
==; use an epsilon-based comparison or a fixed-point/decimal type for exact values like currency. - Prefer
TryFrom/TryIntooveraswhen converting values that could be out of range, especially from user input.
Common Mistakes and Debugging Tips
A mistake I made early on was assuming char indexing on strings would “just work” like it does in Python or C. Once I understood that Rust strings are UTF-8 byte sequences and char is a 4-byte Unicode scalar, the design made much more sense — and I stopped fighting the compiler on this.
Another common issue: forgetting that debug and release builds handle integer overflow differently. If your program works fine with cargo run but panics or behaves oddly after cargo build --release, integer overflow silently wrapping is a good first thing to check — and switching to checked_add/checked_sub in the relevant spot usually resolves it.
Frequently Asked Questions
Why doesn’t Rust have implicit type coercion between numeric types? It’s a deliberate safety decision — implicit coercion hides precision loss and overflow bugs. Rust wants every conversion to be visible in the code.
What’s the difference between usize and u64? usize is sized to match your platform’s pointer width (commonly 64-bit today, but 32-bit on some embedded targets), while u64 is always exactly 64 bits regardless of platform. Use usize for indexing; use u64 when you need a guaranteed fixed width.
Is f64 always better than f32? Not always — f64 gives more precision but uses double the memory and can be slower on some hardware, particularly GPUs or SIMD-heavy workloads where f32 is often preferred.
Why is Rust’s char 4 bytes instead of 1? Because it represents a full Unicode Scalar Value, not just an ASCII byte, so it can safely hold any valid Unicode code point without truncation.
Summary
Rust’s primitive types look simple on the surface, but each one encodes a safety decision: explicit integer sizing and overflow handling, IEEE 754-compliant floats with no silent implicit conversions, a strict boolean type with zero “truthy” ambiguity, and a Unicode-aware char type that ties directly into safe string handling. Understanding these details early saved me from a lot of subtle bugs later, especially when working with user input, financial data, and internationalized text.
References
- The Rust Programming Language Book — Chapter on Data Types (doc.rust-lang.org/book)
- Official Rust Standard Library documentation for primitive types (doc.rust-lang.org/std)
- The Rust Reference — Type Layout (doc.rust-lang.org/reference)
- The Cargo Book (doc.rust-lang.org/cargo)
