R
RUSTY YELLOW PAGES

write! / writeln!Macro


Explanation

write!/writeln! use the exact same format-string grammar as format!, but instead of allocating and returning a fresh String, they write the formatted text into a destination the caller already has — the first argument to either macro is that destination, followed by the format string and its arguments: write!(destination, "...", args...). writeln! additionally appends a trailing newline, the same relationship println! has to print!.

The destination can implement either of two different, similarly-named but distinct Write traits, and which one it is changes what "writing" actually means: std::fmt::Write (also available as core::fmt::Write, needing no allocator) is implemented by String, and it's what a custom Display impl's fmt method writes into via its &mut Formatter argument; std::io::Write is implemented by files, TCP sockets, and locked standard output/error, and represents writing bytes somewhere an actual I/O operation might fail.

That difference shows up directly in the return type: both macros return a Result, but for an fmt::Write target the error case (fmt::Error) is essentially unreachable in ordinary code — the operation only fails if the underlying Write impl itself reports failure, which String's never does — so it's common, and generally fine, to .unwrap() it. For an io::Write target, though, the Result represents a real I/O failure (a full disk, a broken pipe, a closed socket), and production code should propagate it with ? rather than unwrap, exactly the way any other fallible I/O call is handled.

Usage examples

Writing formatted text into a String

use std::fmt::Write as _;

fn build_summary(item_count: u32, total: f64) -> String {
    let mut summary = String::new();
    write!(summary, "{item_count} items, ${total:.2} total").unwrap(); // <- fmt::Write for String never actually fails
    summary
}

let summary = build_summary(3, 47.5);

Implementing Display by writing into a Formatter

A custom Display impl for a duration-like type formats its value piece by piece using write!, avoiding an intermediate allocation just to combine the pieces.

use std::fmt;

struct Elapsed { seconds: u64 }

impl fmt::Display for Elapsed {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        let minutes = self.seconds / 60;
        let remaining = self.seconds % 60;
        write!(f, "{minutes}m {remaining:02}s") // <- writes straight into the Formatter, no intermediate String
    }
}

let elapsed = Elapsed { seconds: 125 };
println!("{elapsed}"); // 2m 05s

The std fmt docs show writing directly into the &mut Formatter that Display::fmt is handed as the idiomatic implementation shape; building an intermediate String with format! just to immediately write it out again is an unnecessary allocation on every call.

Appending log lines to a file

Appending log lines to an open file propagates the io::Write Result with ? rather than unwrapping it, since the destination is a real OS resource whose writes can genuinely fail.

use std::fs::File;
use std::io::{self, Write};

fn log_reading(file: &mut File, sensor_id: u32, value: f64) -> io::Result<()> {
    writeln!(file, "sensor {sensor_id}: {value:.2}")?; // <- io::Write target: a real I/O error must be propagated, not unwrapped
    Ok(())
}

An io::Write destination can genuinely fail (disk full, broken pipe), so production code propagates the Result with ? the same way any other fallible I/O call is handled — the Book's error-handling chapter treats unwrapping a fallible I/O result as acceptable only in throwaway code, unlike the fmt::Write-into-a-String case above where .unwrap() is standard practice.

Explanation

Embedded support: Partial

write!/writeln! split cleanly along the same two-trait line the classic Explanation lays out, and that split is exactly what decides embedded support. A core::fmt::Write destination — heapless::String, a hand-rolled fixed [u8; N] buffer wrapper, or a UART/USART peripheral that implements the trait directly — needs no allocator at all, because core::fmt::Write (unlike std::io::Write) is defined in core, with no OS or heap assumption baked in. That makes writing into one of these the standard, idiomatic replacement for println!/format! in #![no_std] code generally: format! needs alloc plus a configured #[global_allocator] just to produce its returned String, while write!ing the same formatted text into a heapless::String or straight out over UART needs neither — the formatting machinery itself (the {}/{:?}/{:.2} grammar) is identical in both cases, only the destination differs. A std::io::Write destination (a file, a socket) has no support under #![no_std] at all, for the same reason println! doesn't: std::io itself assumes a hosted OS. The two destinations look like the same macro call at the source level; which trait the destination type implements is what decides which of these applies.

Usage examples

Writing formatted text into a heapless::String (no heap)

use core::fmt::Write as _;
use heapless::String;

fn build_status_line(errors: u8, uptime_s: u32) -> String<64> { // <- fixed 64-byte capacity, no allocator
    let mut line: String<64> = String::new();
    write!(line, "errors={errors} uptime={uptime_s}s").unwrap(); // <- core::fmt::Write target: fails only if capacity is exceeded
    line
}

Writing formatted text directly to a UART peripheral

A UART/USART HAL type implementing core::fmt::Write lets write! send formatted diagnostic text straight over the wire, with no intermediate String — and no heap — involved at all.

use core::fmt::Write as _;

struct Uart; // stand-in for a HAL's concrete UART type

impl core::fmt::Write for Uart {
    fn write_str(&mut self, s: &str) -> core::fmt::Result {
        for byte in s.bytes() {
            // ... block until the transmit register is free, then write `byte`
        }
        Ok(())
    }
}

fn report_reading(uart: &mut Uart, sensor_id: u32, value: f32) {
    writeln!(uart, "sensor {sensor_id}: {value:.2}").unwrap(); // <- formatted text goes straight out over the wire, no String built at all
}