How to Convert a Number to a String in Rust

Use the `ToString` trait for simple conversions or `format!` for formatted output, as both are idiomatic and efficient in Rust.

When numbers need to speak

You're writing a high-score list. The score is an i32. You try to print "Score: " + score and the compiler throws a fit. Or you're building a JSON response and need that number as text. Converting numbers to strings is the bridge between computation and communication. In Rust, that bridge has a few toll booths depending on how fast you need to go.

The language gives you simple tools for everyday work and specialized tools for when every nanosecond counts. You'll use the simple tools 99% of the time. The specialized tools exist for the 1% where profiling shows string conversion is eating your CPU.

Pick the right bridge, and your data flows smoothly. Pick the wrong one, and you'll pay in allocations or boilerplate.

The standard path: to_string and format

Most of the time, you just need a number as text. Rust provides two idiomatic ways to do this. Both are fast, safe, and readable.

Call .to_string() on a single value. Use format! when you need to combine values or add formatting.

fn main() {
    // to_string() converts a single value to a String.
    // It works on any type that implements Display, including all standard numbers.
    let score = 42;
    let score_text = score.to_string();
    assert_eq!(score_text, "42");

    // format! constructs a String from multiple parts.
    // It uses the same syntax as println! but returns a String instead of printing.
    let message = format!("Current score: {}", score);
    assert_eq!(message, "Current score: 42");

    // format! also handles formatting directives like padding and precision.
    let padded = format!("{:05}", score);
    assert_eq!(padded, "00042");
}

Trust format! for composition. It calculates the size once and writes straight through.

Under the hood: Display and allocation

When you call .to_string(), Rust looks for the ToString trait. Here's the trick: you rarely implement ToString yourself. The standard library provides a blanket implementation: if a type implements Display, it automatically gets ToString.

So to_string() is just a wrapper around format!("{}", value). Under the hood, Rust calculates how many characters the number needs, allocates that much memory on the heap, writes the digits, and returns the String.

This allocation is cheap for occasional use. A String owns its data. When the String goes out of scope, the memory is freed. This ownership model keeps Rust safe. You don't have to worry about the string data vanishing while you're still using it.

The allocation is the price of admission for a String. Pay it only when you need ownership.

Custom types and the Display trait

What if you have a custom struct? You can't call .to_string() unless you implement Display. This is a design choice. Rust forces you to decide how your type looks as text.

Implement Display for the user-facing representation. Leave Debug for the developer's eyes.

use std::fmt;

/// Represents a player in the game.
struct Player {
    name: String,
    score: u32,
}

/// Implement Display to define how Player appears as a string.
/// This automatically gives Player the .to_string() method.
impl fmt::Display for Player {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        // Write the formatted output to the formatter.
        write!(f, "{}: {}", self.name, self.score)
    }
}

fn main() {
    let player = Player {
        name: "Alice".to_string(),
        score: 150,
    };

    // to_string() now works because Display is implemented.
    let text = player.to_string();
    assert_eq!(text, "Alice: 150");
}

If you try to call .to_string() on a type that doesn't implement Display, the compiler rejects this with E0277 (trait bound not satisfied). The error tells you exactly which trait is missing. Implement Display to fix it.

Convention aside: format! shares its syntax with println!. If you know how to print, you know how to format. println! returns (), format! returns String. Use format! when you need the result; use println! when you just want to show it.

Formatting control: precision and padding

Numbers often need specific formatting. You might need a fixed number of decimal places, leading zeros, or alignment. format! handles all of this with specifiers.

Use format specifiers to control the output. Hard-coding padding is a maintenance trap.

fn main() {
    let price = 19.99;
    let id = 7;

    // .2 limits floats to two decimal places.
    let formatted_price = format!("${:.2}", price);
    assert_eq!(formatted_price, "$19.99");

    // 05 pads integers with zeros to width 5.
    let formatted_id = format!("ID: {:05}", id);
    assert_eq!(formatted_id, "ID: 00007");

    // Combine multiple values with different formatting.
    let report = format!("Item {:03} costs ${:.2}", id, price);
    assert_eq!(report, "Item 007 costs $19.99");
}

Floats have a quirk. 3.14 might print as 3.1400000000000001 without care. Rust's default float formatting uses an algorithm that produces the shortest representation that round-trips back to the same float. This avoids unnecessary noise while preserving precision. Use .2 or other precision specifiers when you need human-readable output.

The performance path: itoa and ryu

When you're converting millions of numbers in a tight loop, to_string() and format! become expensive. They allocate a new String every time. The allocator has to find memory, initialize it, and later free it. This adds up.

For high-performance scenarios, use itoa for integers and ryu for floats. These crates write directly into a pre-allocated buffer. No heap allocation per conversion.

Add itoa = "1" and ryu = "1" to your Cargo.toml.

use itoa::Buffer;

fn main() {
    // Buffer is a reusable scratchpad.
    // It's cheap to create and can be reused across many conversions.
    let mut buf = Buffer::new();

    // format writes the number into the buffer and returns a &str.
    // No allocation happens here.
    let s = buf.format(12345);
    assert_eq!(s, "12345");

    // Reuse the buffer for the next number.
    let s2 = buf.format(67890);
    assert_eq!(s2, "67890");
}

ryu works the same way for floats. It provides a Buffer type that you reuse. The performance gain is significant in hot loops. Benchmarks show itoa can be 5x to 10x faster than to_string() when converting integers in bulk.

Treat the buffer as a scratchpad. If you write over it, the old note is gone.

Pitfalls: buffer reuse and trait bounds

The buffer approach introduces a new class of bugs. itoa::Buffer::format returns a &str that borrows from the buffer. If you reuse the buffer, the previous &str becomes invalid.

This is a classic pitfall. The compiler won't always catch it because &str doesn't track the buffer's lifetime in a way that prevents reuse. You have to manage the lifetimes manually.

use itoa::Buffer;

fn main() {
    let mut buf = Buffer::new();

    // format returns a &str pointing into buf.
    let s1 = buf.format(123);

    // Calling format again overwrites the buffer.
    // s1 is now invalid. It points to stale data.
    let s2 = buf.format(456);

    // s1 points to "456" now, not "123".
    // This assertion passes, proving s1 was invalidated.
    assert_eq!(s1, "456");
}

To avoid this, only hold the &str as long as you need it. Copy the data into a String or Vec<u8> if you need to keep it. Or structure your code so you process each number immediately after formatting.

Profile before optimizing. itoa adds complexity that rarely pays off outside hot loops.

Decision matrix

Use to_string() when you need a quick conversion of a single value and readability matters. Use format! when you're combining multiple values, adding text, or applying formatting like padding and precision. Use itoa when profiling shows integer-to-string conversion is a bottleneck in a tight loop and you can manage a reusable buffer. Use ryu when you have the same performance pressure with floating-point numbers. Reach for format! over manual concatenation; it's faster and safer because it calculates the total size upfront.

Readability wins by default. Performance wins when the profiler points the finger.

Where to go next