How to Pad a String in Rust

Pad a string in Rust using the format! macro with alignment specifiers like < for left and > for right padding.

When columns drift apart

You are building a CLI tool that prints a table of users. The first column is names, the second is IDs. You print the first row, and it looks fine. You print the second row, and the IDs shift three characters to the right because the name was longer. The table is unreadable. You need to force every name to take up exactly the same amount of horizontal space, filling the gaps with spaces so the columns line up. That is padding.

Rust handles padding through its formatting system. The format! macro uses a syntax that lets you control alignment, width, and fill characters. You specify these rules inside the curly braces of the format string. The compiler expands format! into code that calls the std::fmt traits, calculates the required space, and constructs the result.

Padding is not limited to strings. Any type that implements std::fmt::Display or std::fmt::Debug can be padded. The formatting system treats the output of the type as a sequence of characters and applies the padding rules to that sequence.

The formatting syntax

The format specifier lives inside {}. The general structure is {flags width precision}. Flags control alignment. Width sets the minimum field size. Precision sets the maximum content size. You can combine them to get exact control over the output.

Alignment flags are < for left, > for right, and ^ for center. If you omit the flag, the default depends on the type. Strings and numbers default to right alignment. You can change the fill character by placing it before the alignment flag. The fill character defaults to a space.

let name = "Alice";

// Right-align in a field of width 10.
// Spaces fill the left side.
let right = format!("{:>10}", name);
assert_eq!(right, "     Alice");

// Left-align in a field of width 10.
// Spaces fill the right side.
let left = format!("{:<10}", name);
assert_eq!(left, "Alice     ");

// Center-align in a field of width 10.
// Spaces split evenly on both sides.
let center = format!("{:^10}", name);
assert_eq!(center, "   Alice  ");

The width is a number inside the braces. If the content is longer than the width, the field expands to fit the content. Padding never truncates. Width is a minimum, not a maximum. If you need to truncate, you use precision.

Width versus precision

Width and precision serve opposite purposes. Width guarantees a minimum size. Precision guarantees a maximum size. For strings, precision limits the number of characters in the output. If you set both, precision caps the content first, then width pads the result.

let text = "Hello, World!";

// Width 15, no precision.
// String is 13 chars. Padded to 15.
let only_width = format!("{:>15}", text);
assert_eq!(only_width, "  Hello, World!");

// Precision 5, no width.
// String truncated to 5 chars.
let only_precision = format!("{:.5}", text);
assert_eq!(only_precision, "Hello");

// Width 15, precision 5.
// String truncated to 5 chars, then padded to 15.
let both = format!("{:>15.5}", text);
assert_eq!(both, "           Hello");

Precision on strings is rare in everyday code. Most developers truncate manually using slicing or get. The precision syntax is useful when you want a single format string to handle both padding and truncation without branching logic.

Convention aside: Use precision for truncation only when the limit is fixed and small. If you need dynamic truncation based on runtime data, build the slice first. The format string becomes harder to read when precision and width interact with dynamic values.

Dynamic widths and fills

You can make the width dynamic by using $ and passing the value as an argument. The width argument must be a usize. You can also make the precision dynamic with .*.

let name = "Bob";
let width = 12;

// Width comes from the second argument.
let padded = format!("{:>width$}", name, width = width);
assert_eq!(padded, "         Bob");

// Dynamic precision and width.
// "Hello, World!" truncated to 6, padded to 10.
let result = format!("{:>10.6}", "Hello, World!");
assert_eq!(result, "    Hello,");

The fill character cannot be dynamic via the format string. You cannot write {:fill$>10}. If you need a dynamic fill character, you must construct the format string at runtime or repeat the character manually.

let fill_char = 'x';
let width = 10;
let text = "hi";

// Construct the format string dynamically.
// This allocates the format string, then allocates the result.
let fmt = format!("{}{:>10}", fill_char.to_string().repeat(width - 2), text);
// Better approach: repeat the fill char and concatenate.
let manual = format!("{}{}", fill_char.to_string().repeat(8), text);
assert_eq!(manual, "xxxxxxxxhi");

Constructing format strings dynamically is inefficient. The compiler cannot optimize the format string if it is built at runtime. You lose the compile-time checks that catch typos in format specifiers. Reach for manual repetition or a helper function when the fill character varies.

The Unicode trap

The width in format! counts Unicode scalar values, not bytes, and not visual width. This distinction matters when your text contains emojis, combining characters, or wide characters. A single emoji like ๐Ÿš€ is one scalar value. format! treats it as width 1. In many fonts, the emoji takes up two character cells. Your columns will misalign visually even though the scalar count is correct.

let rocket = "๐Ÿš€";
let text = "Hi๐Ÿš€";

