Question
Rust supports 128-bit integers through the i128 and u128 types. For example:
let a: i128 = 170141183460469231731687303715884105727;
How do these i128 values work on a 64-bit system? In particular, how is arithmetic performed when a single i128 value does not fit into one x86-64 register?
Does the compiler represent one i128 value using multiple CPU registers, or is it handled more like a big integer structure behind the scenes?
Short Answer
By the end of this page, you will understand what Rust's i128 really is, how a 64-bit CPU stores and computes with 128-bit integers, and why i128 is still a fixed-size primitive type rather than an arbitrary-precision big integer. You will also see how compilers break 128-bit operations into smaller machine instructions when the hardware cannot do the whole operation in one step.
Concept
Rust's i128 and u128 are primitive fixed-width integer types. That means they always occupy exactly 128 bits of storage.
They are not arbitrary-precision integers like BigInt. A BigInt can grow to any size at runtime, usually by allocating memory on the heap. An i128, by contrast, has a known size at compile time and is stored inline like i32 or i64.
What this means on a 64-bit CPU
A 64-bit CPU is optimized for values that fit naturally into 64-bit registers, but that does not mean it cannot work with larger fixed-size values.
When the CPU lacks a single instruction for a full 128-bit operation, the compiler generates multiple smaller instructions to simulate it. Conceptually, it treats a 128-bit integer as two 64-bit halves:
- a low 64-bit part
- a high 64-bit part
Then it performs arithmetic with carries, borrows, shifts, and comparisons across those halves.
Why this matters
This idea appears all over systems programming:
- implementing large counters
- precise hashing and bit manipulation
- high-range numeric IDs
- cryptographic and serialization code
- compiler backends and low-level libraries
Understanding i128 helps you see the difference between:
Mental Model
Think of a 128-bit integer like a very large number written across two adjacent 64-bit boxes.
- The first box stores the lower half.
- The second box stores the upper half.
If you add two such numbers:
- Add the lower boxes.
- If that overflows, carry
1into the upper boxes. - Add the upper boxes.
This is the same way you do long addition by hand:
- add the rightmost digits first
- carry to the next column if needed
So i128 on a 64-bit machine is not magic. It is mostly "do normal arithmetic in pieces".
The compiler acts like someone carefully doing arithmetic column by column, but using 64-bit chunks instead of decimal digits.
Syntax and Examples
Rust lets you use i128 and u128 just like other integer types.
Basic syntax
fn main() {
let signed: i128 = -42;
let unsigned: u128 = 42;
let sum = signed + 100;
let product = unsigned * 2;
println!("{} {}", sum, product);
}
Large values
fn main() {
let max: i128 = i128::MAX;
let min: i128 = i128::MIN;
println!("max = {}", max);
println!("min = {}", min);
}
What the compiler conceptually does
The following is not how you normally write Rust, but it shows the idea behind 128-bit addition on a 64-bit machine:
Step by Step Execution
Consider this example:
fn main() {
let a: u128 = (1u128 << 64) + 10;
let b: u128 = 25;
let c = a + b;
println!("{}", c);
}
Let's walk through it conceptually.
Step 1: Build a
let a: u128 = (1u128 << 64) + 10;
This value means:
- high 64 bits =
1 - low 64 bits =
10
So a is conceptually:
| High 64 bits | Low 64 bits |
|---|
Real World Use Cases
Even though i128 is larger than the natural register size on many CPUs, it is still useful in real software.
Common use cases
- Large counters: tracking huge event counts, byte totals, or IDs.
- Time calculations: representing very large durations or timestamps safely.
- Hashing and checksums: some algorithms combine values into 128-bit results.
- Binary protocols: some file formats and network formats store 128-bit fields.
- Financial or fixed-point math: extra range can help when scaling integer values.
- UUID-related processing: UUIDs are 128 bits, and some code manipulates them as one integer-like value.
Example: large byte counter
fn main() {
let files = [5_000_000_000u128, 8_000_000_000u128, 12_000_000_000u128];
let total: u128 = files.iter().sum();
println!("Total bytes: {}", total);
}
Example: combining two u64 values into one u128
fn (high: , low: ) {
((high ) << ) | (low )
}
() {
= (, );
(, id);
}
Real Codebase Usage
In real Rust projects, developers usually treat i128 as a normal integer type, but they remain aware that some operations may be more expensive than i64 on some targets.
Common patterns
Validation and range safety
fn parse_amount(input: &str) -> Result<i128, String> {
input.parse::<i128>().map_err(|_| "invalid number".to_string())
}
A larger integer type can reduce overflow risk when parsing user or external data.
Intermediate calculations
Developers often use i128 for intermediate math even if the final result is smaller.
fn multiply_then_divide(a: i64, b: i64, divisor: i64) -> i64 {
((a as i128 * b as i128) / divisor as i128) as
}
Common Mistakes
Mistake 1: Thinking i128 is a big integer type
i128 is fixed-size, not arbitrary precision.
let x: i128 = i128::MAX;
// x cannot grow beyond 128 bits
If you need unlimited size, use a big integer library such as num-bigint.
Mistake 2: Assuming one CPU instruction handles every operation
Beginners sometimes assume primitive type = one register = one instruction. That is not always true.
A type can be primitive at the language level even if the compiler must emit multiple machine instructions.
Mistake 3: Forgetting overflow rules
fn main() {
let x: i128 = i128::MAX;
let y = x + 1; // may panic in debug builds due to overflow
println!("{}", y);
}
Use checked or wrapping arithmetic when needed:
fn () {
: = ::MAX;
(, x.());
(, x.());
}
Comparisons
| Concept | Size | Fixed at compile time? | Heap allocation? | Typical implementation on 64-bit CPU |
|---|---|---|---|---|
i64 | 64 bits | Yes | No | Usually fits in one register |
i128 | 128 bits | Yes | No | Often uses two 64-bit parts or helper routines |
u128 | 128 bits | Yes | No | Same as i128, but unsigned |
BigInt | Variable | No | Usually yes |
Cheat Sheet
Quick facts
i128= signed 128-bit integeru128= unsigned 128-bit integer- Both are fixed-size primitive types
- They are not arbitrary-precision integers
- On a 64-bit CPU, arithmetic may use multiple instructions
Useful constants
i128::MIN
i128::MAX
u128::MIN
u128::MAX
Basic syntax
let a: i128 = -123;
let b: u128 = 500;
let c = a + 10;
Conversions
let x: u64 = 5;
let y: u128 = x as u128;
Safe arithmetic helpers
FAQ
Is i128 a real primitive type in Rust?
Yes. i128 and u128 are built-in primitive integer types in Rust.
Does a 64-bit CPU have a 128-bit register for i128?
Usually not for normal integer registers. The compiler typically uses multiple registers, stack storage, helper routines, or instruction sequences to implement operations.
Is i128 implemented as a struct in Rust?
Not at the language level. It behaves as a primitive scalar type, even if the compiler internally lowers operations into smaller parts.
Is i128 the same as BigInt?
No. i128 has a fixed maximum size of 128 bits. BigInt can grow beyond that.
Are i128 operations slower than i64?
They can be, especially on hardware without direct support for wide integer operations. But they are still often practical and convenient.
Can Rust use two 64-bit registers for one i128 value?
Yes, conceptually and often in practice. The exact strategy depends on the target architecture, ABI, and optimization decisions made by the compiler.
Mini Project
Description
Build a small Rust program that stores and manipulates 128-bit values by combining two u64 numbers into one u128. This demonstrates that u128 is a fixed-size primitive while also helping you think in terms of high and low 64-bit halves, which is how wide arithmetic is commonly reasoned about on 64-bit systems.
Goal
Create a program that packs two u64 values into a u128, unpacks them again, and performs addition while printing the result.
Requirements
- Write a function that combines a high
u64and lowu64into oneu128. - Write functions to extract the high and low 64-bit halves from a
u128. - Add two
u128values and print the resulting high and low halves. - Demonstrate a case where adding the low half produces a carry into the high half.
Keep learning
Related questions
Accessing Cargo Package Metadata in Rust
Learn how to read Cargo package metadata like version, name, and authors in Rust using compile-time environment macros.
Associated Types vs Generic Type Parameters in Rust: When to Use Each
Learn when to use associated types vs generic parameters in Rust traits, with clear rules, examples, and practical API design advice.
Convert an Integer to a String in Rust
Learn the current Rust way to convert integers to strings, why `to_str()` no longer works, and when to use `to_string()` or `format!`.