Skip to content

Printing bytes

Level: 101 → 201 · working knowledge

One line: A byte slice has no {}, and {:?} shows it as a list of numbers — escape_ascii() shows what Python shows for b'…': each ASCII byte as its letter, every other byte as \xNN, and the result is valid source for the same bytes.

let data = "Real 🐍".as_bytes();
println!("{:?}", data);                   // [82, 101, 97, 108, 32, 240, 159, 144, 141]
println!("{:02x?}", data);                // [52, 65, 61, 6c, 20, f0, 9f, 90, 8d]
println!("b\"{}\"", data.escape_ascii()); // b"Real \xf0\x9f\x90\x8d"

The first two lines are the bytes as numbers, in decimal and in hex. The third is the one to reach for while debugging: the part of the data that is ASCII reads as text, every other byte is spelled as a byte, and nothing is decoded, dropped or replaced. <[u8]>::escape_ascii has been stable since Rust 1.60; it returns an iterator that also implements Display, so it goes straight into println! without building a String first.

Verified output of printing_bytes.rs — regenerated by tools/run_examples.py, never hand-typed.

=== one byte string, five questions ===
  which numbers?     {:?}             [82, 101, 97, 108, 32, 240, 159, 144, 141]
  ...in hex?         {:02x?}          [52, 65, 61, 6c, 20, f0, 9f, 90, 8d]
  what does it say?  escape_ascii()   Real \xf0\x9f\x90\x8d
  one hex string?    {:02x} per byte  5265616c20f09f908d
  is it text?        str::from_utf8   Ok("Real 🐍")

=== escape_ascii: an ASCII byte stays a letter, the rest are escaped ===
  b"hello"             -> hello
  [0u8; 5]             -> \x00\x00\x00\x00\x00
  b"tab\there"         -> tab\there
  b"CR LF\r\n"         -> CR LF\r\n
  b"back\\slash"       -> back\\slash
  b"it's \"q\""        -> it\'s \"q\"
  [0x7f, 0x80, 0xff]   -> \x7f\x80\xff

=== the output is ASCII, and longer than the input ===
  9 bytes in, 21 characters out, all of them ASCII: true
  printable ASCII  0x41  ->  A     1 character(s)
  named escape     0x0a  ->  \n    2 character(s)
  a quote          0x22  ->  \"    2 character(s)
  anything else    0xf0  ->  \xf0  4 character(s)

=== the output is source: paste it into b"..." and the bytes come back ===
  b"Real \xf0\x9f\x90\x8d"
  that literal, compiled into this program, equals the data: true

=== the hand-rolled version escapes the letters too ===
  every byte as \xNN      b"\x52\x65\x61\x6c\x20\xf0\x9f\x90\x8d"
  escape_ascii           b"Real \xf0\x9f\x90\x8d"
  "Real " + the loop     b"Real \x52\x65\x61\x6c\x20\xf0\x9f\x90\x8d"
  ...which says Real twice: once as letters, once as \x52\x65\x61\x6c\x20

=== when the bytes are not UTF-8, only one view keeps all of them ===
  str::from_utf8     Err("incomplete utf-8 byte sequence from index 3")
  from_utf8_lossy    "caf�"
  escape_ascii       caf\xe9
  the lossy view replaced 0xe9 with U+FFFD; escape_ascii still says e9

Which view answers which question

You want Write What it loses
the numbers {:?} nothing — you just read decimal
the numbers in hex {:02x?} nothing
what it says, as far as it is ASCII escape_ascii() nothing
one hex string {:02x} per byte, padded nothing, as long as it is padded
the text, if it is text str::from_utf8 nothing — it hands back an error instead, at the first bad byte
the text, whatever happens String::from_utf8_lossy each bad sequence, replaced by U+FFFD without a word

The trap: escaping every byte

The loop most people write first is correct and unreadable:

let data = "Real 🐍".as_bytes();
let every: String = data.iter().map(|b| format!("\\x{b:02x}")).collect();
println!("b\"{every}\"");                 // b"\x52\x65\x61\x6c\x20\xf0\x9f\x90\x8d"
println!("b\"{}\"", data.escape_ascii()); // b"Real \xf0\x9f\x90\x8d"

Both lines are valid literals for the same nine bytes, and only the second shows you the word. The run above also prints the version that tries to fix that by printing "Real " in front of the loop — which is what a hand-rolled "print it like Python" helper tends to become. It says Real twice, once as letters and once as \x52\x65\x61\x6c\x20, and every escape in it is a correct one, so nothing complains until somebody reads it.

Why {} is refused

A byte slice has no Display, on purpose. There is no one right way to show bytes to a person, so std makes you choose:

rustc 1.98.0 on display_bytes.rs — a three-line main that prints a &[u8] with {}
error[E0277]: `[u8]` doesn't implement `std::fmt::Display`
 --> display_bytes.rs:3:20
  |
