How do I convert integers to strings fast with itoa in Rust?

Walkthrough

The itoa crate provides ultra-fast integer-to-string conversion. While Rust's standard library ToString::to_string() works fine for most cases, itoa can be 2-10x faster by avoiding heap allocation in many cases and using highly optimized conversion algorithms. It's particularly valuable in performance-critical code like serialization, logging, and data processing where millions of integer conversions occur.

Key concepts:

  1. itoa::fmt — writes formatted integers directly to a fmt::Write target
  2. itoa::Buffer — a reusable buffer that avoids repeated allocations
  3. No heap allocation — the buffer lives on the stack
  4. Write trait — integrates with std::fmt::Write and std::io::Write
  5. All integer types — supports i8, i16, i32, i64, i128, isize, and unsigned variants

Code Example

# Cargo.toml
[dependencies]
itoa = "1.0"
use itoa::Buffer;
 
fn main() {
    let mut buffer = Buffer::new();
    
    let value: i32 = 42;
    let s = buffer.format(value);
    println!("{}", s); // "42"
    
    let large: i64 = 1234567890123456789;
    let s = buffer.format(large);
    println!("{}", s); // "1234567890123456789"
    
    let negative: i32 = -98765;
    let s = buffer.format(negative);
    println!("{}", s); // "-98765"
}

Basic Usage

use itoa::Buffer;
 
fn main() {
    let mut buffer = Buffer::new();
    
    // Format various integer types
    let a: i8 = 127;
    println!("i8: {}", buffer.format(a));
    
    let b: u8 = 255;
    println!("u8: {}", buffer.format(b));
    
    let c: i16 = -32768;
    println!("i16: {}", buffer.format(c));
    
    let d: u16 = 65535;
    println!("u16: {}", buffer.format(d));
    
    let e: i32 = -2147483648;
    println!("i32: {}", buffer.format(e));
    
    let f: u32 = 4294967295;
    println!("u32: {}", buffer.format(f));
    
    let g: i64 = -9223372036854775808;
    println!("i64: {}", buffer.format(g));
    
    let h: u64 = 18446744073709551615;
    println!("u64: {}", buffer.format(h));
    
    let i: i128 = -170141183460469231731687303715884105728;
    println!("i128: {}", buffer.format(i));
    
    let j: u128 = 340282366920938463463374607431768211455;
    println!("u128: {}", buffer.format(j));
    
    let k: isize = -42;
    println!("isize: {}", buffer.format(k));
    
    let l: usize = 42;
    println!("usize: {}", buffer.format(l));
}

Buffer Reuse

use itoa::Buffer;
 
fn main() {
    // Create buffer once, reuse many times
    let mut buffer = Buffer::new();
    
    // Each call to format() reuses the same buffer
    for i in 0..10 {
        let s = buffer.format(i);
        println!("Iteration {}: {}", i, s);
    }
    
    // The buffer is overwritten each time
    // Previous results are no longer valid
    let s1 = buffer.format(100);
    println!("s1: {}", s1); // "100"
    
    let s2 = buffer.format(200);
    println!("s2: {}", s2); // "200"
    // s1 may now be invalid - don't use it after reusing buffer
}

Comparing with Standard Library

use itoa::Buffer;
 
fn main() {
    let value: i32 = 1234567890;
    
    // Standard library approach
    let s1 = value.to_string();
    println!("std::to_string: {}", s1);
    
    // itoa approach
    let mut buffer = Buffer::new();
    let s2 = buffer.format(value);
    println!("itoa::format: {}", s2);
    
    // Both produce the same result
    assert_eq!(s1, s2);
    
    // But itoa avoids heap allocation
    // to_string() allocates a new String each time
    // Buffer::format() returns a &str into stack memory
}

Performance Benchmark

use itoa::Buffer;
use std::time::Instant;
 
fn main() {
    const ITERATIONS: usize = 10_000_000;
    let numbers: Vec<i32> = (0..ITERATIONS as i32).collect();
    
    // Benchmark std::to_string()
    let start = Instant::now();
    let mut total_len = 0;
    for n in &numbers {
        let s = n.to_string();
        total_len += s.len();
    }
    let std_duration = start.elapsed();
    println!("std::to_string(): {:?} (total_len = {})", std_duration, total_len);
    
    // Benchmark itoa::Buffer::format()
    let start = Instant::now();
    let mut buffer = Buffer::new();
    let mut total_len = 0;
    for n in &numbers {
        let s = buffer.format(*n);
        total_len += s.len();
    }
    let itoa_duration = start.elapsed();
    println!("itoa::format(): {:?} (total_len = {})", itoa_duration, total_len);
    
    let speedup = std_duration.as_nanos() as f64 / itoa_duration.as_nanos() as f64;
    println!("Speedup: {:.2}x", speedup);
}

