skip to content

When does replacing strings.Split with strings.SplitSeq actually reduce allocations?

level: seniorimportance: nice to knowfreq 30%

answer

  1. what does Split itself allocate?
  2. the substrings were never copies
  3. only the slice header array is saved
  4. iter.Seq yields; it does not collect
  5. read allocs/op before and after

basics

~20 s

Only the result slice is saved: strings.Split allocates one []string per call, while the substrings are already views into the input. So SplitSeq helps when that per-call allocation sits on a hot path, and not otherwise.

solid answer

~50 s

`strings.Split` makes exactly one heap allocation per call: the `[]string` holding the results. The substrings themselves are never copies — a Go string is immutable, so each element is a pointer and length into the original string's bytes. `strings.SplitSeq(s, sep)` returns an `iter.Seq[string]` you range over, yielding the same substrings without ever building that slice, so the saving is precisely one allocation per call and nothing else. That is worth having in a parser that splits every line of a high-volume stream, where one allocation per line dominates; it is worth nothing in code that splits a config file once at startup. It also costs you random access, `len`, sorting and re-iteration, so if you need the fields as a collection, keep `Split`. Decide it with a benchmark run under `-benchmem` and read `allocs/op`, not by preference.

code

go · 9 lines
go
// One []string allocated per line.
for _, field := range strings.Split(line, ",") {
	handle(field)
}

// No slice built: SplitSeq yields the same substrings one at a time.
for field := range strings.SplitSeq(line, ",") {
	handle(field)
}

go deeper

for a junior

Know that both forms give you the same pieces, and that the iterator version is used with range but cannot be indexed or measured with len. Prefer the slice form until you have a reason not to.

for a middle

Explain the cost model: one slice allocation per call, substrings that are views rather than copies, and an iterator that removes exactly that one allocation and nothing else.

for a senior

Show that you scope the change to hot paths and prove it with a benchmark under -benchmem, reading allocs/op. Mention the retention trap, where a small retained field pins the whole input string.

for a principal

The judgment is where micro-optimisation earns its readability cost. A per-item parse in a high-volume path is worth the iterator; a package-wide sweep with no measurement is churn that later readers must decode.

## What the slice-returning forms actually allocate A Go string is an immutable pointer-and-length pair. Because the bytes cannot change, a substring can safely share them, and `strings.Split` exploits that: every element of its result points into the *original* string's bytes. No character data is copied. What it does allocate is the backing array of the `[]string` itself. `Split` counts the separators first and makes one slice of exactly the right size, so the cost is one heap allocation per call, sized `count+1` string headers (16 bytes each on a 64-bit platform). The slice is returned, so it escapes and cannot stay on the stack. That is the entire budget, and knowing it stops two mistakes. The first is imagining that splitting a large line copies the line — it does not. The second is imagining the saving is bigger than it is: switching to an iterator removes one allocation per call, not a proportional cost. ## The iterator forms ```go func SplitSeq(s, sep string) iter.Seq[string] func FieldsSeq(s string) iter.Seq[string] func Lines(s string) iter.Seq[string] ``` Each returns a function you consume with `range`, yielding one substring at a time: ```go for field := range strings.SplitSeq(line, ",") { handle(field) } ``` The yielded values are the same substrings `Split` would have produced, still views into the input. No `[]string` is built, so the loop makes no allocation at all — the iterator closure itself is typically stack-allocated when it does not escape the calling function, which it does not in the pattern above. `SplitAfterSeq`, `FieldsFuncSeq` and the whole set of `bytes` equivalents exist too, so a `[]byte` pipeline gets the same treatment without converting to `string`. A `break` out of the loop simply stops the iteration; the iterator holds no resources to release. ## When the swap is worth making Ask two questions. **Is the call on a hot path?** One allocation is a handful of nanoseconds plus the garbage it eventually creates. In a broker client parsing thousands of frames a second, with several header lines each, that is a measurable share of the allocation rate, and allocation rate is what drives GC frequency. In a startup path, it is invisible, and the change is churn. **Do you actually consume the fields one at a time?** The iterator gives you exactly that and nothing more. If the next line of code is `len(parts)`, `parts[1]`, a sort, or a second pass, you need the slice — collecting the iterator back into one yourself just moves the allocation and adds a line. In particular, validating "this line must have exactly two fields" is a length check, and that argues for `strings.Cut` or `strings.SplitN` instead of either. ## Measuring rather than asserting The honest answer in an interview is that you measured it. A benchmark of the parse function, run with `-benchmem`, prints `allocs/op` and `B/op`; the swap should move `allocs/op` down by exactly one per split call, and `ns/op` by however much your allocator and GC were charging for that. If `allocs/op` does not move, the allocation you were chasing was somewhere else — commonly a `string([]byte)` conversion at the boundary, or a `map[string]string` being built from the parsed fields, both of which dwarf the split. That is also the review answer. A pull request that swaps `Split` for `SplitSeq` across a package without a benchmark is a change of style, not of performance, and it makes the code harder to read wherever the fields were genuinely wanted as a collection. ## The retention trap, which applies to both forms Because every substring points into the original string's bytes, keeping one field alive keeps the *whole* input alive. Parsing a 64 KB frame and storing a 12-byte header value in a long-lived map retains all 64 KB. This is not a reason to avoid `Split` or `SplitSeq` — it is a reason to copy explicitly (`strings.Clone`) when a small extracted value outlives the buffer it came from. Iterators do not change this either way. With `[]byte` there is an additional edge: the sub-slices returned by `bytes.Split` and yielded by `bytes.SplitSeq` alias a *mutable* array, so writing through one is visible through the original and through any overlapping neighbour. That is a correctness concern, not a performance one, and it is why byte parsers usually treat the yielded slices as read-only. ## Summary The iterator forms save the result slice and nothing else. Reach for them where a split runs per item on a hot path and the fields are consumed in order; keep the slice-returning forms where you need a collection; and let a `-benchmem` benchmark, not taste, decide which of those a given call site is.

  • Why does storing one small field from a large parsed line keep the whole line alive?
    Because the substrings produced by splitting are views into the original string's bytes, not copies. A 12-byte value extracted from a 64 KB frame still points into that 64 KB array, which therefore cannot be collected. Copy it with `strings.Clone` when the small value outlives the buffer; that trades one deliberate allocation for releasing the rest.
  • What do you lose by ranging over an iterator instead of holding the slice?
    Random access, `len`, sorting, and cheap re-iteration. A rule like "this header line must have exactly two fields" is a length check, so an iterator forces you to count manually. Where the shape of the result is part of the validation, `strings.Cut` or `strings.SplitN` with an explicit length check is clearer than either form.
  • Your benchmark shows allocs/op barely moved after the swap. What now?
    Revert it and look elsewhere — the split was not the cost. The usual culprits in a parser are a `string([]byte)` conversion at the input boundary, building a map or struct from the parsed fields, and `fmt` calls on the error path. A CPU or heap profile of the benchmark points at whichever it is far faster than guessing.

saying these in an interview costs you the question

  • Claims strings.Split copies the substring bytes
  • Expects an order-of-magnitude win from removing one slice allocation
  • Swaps Split for SplitSeq across a package without a benchmark
  • Collects the iterator into a slice and calls it allocation-free
  • Ignores that bytes sub-slices alias a mutable array