3 |     println!("{}", data);
  |               --   ^^^^ `[u8]` cannot be formatted with the default formatter
  |               |
  |               required by this formatting parameter
  |
  = help: the trait `std::fmt::Display` is not implemented for `[u8]`
  = note: in format strings you may be able to use `{:?}` (or {:#?} for pretty-print) instead
  = note: required for `&[u8]` to implement `std::fmt::Display`

error: aborting due to 1 previous error

For more information about this error, try `rustc --explain E0277`.

The note suggests {:?}, the numbers view — which is why so many programs print a byte slice as a list of decimals: it is the answer the compiler offers.

If you are coming from another language

Python. escape_ascii() is the inside of what repr() prints for a bytes value — and so of what the >>> prompt shows, because the prompt calls repr() for you (repr is not str has that half). Compared over all 256 single bytes, the two agree on 254:

Measured 2026-09-10 — rustc 1.98.0 and Python 3.14.7, put side by side; not verbatim program output
  byte    Rust  [b].escape_ascii()    Python  repr(bytes([b]))[2:-1]
  0x22    \"                          "
  0x27    \'                          '
  the other 254 are identical: ASCII as itself, \t \n \r \\ by name, the rest as lowercase \xNN

The two that differ are the quotes, and both languages chose that on purpose. Python picks the delimiter per value — b"it's" is printed in double quotes so that its ' needs no escape — while Rust escapes both quotes every time, so its output is correct between either kind: pasted into Python's b'…' or b"…", every one of the 256 comes back as the byte it came from. The other views map one to one: list(b) is {:?}, b.hex() is the hex string — Rust has no .hex(), and Why hexadecimal writes one — b.decode() is str::from_utf8, and b.decode(errors='replace') is from_utf8_lossy. Python also has the way back, ast.literal_eval(repr(b)); std does not, which is the kata below.

ABAP. An XSTRING has one built-in face, and it is the hex string: WRITE and the debugger show its bytes as two hex digits each, the {:02x} row of the table. Reading the text inside it means converting through a code page, with a conversion class that either replaces a byte it cannot map or raises, depending on how you create it — the from_utf8_lossy / from_utf8 pair again. escape_ascii is the view in between: lossless like the hex, readable like the text, and in ABAP a loop you would write yourself.

Practice

Undo escape_ascii. std prints a byte string this way and has nothing that reads one back. Write unescape_ascii(&str) -> Result<Vec<u8>, _>, then prove it exact rather than asserting it: all 256 single bytes and all 65,536 two-byte strings must round-trip, and their escaped forms must all be different. Decide what it does with the inputs escape_ascii never writes — a trailing \, an escape it does not use such as \q, \x without two hex digits, and any non-ASCII character. Last, check that it also reads the inside of what Python prints for b"it's", which leaves the ' unescaped.

Solution

printing_bytes_kata.rs in full — pasted here by tools/run_examples.py from the file CI compiles and runs.

//! Kata solution: undo `escape_ascii`.
//!
//! std prints a byte string with `escape_ascii()` and has nothing that reads one
//! back. The reverse is short; proving it exact is the exercise — every one of
//! the 65,536 two-byte strings has to survive the round trip.
//!
//!   rustc --edition 2024 printing_bytes_kata.rs -o /tmp/pbk && /tmp/pbk

use std::collections::BTreeSet;

#[derive(Debug, PartialEq)]
enum UnescapeError {
    /// `escape_ascii` only ever writes ASCII.
    NotAscii(usize),
    /// A `\` with nothing after it.
    LoneBackslash(usize),
    /// `\x` without two hex digits after it.
    ShortHex(usize),
    /// A backslash escape `escape_ascii` never writes, such as `\q`.
    UnknownEscape(usize, char),
}

fn unescape_ascii(text: &str) -> Result<Vec<u8>, UnescapeError> {
    let src = text.as_bytes();
    if let Some(at) = src.iter().position(|b| !b.is_ascii()) {
        return Err(UnescapeError::NotAscii(at));
    }
    let hex = |at: usize| src.get(at).and_then(|&d| (d as char).to_digit(16));
    let mut out = Vec::with_capacity(src.len());
    let mut i = 0;
    while i < src.len() {
        if src[i] != b'\\' {
            out.push(src[i]);
            i += 1;
            continue;
        }
        match src.get(i + 1) {
            None => return Err(UnescapeError::LoneBackslash(i)),
            Some(b'n') => out.push(b'\n'),
            Some(b'r') => out.push(b'\r'),
            Some(b't') => out.push(b'\t'),
            Some(&q @ (b'\\' | b'\'' | b'"')) => out.push(q),
            Some(b'x') => match (hex(i + 2), hex(i + 3)) {
                (Some(hi), Some(lo)) => {
                    out.push((hi * 16 + lo) as u8);
                    i += 2; // the two digits, on top of the two below
                }
                _ => return Err(UnescapeError::ShortHex(i)),
            },
            Some(&other) => return Err(UnescapeError::UnknownEscape(i, other as char)),
        }
        i += 2;
    }
    Ok(out)
}

fn main() {
    println!("=== the lesson's string, there and back ===");
    let data = "Real 🐍".as_bytes();
    let shown = data.escape_ascii().to_string();
    println!("  escaped   {shown}");
    println!("  back      {:?}", unescape_ascii(&shown));
    println!("  equal     {}", unescape_ascii(&shown) == Ok(data.to_vec()));

    println!("\n=== exact, proved rather than asserted ===");
    let singles = (0..=255u8)
        .filter(|&b| unescape_ascii(&[b].escape_ascii().to_string()) == Ok(vec![b]))
        .count();
    println!("  single bytes that round-trip    : {singles} of 256");
    let mut pairs = 0;
    let mut forms = BTreeSet::new();
    for x in 0..=255u8 {
        for y in 0..=255u8 {
            let s = [x, y].escape_ascii().to_string();
            if unescape_ascii(&s) == Ok(vec![x, y]) {
                pairs += 1;
            }
            forms.insert(s);
        }
    }
    println!("  two-byte strings that round-trip: {pairs} of 65536");
    println!("  distinct escaped forms          : {}   <- one per input, so nothing collides", forms.len());

    println!("\n=== the inputs escape_ascii never writes ===");
    for input in [r"abc\", r"\q", r"\x4", r"\xzz", "café"] {
        println!("  {input:<8} -> {:?}", unescape_ascii(input));
    }

    println!("\n=== and it reads what Python prints, too ===");
    // Python shows b"it's" in double quotes, so that the ' needs no escape.
    println!("  it's      -> {:?}", unescape_ascii("it's").map(|v| String::from_utf8(v).unwrap()));
}

Verified output of printing_bytes_kata.rs — regenerated by tools/run_examples.py, never hand-typed.

=== the lesson's string, there and back ===
  escaped   Real \xf0\x9f\x90\x8d
  back      Ok([82, 101, 97, 108, 32, 240, 159, 144, 141])
  equal     true

=== exact, proved rather than asserted ===
  single bytes that round-trip    : 256 of 256
  two-byte strings that round-trip: 65536 of 65536
  distinct escaped forms          : 65536   <- one per input, so nothing collides

=== the inputs escape_ascii never writes ===
  abc\     -> Err(LoneBackslash(3))
  \q       -> Err(UnknownEscape(0, 'q'))
  \x4      -> Err(ShortHex(0))
  \xzz     -> Err(ShortHex(0))
  café     -> Err(NotAscii(3))

=== and it reads what Python prints, too ===
  it's      -> Ok("it's")

See also

Po polsku

Wycinek bajtów (&[u8]) nie implementuje Display i to nie jest przeoczenie: bajty można pokazać człowiekowi na kilka sposobów i żaden nie jest oczywiście właściwy, więc kompilator każe wybrać. Podpowiada {:?} — i dlatego tyle programów wypisuje bajty jako listę liczb dziesiętnych. Do debugowania zwykle lepszy jest escape_ascii(): bajty ASCII zostają literami, a każdy inny bajt zamienia się w \xNN, dokładnie tak, jak Python pokazuje obiekt bytes. Polski tekst wygląda w tym widoku jak ciąg kodów — "żółw".as_bytes().escape_ascii() to \xc5\xbc\xc3\xb3\xc5\x82w — i właśnie to jest w nim uczciwe: widać bajty UTF-8, a nie litery, których w bajtach nie ma, dopóki ich nie zdekodujesz (str::from_utf8, a gdy dane mogą być uszkodzone — String::from_utf8_lossy, który po cichu wstawi U+FFFD).

Pułapka, na którą warto uważać, to ręcznie napisana pętla format!("\\x{b:02x}") po każdym bajcie: wynik jest poprawny, ale sekwencją ucieczki zostaje każdy bajt, także zwykła litera, więc słowo „Real” ginie w ciągu \x52\x65\x61\x6c. escape_ascii zwraca iterator, który implementuje Display, więc trafia prosto do println! bez budowania String, a jego wynik wklejony między b" i " jest poprawnym literałem bajtowym tych samych bajtów. Drogi powrotnej biblioteka standardowa nie ma — unescape_ascii trzeba napisać samemu i to jest ćwiczenie na tej stronie.

Szukaj po polsku: wypisywanie bajtów · sekwencje ucieczki · łańcuch bajtów · rust escape_ascii · rust print bytes like python · rust Vec<u8> Display