Writing to fmt::Write

use itoa;
use std::fmt::{self, Write};
 
struct NumberList {
    numbers: Vec<i32>,
}
 
impl fmt::Display for NumberList {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        let mut first = true;
        for n in &self.numbers {
            if !first {
                f.write_str(", ")?;
            }
            first = false;
            // Use itoa to write directly without allocation
            itoa::fmt(f, *n)?;
        }
        Ok(())
    }
}
 
fn main() {
    let list = NumberList {
        numbers: vec![1, 2, 3, 4, 5, 100, -42, 999999],
    };
    
    println!("Numbers: {}", list);
    
    // Build string manually
    let mut output = String::new();
    for i in 0..10 {
        if i > 0 {
            output.push_str(", ");
        }
        itoa::fmt(&mut output, i * i).unwrap();
    }
    println!("Squares: {}", output);
}

Writing to io::Write

use itoa::Buffer;
use std::io::{self, Write};
 
fn main() -> io::Result<()> {
    let mut stdout = io::stdout().lock();
    let mut buffer = Buffer::new();
    
    // Write integers directly to stdout
    for i in 0..5 {
        let s = buffer.format(i);
        stdout.write_all(s.as_bytes())?;
        stdout.write_all(b"\n")?;
    }
    
    // Write to a Vec<u8>
    let mut output = Vec::new();
    for i in 100..105 {
        let s = buffer.format(i);
        output.extend_from_slice(s.as_bytes());
        output.push(b' ');
    }
    
    let result = String::from_utf8(output).unwrap();
    println!("Output: {}", result);
    
    Ok(())
}

Building CSV Output

use itoa::Buffer;
 
fn main() {
    let data = vec![
        (1, "Alice", 95),
        (2, "Bob", 87),
        (3, "Charlie", 92),
        (4, "Diana", 88),
    ];
    
    let mut buffer = Buffer::new();
    let mut csv = String::new();
    
    // Header
    csv.push_str("id,name,score\n");
    
    // Data rows - use itoa for integer fields
    for (id, name, score) in &data {
        csv.push_str(buffer.format(*id));
        csv.push(',');
        csv.push_str(name);
        csv.push(',');
        csv.push_str(buffer.format(*score));
        csv.push('\n');
    }
    
    print!("{}", csv);
}

JSON-like Output

use itoa::Buffer;
 
fn main() {
    let numbers: Vec<i32> = (0..10).collect();
    
    let mut buffer = Buffer::new();
    let mut json = String::new();
    
    json.push('[');
    
    for (i, n) in numbers.iter().enumerate() {
        if i > 0 {
            json.push(',');
        }
        json.push_str(buffer.format(*n));
    }
    
    json.push(']');
    
    println!("{}", json);
}

Custom Number Formatting

use itoa::Buffer;
use std::fmt::Write;
 
fn format_with_commas(n: i64) -> String {
    let mut buffer = Buffer::new();
    let num_str = buffer.format(n.abs());
    
    let mut result = String::new();
    
    if n < 0 {
        result.push('-');
    }
    
    // Add commas every 3 digits from right
    let chars: Vec<char> = num_str.chars().collect();
    for (i, c) in chars.iter().enumerate() {
        if i > 0 && (chars.len() - i) % 3 == 0 {
            result.push(',');
        }
        result.push(*c);
    }
    
    result
}
 
fn main() {
    println!("{}", format_with_commas(1234567890));
    println!("{}", format_with_commas(42));
    println!("{}", format_with_commas(-9876543210));
    println!("{}", format_with_commas(1000000));
}

Memory-Efficient Counter Display

use itoa::Buffer;
use std::io::{self, Write};
 
struct ProgressDisplay {
    current: u64,
    total: u64,
    buffer: Buffer,
}
 
impl ProgressDisplay {
    fn new(total: u64) -> Self {
        Self {
            current: 0,
            total,
            buffer: Buffer::new(),
        }
    }
    
    fn update(&mut self, current: u64) {
        self.current = current;
    }
    
    fn display(&self) {
        let current_str = self.buffer.format(self.current);
        let total_str = self.buffer.format(self.total);
        
        // Note: This is just for demonstration - in real code, 
        // we'd need to handle the borrowing issue differently
        eprint!("\rProgress: {}/{} ({:.1}%)", 
            current_str,
            self.total,
            (self.current as f64 / self.total as f64) * 100.0
        );
    }
}
 
