Skip to content

← Collections, Text and First Iterators step 3 of 21

Easy Primitives

String building: push_str, write!, and the 590x cliff

Render one line of CSV, with RFC 4180 quoting.

pub fn render_csv_line(fields: &[String]) -> String

Join the fields with commas. A field must be quoted if it contains a comma, a double quote, a carriage return or a newline; inside quotes, every " is written twice. Every other field is copied through untouched.

["a", "b", "c"]        ->  a,b,c
["a,b", "c"]           ->  "a,b",c
[he said "hi"]         ->  "he said ""hi"""
["", ""]               ->  ,
[]                     ->  (empty string)

There is also a hidden case that builds 5000 fields and asserts that your function performs at most 20 heap allocations while producing the line. That budget is not tight — it comfortably allows a single output String growing by doubling, which is 13 allocations for this input. What it does not allow is one allocation per field.

First: the error in the starter

The starter reaches for write!, which is the right instinct. It does not compile:

error[E0599]: cannot write into `String`
help: the method is available for `String` here:
      use std::fmt::Write;

This is a genuinely confusing message the first time, and it is worth understanding rather than pattern-matching. write!(dst, ...) expands to dst.write_fmt(format_args!(...)). There are two traits in the standard library with a write_fmt method:

  • std::io::Write — for byte sinks (files, sockets, Vec<u8>, stdout). Its methods return io::Result, because writing to a socket can fail.
  • std::fmt::Write — for text sinks (String, fmt::Formatter). Its methods return fmt::Result, because formatting into memory can only fail if the sink says so.

A trait’s methods only exist on a type when the trait is in scope. So the fix is use std::fmt::Write; at the top of your function or module — and once you understand that, the whole family of “no method named X found” errors on a type you know has X becomes a one-second diagnosis: the trait is not imported.

(std::io::Write is the wrong one here and would give you a different error, because String is not a byte sink. That pair of near-identical traits with the same method names is the single most common import mix-up in Rust.)

Second: the belief you probably imported

Most people arrive in Rust having been told, correctly, that in Python or Java s = s + t in a loop is quadratic, because strings are immutable and every + copies the whole accumulated result. So they arrive suspicious of + and confident about “use a builder”.

In Rust that belief is wrong, and the reason is a signature. String‘s Add impl is:

impl Add<&str> for String {
    fn add(mut self, other: &str) -> String { self.push_str(other); self }
}

It takes self by value. There is no copy of the accumulated buffer — it moves the existing String in, appends into its existing capacity, and moves it back out. s = s + &t is push_str with different syntax.

Measured over 20 000 parts:

s = s + p                        0.047 ms
s.push_str(p)                    0.048 ms
with_capacity + push_str         0.047 ms
parts.concat()                   0.034 ms
s = format!("{s}{p}")           28.100 ms      <-- 590x

There is the real cliff, and it is hiding behind syntax that looks equivalent to the line above it. format! allocates a brand new String every iteration and copies everything accumulated so far into it. That is the quadratic pattern people came looking for, and it is the one they write when they are trying to avoid +.

On this problem’s 5000-field input the same shape costs 17 909 allocations against a budget of 20. Note what did not happen: clippy -D warnings passes the quadratic version happily. No lint saves you. The only thing that catches it is the measurement.

An honest footnote: clippy’s string_add lint still flags s = s + p on style grounds, even though the benchmark exonerates it on performance. It is not in the default set, so it will not fail you here — but if you meet it in a codebase that enables it, now you know the difference between “this is slow” and “we prefer the other spelling”.

::: question format!("{x}") versus x.to_string() for a single u32 — same thing? Same result, different cost. Measured over 200 000 conversions: format!("{x}") takes 3.80 ms, x.to_string() 2.12 ms — about 1.8× slower. format! builds a fmt::Arguments, walks a format-string parse tree and dispatches through Display; to_string on an integer goes through a specialised path.

clippy has useless_format for the extreme case of format!("{}", s) where s is already a string. For the integer case there is no lint — it is a judgement call, and it only matters in a hot loop. :::

Which builder to reach for

  • Appending in a loop: push_str / push. Clear, and as fast as anything.
  • A slice of strings joined by nothing: parts.concat(). Joined by a separator: parts.join(", "). Both pre-compute the total length and allocate exactly once — that is why concat wins the benchmark above.
  • Interpolating values into a growing buffer: write!(&mut buf, "...") with use std::fmt::Write. Never buf += &format!(...).
  • Building the string fresh, once, from a template: format!. It is only bad in the accumulator position.

Remember the grade is compile + tests + clippy -D warnings.