We can't find the internet
Attempting to reconnect
Something went wrong!
Hang in there while we get back on track
Build strings with iodata, not concatenation
almirsarajcic
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.
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/1gives 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, thenEnum.reverse/1once) 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.
Comments (2)
Sign in with GitHub to join the discussion
copied to clipboard
Santiago Papiernik
1 month ago
It is possible to call IO.iodata_to_binary(rows) directly, since rows is already a valid iodata
Almir Sarajčić
1 month ago Edited
Good catch!
In this exact scenario, that’s probably the best option.