fn main() {
    let mut progress = ProgressDisplay::new(100);
    
    for i in 0..=100 {
        progress.update(i);
        progress.display();
        std::thread::sleep(std::time::Duration::from_millis(50));
    }
    println!();
}

Handling Large Numbers

use itoa::Buffer;
 
fn main() {
    let mut buffer = Buffer::new();
    
    // u64 max
    let max_u64: u64 = u64::MAX;
    println!("u64::MAX = {}", buffer.format(max_u64));
    
    // i64 min and max
    let min_i64: i64 = i64::MIN;
    println!("i64::MIN = {}", buffer.format(min_i64));
    
    let max_i64: i64 = i64::MAX;
    println!("i64::MAX = {}", buffer.format(max_i64));
    
    // i128 values
    let big: i128 = 123456789012345678901234567890;
    println!("i128 = {}", buffer.format(big));
    
    // usize for sizes
    let size: usize = 1024 * 1024 * 1024; // 1 GB in bytes
    println!("Size: {} bytes", buffer.format(size));
}

Integrating with Serde

use itoa::Buffer;
use std::fmt;
 
// Custom display wrapper for fast integer formatting
struct FastInt<T>(T);
 
impl<T: itoa::Integer> fmt::Display for FastInt<T> {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        let mut buffer = Buffer::new();
        f.write_str(buffer.format(self.0))
    }
}
 
fn main() {
    let value: i32 = 12345;
    println!("Fast: {}", FastInt(value));
    
    // When writing custom serialization
    let mut buffer = Buffer::new();
    
    // Build JSON array of integers
    let mut json = String::new();
    json.push('[');
    
    for i in -5..=5 {
        if i > -5 {
            json.push(',');
        }
        json.push_str(buffer.format(i));
    }
    
    json.push(']');
    
    println!("JSON: {}", json);
}

Real-World Example: Log Formatter

use itoa::Buffer;
use std::io::{self, Write};
use std::time::{SystemTime, UNIX_EPOCH};
 
struct LogEntry {
    timestamp: u64,
    level: &'static str,
    message: &'static str,
    code: i32,
}
 
impl LogEntry {
    fn format_into(&self, buffer: &mut Buffer, output: &mut Vec<u8>) {
        // Timestamp
        output.extend_from_slice(b"[");
        output.extend_from_slice(buffer.format(self.timestamp).as_bytes());
        output.extend_from_slice(b"] ");
        
        // Level
        output.extend_from_slice(self.level.as_bytes());
        output.extend_from_slice(b": ");
        
        // Message
        output.extend_from_slice(self.message.as_bytes());
        
        // Code
        output.extend_from_slice(b" (code: ");
        output.extend_from_slice(buffer.format(self.code).as_bytes());
        output.extend_from_slice(b")\n");
    }
}
 
fn main() {
    let entries = vec![
        LogEntry { timestamp: 1705312800, level: "INFO", message: "Server started", code: 0 },
        LogEntry { timestamp: 1705312805, level: "WARN", message: "High memory usage", code: 1001 },
        LogEntry { timestamp: 1705312810, level: "ERROR", message: "Connection failed", code: 5003 },
        LogEntry { timestamp: 1705312815, level: "INFO", message: "Reconnected", code: 0 },
    ];
    
    let mut buffer = Buffer::new();
    let mut output = Vec::new();
    
    for entry in &entries {
        entry.format_into(&mut buffer, &mut output);
    }
    
    // Write to stdout
    io::stdout().write_all(&output).unwrap();
}

Real-World Example: Metrics Collector

use itoa::Buffer;
use std::collections::HashMap;
 
struct Metrics {
    counters: HashMap<&'static str, u64>,
    gauges: HashMap<&'static str, i64>,
}
 
impl Metrics {
    fn new() -> Self {
        Self {
            counters: HashMap::new(),
            gauges: HashMap::new(),
        }
    }
    
    fn increment(&mut self, name: &'static str, delta: u64) {
        *self.counters.entry(name).or_insert(0) += delta;
    }
    
    fn set_gauge(&mut self, name: &'static str, value: i64) {
        self.gauges.insert(name, value);
    }
    
    fn format_prometheus(&self) -> String {
        let mut buffer = Buffer::new();
        let mut output = String::new();
        
        for (name, value) in &self.counters {
            output.push_str(name);
            output.push(' ');
            output.push_str(buffer.format(*value));
            output.push('\n');
        }
        
        for (name, value) in &self.gauges {
            output.push_str(name);
            output.push(' ');
            output.push_str(buffer.format(*value));
            output.push('\n');
        }
        
        output
    }
}
 
