# Build strings with iodata, not concatenation

Building a string by repeatedly `<>`-ing onto an accumulator can be quadratic — but not for the reason most people think. The BEAM has a runtime optimization that lets `acc <> new` grow a binary in place *when it can prove `acc` is only referenced once, at the front*. Break that shape — accumulate in the other order, thread the binary through a struct field, anything that makes the compiler unsure who else holds a reference — and you fall off the fast path into a full copy on every append. iodata never depends on that guarantee, so it's fast unconditionally.

```elixir
rows = for i <- 1..50_000, do: "row-#{i},value-#{i * 2}\n"

# ❌ concatenation that can't append in place — full copy every iteration
Enum.reduce(rows, "", fn r, acc -> r <> acc end)
# => 860_966 us  (≈861 ms) for 50_000 rows

# ✅ iodata — nested list, flattened once at the end
Enum.reduce(rows, [], fn r, acc -> [acc, r] end) |> IO.iodata_to_binary()
# => 1_957 us  (≈2 ms) for 50_000 rows
```

## Why

Binaries are immutable. `acc <> new` is only cheap when the runtime can extend the *same* underlying buffer in place — it does this by over-allocating spare capacity and writing into it, provided `acc` is dead after this call and no other code holds a reference to it. That's a fragile, implementation-specific invariant: swap the argument order to `new <> acc`, stash the accumulator inside a map or struct field between iterations, or read it back mid-loop, and the runtime can no longer prove uniqueness — every append then copies the whole accumulator, which is O(n) per step and O(n²) overall.

An iolist sidesteps the question entirely. `[acc, r]` just conses a new list cell holding references to the old list and the new chunk — that's O(1) regardless of order, size, or how many other places hold a reference to `acc`. You pay one linear pass to flatten it into a binary at the end, with `IO.iodata_to_binary/1`.

Even better: in most real code you don't need to flatten at all. `IO.write/2`, `File.write/2`, `:gen_tcp.send/2`, and Plug/Phoenix response bodies all accept iodata directly and write the fragments as-is — the flatten is pure overhead you can skip entirely. This is also why Phoenix templates compile to nested iolists instead of concatenated strings: the final list only gets flattened (or streamed as-is) once, by the layer that actually needs bytes on the wire.

A few practical points:

- `IO.iodata_length/1` gives you the total byte size without flattening, if you just need a length.
- iodata can nest arbitrarily — lists of lists of binaries — and can mix binaries with plain integers (each treated as one byte), so you can build a chunk with `[header, byte, body]` without any manual coercion.
- Prepending to an iolist (`[r | acc]`, an actual list, then `Enum.reverse/1` once) is also O(1) per step for the same reason — cons is always cheap, unlike binary concatenation, whose cheap path only exists in one direction.

The measurements above (10,000 and 50,000 rows, each built from `"row-#{i},value-#{i * 2}\n"`) were run three times on Elixir 1.20.3 / OTP 29 and were consistently in the same range: at 10,000 rows, the order-sensitive concatenation took ~18ms against iodata's ~0.4ms; at 50,000 rows that widened to ~0.8–1.2s against ~2ms. For contrast, when the accumulator *does* stay in the fast-path shape (`Enum.reduce(rows, "", fn r, acc -> acc <> r end)`), it measured close to iodata — a few hundred microseconds at 10,000 rows and under 2ms at 50,000 — which is exactly why this bites people: the code that looks identical in shape can be either fine or quadratic depending on details the optimization relies on. iodata removes the guesswork.

[IO.iodata_to_binary/1 docs](https://hexdocs.pm/elixir/IO.html#iodata_to_binary/1)


---

Created by: almirsarajcic
Date: September 01, 2026
URL: https://elixirdrops.net/d/2XDNkzsc