// Width is 3 scalar values.
// "Hi๐Ÿš€" has 3 scalars: 'H', 'i', '๐Ÿš€'.
// No padding added.
let padded = format!("{:>5}", text);
assert_eq!(padded, "  Hi๐Ÿš€");

// Visual width might be 4 or 5 depending on the font.
// The padding looks wrong on screen.

Combining characters are worse. The character รฉ can be represented as a single scalar รฉ or as e followed by a combining acute accent ยด. format! sees two scalars in the second case. The width calculation doubles for the same visual character.

let composed = "รฉ";      // 1 scalar
let decomposed = "e\u{0301}"; // 2 scalars: e + combining accent

let p1 = format!("{:>3}", composed);
let p2 = format!("{:>3}", decomposed);

// p1 has 1 space. p2 has 0 spaces.
// Both look like "รฉ", but padding differs.
assert_eq!(p1, " รฉ");
assert_eq!(p2, "รฉ");

If you need visual column alignment with non-ASCII text, format! is insufficient. You need a crate that understands grapheme clusters and character widths. The unicode-width crate provides functions to measure visual width. The unicode-segmentation crate handles grapheme clusters.

Count characters, not bytes, and never trust visual width in format!. Use a dedicated crate when your data includes emojis or accented text.

Performance and allocation

format! returns a String. It allocates memory on the heap for every call. If you are padding strings in a tight loop, the allocation overhead adds up. The allocator must find space, initialize the buffer, and later free the memory. This pressure can fragment the heap and slow down your program.

Use write! to write into an existing buffer. write! takes a mutable reference to a type that implements std::fmt::Write. String implements this trait. You can reuse the same String across iterations, clearing it or truncating it as needed.

use std::fmt::Write;

fn build_report(names: &[&str], buffer: &mut String) {
    for name in names {
        // Write into the existing buffer.
        // No new allocation per iteration.
        write!(buffer, "{:>10}\n", name).unwrap();
    }
}

let mut output = String::new();
build_report(&["Alice", "Bob", "Charlie"], &mut output);

write! returns a Result. The unwrap() is safe here because writing to a String only fails if the allocator runs out of memory. In production code, handle the error or use expect with a message. The convention is to unwrap in examples and small scripts. In library code, propagate the error.

Allocate once, write many times. Let the buffer do the heavy lifting.

Padding custom types

Padding works on any type that implements Display or Debug. If you define a struct, you can implement Display to control how it formats. The formatter then handles padding automatically. You do not need to implement padding logic yourself.

use std::fmt;

struct User {
    name: String,
    id: u32,
}

impl fmt::Display for User {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        // The formatter applies width and alignment to this output.
        write!(f, "User({}, {})", self.name, self.id)
    }
}

let user = User {
    name: "Bob".into(),
    id: 42,
};

// User implements Display, so padding works.
let padded = format!("{:>25}", user);
assert_eq!(padded, "          User(Bob, 42)");

If you try to pad a type that does not implement Display, the compiler rejects the code. You get E0277 (trait bound not satisfied). The error message tells you which trait is missing. Implement Display or use {:?} with Debug.

Implement Display when you want a human-readable representation. Implement Debug when you want a developer-readable representation. Padding works on both. Choose the trait that matches the audience of the output.

Pitfalls and errors

Width is a minimum. If your string is longer than the width, the output expands. format! does not truncate based on width. If you need truncation, use precision or slice the string.

let long = "This is a very long string";
let result = format!("{:>10}", long);
// Result is the full string, not truncated.
assert_eq!(result.len(), 26);

Precision on strings truncates to the number of characters. If you set precision on a number, it controls decimal places for floats or significant digits. Mixing precision and width on numbers can produce unexpected results. Precision applies to the number formatting first, then width pads the result.

If you pass a width argument that is not a usize, the compiler rejects the code. You get E0308 (mismatched types). The width argument must be an integer type that can be converted to usize.

let width: i32 = 10;
// This fails. width must be usize.
// let result = format!("{:>width$}", "hi", width);
// Error: E0308 mismatched types.

Cast the width to usize before passing it. Or use a usize variable directly.

Treat the format string as a contract. If the flags do not match the type, the compiler catches it. If the types do not match the flags, the compiler catches it. Trust the borrow checker and the type system. They prevent runtime panics from malformed format strings.

Decision matrix

Use format! when you need a new padded string for a single operation. Use write! when you are building a large output in a loop and want to reuse a buffer. Use std::fmt::Display implementation when you want a custom type to support padding natively. Use a crate like unicode-width when you need visual column alignment with emojis or wide characters. Reach for manual string repetition only when you need zero-allocation padding into a pre-sized byte buffer.

Pick the tool that matches your allocation budget and your text complexity.

Where to go next