fn main() {
    let mut metrics = Metrics::new();
    
    metrics.increment("requests_total", 1);
    metrics.increment("requests_total", 1);
    metrics.increment("errors_total", 1);
    metrics.set_gauge("active_connections", 42);
    metrics.set_gauge("memory_bytes", 1024000);
    
    println!("{}", metrics.format_prometheus());
}

Real-World Example: Binary Protocol Encoder

use itoa::Buffer;
use std::io::{self, Write};
 
struct PacketEncoder {
    buffer: Buffer,
    output: Vec<u8>,
}
 
impl PacketEncoder {
    fn new() -> Self {
        Self {
            buffer: Buffer::new(),
            output: Vec::new(),
        }
    }
    
    fn write_header(&mut self, packet_type: u8, length: usize) {
        self.output.push(packet_type);
        // Length-prefixed as ASCII digits with null terminator
        let len_str = self.buffer.format(length);
        self.output.extend_from_slice(len_str.as_bytes());
        self.output.push(0); // null terminator
    }
    
    fn write_int_field(&mut self, value: i64) {
        let s = self.buffer.format(value);
        self.output.extend_from_slice(s.as_bytes());
        self.output.push(b'|'); // field separator
    }
    
    fn write_string_field(&mut self, value: &str) {
        self.output.extend_from_slice(value.as_bytes());
        self.output.push(b'|');
    }
    
    fn finish(self) -> Vec<u8> {
        self.output
    }
}
 
fn main() {
    let mut encoder = PacketEncoder::new();
    
    // Encode a simple packet
    encoder.write_header(1, 3); // Type 1, 3 fields
    encoder.write_int_field(12345);
    encoder.write_int_field(-9876);
    encoder.write_string_field("hello");
    
    let packet = encoder.finish();
    println!("Packet ({} bytes): {:?}", packet.len(), packet);
    println!("As ASCII: {}", String::from_utf8_lossy(&packet));
}

Thread-Safe Usage

use itoa::Buffer;
use std::sync::Mutex;
use std::thread;
 
fn main() {
    // Each thread should have its own buffer
    let handles: Vec<_> = (0..4)
        .map(|thread_id| {
            thread::spawn(move || {
                let mut buffer = Buffer::new();
                let mut results = Vec::new();
                
                for i in 0..1000 {
                    let n = thread_id * 1000 + i;
                    let s = buffer.format(n);
                    results.push(s.to_string()); // Convert to owned if needed
                }
                
                results.len()
            })
        })
        .collect();
    
    let total: usize = handles.into_iter().map(|h| h.join().unwrap()).sum();
    println!("Processed {} numbers", total);
}

Comparison Table

use itoa::Buffer;
 
fn main() {
    let value: i64 = 9876543210;
    
    println!("Comparison for value {}:", value);
    println!();
    
    // Method 1: to_string() - allocates String
    let s1 = value.to_string();
    println!("to_string():        \"{}\" (allocates String)", s1);
    
    // Method 2: format! macro - allocates String
    let s2 = format!("{}", value);
    println!("format!(\"{{}}\"):    \"{}\" (allocates String)", s2);
    
    // Method 3: itoa::Buffer - no allocation
    let mut buffer = Buffer::new();
    let s3 = buffer.format(value);
    println!("itoa::Buffer:       \"{}\" (stack buffer, no allocation)", s3);
    
    // Method 4: write! to String - allocates
    let mut s4 = String::new();
    std::fmt::write(&mut s4, format_args!("{}", value)).unwrap();
    println!("write! to String:   \"{}\" (allocates String)", s4);
    
    // Method 5: itoa::fmt to String - no intermediate allocation
    let mut s5 = String::new();
    itoa::fmt(&mut s5, value).unwrap();
    println!("itoa::fmt:          \"{}\" (writes directly)", s5);
}

Summary

  • itoa provides fast integer-to-string conversion without heap allocation
  • Create a Buffer once and reuse it with format() for multiple conversions
  • format() returns a &str that borrows from the buffer — it's overwritten on next call
  • itoa::fmt() writes directly to any fmt::Write or io::Write implementation
  • Supports all integer types: i8, i16, i32, i64, i128, isize, and unsigned variants
  • 2-10x faster than to_string() for repeated conversions
  • Use in performance-critical paths: serialization, logging, metrics, network protocols
  • Each thread should have its own buffer (Buffer is not thread-safe)
  • No dependencies, minimal API, highly optimized assembly for common platforms