Skip to content

Why println! is a macro

Level: 201 · working knowledge

One line: A function gets its format string only when the program runs, so it cannot see your variables or check its arguments against the string; println! is a macro so that the compiler reads the string first, and a missing argument, an extra one, an unknown {name} or a type with no Display stops the build, with an error that points inside the string.

Two calls, and what they became

demo/println_app/src/main.rs
//! Two calls to `println!`, and nothing else, so that the expansion fits on
//! the page.

fn main() {
    let name = "Ada";
    let items = 3;
    println!("{name} has {items} items in the cart"); // Ada has 3 items in the cart
    println!("{} bought {:?}", name, ["tea", "milk"]); // Ada bought ["tea", "milk"]
}

Verified output of cargo run -q -p println_app — declared in cargo_runs.toml and regenerated by tools/run_cargo_demos.py, never hand-typed.

Ada has 3 items in the cart
Ada bought ["tea", "milk"]

cargo expand shows what the macros became. It is a separate install; the key below was recorded with the command it runs underneath, cargo rustc -- -Zunpretty=expanded:

Verified output of cargo rustc -q -p println_app --profile=check -- -Zunpretty=expanded — declared in cargo_runs.toml and regenerated by tools/run_cargo_demos.py, never hand-typed.

#![feature(prelude_import)]
//! Two calls to `println!`, and nothing else, so that the expansion fits on
//! the page.
extern crate std;
#[prelude_import]
use std::prelude::rust_2024::*;

fn main() {
    let name = "Ada";
    let items = 3; // Ada has 3 items in the cart
    // Ada bought ["tea", "milk"]
    {
        ::std::io::_print(format_args!("{0} has {1} items in the cart\n",
                name, items));
    };
    {
        ::std::io::_print(format_args!("{0} bought {1:?}\n", name,
                ["tea", "milk"]));
    };
}

Three things happened before any type checking:

  • println! is gone. Each call became a block calling ::std::io::_print with one argument. That function takes a single fmt::Arguments ↗; building that one value, checked, is the macro's whole job. println! itself is a macro_rules! macro over the built-in format_args_nl!, as Three kinds of procedural macro shows from std's source.
  • The \n was appended to the string. That is the _nl in format_args_nl!.
  • {name} became {0}, and name became an argument. The capture was turned into an ordinary positional argument while the string was read, so from here on the compiler checks name as a variable like any other. What format_args! becomes after this, with the borrows made explicit, is on Printing the HIR.

The trailing comments moved to the wrong lines: the printer keeps comments but not their positions (the same trap on the HIR).

The same thing as a function

Here is the best a function can do: take the template as a &str, and every argument as a trait object, since a slice holds one type.

demo/as_a_function/src/main.rs
//! `format!` written as an ordinary function. It compiles, and each mistake the
//! macro would refuse is found only when the line runs, if at all.

use std::fmt::Display;

/// Replaces each `{}` in `template` with the next argument.
///
/// The template is a `&str` like any other, so it is read while the program
/// runs. The arguments have to share one type, so every one is a `&dyn Display`.
fn format_function(template: &str, args: &[&dyn Display]) -> Result<String, String> {
    let holes = template.matches("{}").count();
    if holes != args.len() {
        return Err(format!("expected {holes} arguments, got {}", args.len()));
    }
    let mut out = String::new();
    let mut rest = template;
    for arg in args {
        let (before, after) = rest.split_once("{}").expect("counted above");
        out.push_str(before);
        out.push_str(&arg.to_string());
        rest = after;
    }
    out.push_str(rest);
    Ok(out)
}

fn main() {
    let name = "Ada";
    let items = 3;
    let right = format_function("{} has {} items in the cart", &[&name, &items]);
    let one_short = format_function("{} has {} items in the cart", &[&name]);
    let by_name = format_function("{name} has {items} items in the cart", &[]);
    println!("{right:?}");
    println!("{one_short:?}");
    println!("{by_name:?}");
}

Verified output of cargo run -q -p as_a_function — declared in cargo_runs.toml and regenerated by tools/run_cargo_demos.py, never hand-typed.

