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.
copied to clipboard