Ok("Ada has 3 items in the cart")
Err("expected 2 arguments, got 1")
Ok("{name} has {items} items in the cart")

It compiles, all three calls. What that costs:

  • A mistake is a run-time value. one_short is an Err that someone has to handle, on every call, forever, for a mistake that was visible in the source. The macro version of it does not build (below).
  • {name} cannot work. A function receives a string, not the caller's scope; there is nothing it could look name up in. So by_name is not even an error: it comes back Ok, braces and all.
  • One trait for every argument. &[&dyn Display] has no room for the second call in println_app, where one hole is {} and the other {:?}: those need Display from one argument and Debug from the other.
  • The template is read on every call. format_args! splits the string once, while compiling; format_function searches it with matches and split_once each time it runs.

What the compiler refuses

Each of these is a small binary of its own in the println_refusals package, and each error quotes the line. The first two come from format_args! itself, while it expands:

Verified output of cargo build -q -p println_refusals --bin missing_argument, which fails on purpose — declared in cargo_runs.toml and regenerated by tools/run_cargo_demos.py, never hand-typed.

error: 2 positional arguments in format string, but there is 1 argument
 --> println_refusals/src/bin/missing_argument.rs:3:15
  |
3 |     println!("{} has {} items in the cart", name);
  |               ^^     ^^                     ----

error: could not compile `println_refusals` (bin "missing_argument") due to 1 previous error

Verified output of cargo build -q -p println_refusals --bin extra_argument, which fails on purpose — declared in cargo_runs.toml and regenerated by tools/run_cargo_demos.py, never hand-typed.

error: argument never used
 --> println_refusals/src/bin/extra_argument.rs:3:48
  |
3 |     println!("{} has items in the cart", name, items);
  |              --------------------------        ^^^^^ argument never used
  |              |
  |              formatting specifier missing

error: could not compile `println_refusals` (bin "extra_argument") due to 1 previous error

The next two are not format_args! errors at all. The macro accepted the string and turned {nmae} and {cart} into arguments, as the expansion above did with {name}; name resolution then fails to find nmae, and the type checker finds no Display for Cart. Both still underline text inside the string — nmae for the first, all of {cart} for the second — because the arguments the macro created carry spans that point there:

Verified output of cargo build -q -p println_refusals --bin unknown_name, which fails on purpose — declared in cargo_runs.toml and regenerated by tools/run_cargo_demos.py, never hand-typed.

error[E0425]: cannot find value `nmae` in this scope
 --> println_refusals/src/bin/unknown_name.rs:3:16
  |
3 |     println!("{nmae} has {items} items in the cart");
  |                ^^^^
  |
help: a local variable with a similar name exists
  |
3 -     println!("{nmae} has {items} items in the cart");
3 +     println!("{name} has {items} items in the cart");
  |

For more information about this error, try `rustc --explain E0425`.
error: could not compile `println_refusals` (bin "unknown_name") due to 1 previous error

Verified output of cargo build -q -p println_refusals --bin not_display, which fails on purpose — declared in cargo_runs.toml and regenerated by tools/run_cargo_demos.py, never hand-typed.

error[E0277]: `Cart` doesn't implement `std::fmt::Display`
 --> println_refusals/src/bin/not_display.rs:7:15
  |
7 |     println!("{cart} has {} items", cart.items);
  |               ^^^^^^ `Cart` cannot be formatted with the default formatter
  |
help: the trait `std::fmt::Display` is not implemented for `Cart`
 --> println_refusals/src/bin/not_display.rs:1:1
  |
1 | struct Cart {
  | ^^^^^^^^^^^
  = note: in format strings you may be able to use `{:?}` (or {:#?} for pretty-print) instead

For more information about this error, try `rustc --explain E0277`.
error: could not compile `println_refusals` (bin "not_display") due to 1 previous error

The mistakes that stop the macro from reading the string — {n + 1}, {self.name}, a format string held in a variable — are on The braces take a name, from the user's side.

Checking a literal is not reserved to the compiler

A function-like procedural macro receives its input as tokens too, a string literal among them, so it can read the string while compiling and refuse it with compile_error! or a syn::Error. A routes! macro does exactly that with "/users/{id}", checking each {param} against the handler the way format_args! checks {name} against the arguments.

One thing it cannot copy on stable Rust: the underline inside the string. Pointing at part of a literal needs Literal::subspan ↗, which in 1.98.0's proc_macro is still #[unstable(feature = "proc_macro_span", issue = "54725")], and proc-macro2's wrapper documents that it "will always return None" off nightly. A procedural macro's error underlines the whole literal; format_args!, being part of the compiler, underlines {nmae}.

If you are coming from another language

  • C. printf is a variadic function: the format string is read at run time, and nothing in the language ties %d to an argument. Compilers bolt a check on anyway — Apple clang 21 warns "more '%' conversions than data arguments" for printf("%s has %d items in the cart\n", name), by default — but it is a warning, the program builds, and one run of it here printed Ada has -1084071784 items in the cart. Reading a missing vararg is undefined behaviour, so there is no promise even of that. In Rust the same line does not compile.
  • C++. C++20's std::format is the closest match: its basic_format_string constructor is consteval, so with a literal the string is parsed at compile time and std::format("{} has {} items", "Ada") fails to compile (checked with Apple clang 21 and -std=c++20). The error arrives as "call to consteval function … is not a constant expression" with a note from inside libc++, rather than a sentence about arguments. There is no capture by name.
  • Python. An f-string looks like {name} capture and is parsed when the module is compiled, but names are looked up when the line runs: f"{nmae} has items" inside a function nobody calls raises nothing, and calling it raises NameError. "{} has {} items".format(name) fails at run time with IndexError: Replacement index 1 out of range for positional args tuple (Python 3.14). Rust's {name} is resolved while compiling, like any other use of name.
  • Java. String.format("%s has %d items", "Ada") compiles and throws java.util.MissingFormatArgumentException: Format specifier '%d' when it runs (OpenJDK 25). javac does not look inside the string.
  • ABAP. (Not machine-checked — CI cannot run ABAP.) A string template |{ name } has { items } items| embeds the expressions themselves, so there is no count to get wrong and a misspelt name is a syntax error, like Rust's capture. MESSAGE with &1…&4 placeholders is the printf side: nothing checks that the WITH values match the text of the message.

See also

  • The braces take a name — the format string from the user's side: what may go between the braces, and why it must be a literal
  • What dbg! does — another macro_rules! macro that needs to be one: it prints the source text of its argument
  • Macros — what the ! means at a call site
  • Printing the HIR — what format_args! turns into after expansion
  • Three kinds of procedural macro — built-in macros such as format_args!, and the procedural ones a crate can add
  • A routes! macro — a procedural macro that checks {param} in a string literal while compiling
  • std::fmt ↗ — the format string syntax in full

Po polsku

println! nie może być zwykłą funkcją, bo funkcja dostaje łańcuch formatujący (format string) dopiero w czasie działania programu. Nie zna wtedy zmiennych wywołującego, więc {name} nie ma gdzie wyszukać, a liczba i typy argumentów muszą być ustalone w sygnaturze — najlepsze, co się da zrobić, to wycinek &[&dyn Display], jedna cecha dla wszystkich. Pomyłka zostaje wartością Err sprawdzaną przy każdym wywołaniu. Makro działa wcześniej: println! to macro_rules! nad wbudowanym format_args_nl!, a rozwinięcie pokazuje, że {name} zamienia się w zwykły argument pozycyjny, a na końcu łańcucha pojawia się \n.

Błędy przychodzą z dwóch miejsc. Zła liczba argumentów to komunikat samego format_args!, jeszcze podczas rozwijania makra. Nieznana nazwa (E0425) i brak Display (E0277) to już zwykłe błędy rozwiązywania nazw i sprawdzania typów, ale podkreślenie i tak wskazuje fragment wewnątrz literału. Własne makro proceduralne też może sprawdzić literał w czasie kompilacji, jednak na stabilnym Ruście podkreśli cały literał, bo Literal::subspan jest wciąż niestabilne.

Szukaj po polsku: dlaczego println! jest makrem · format_args! w Ruście · rust format string compile time check · rust println macro expansion · rust variadic functions