Mit Go 1.24 hat das strings-Paket eine ganze Familie an Iterator-Funktionen bekommen — darunter SplitSeq. Die Funktion macht exakt das, was Split seit jeher tut: einen String an einem Trennzeichen zerlegen. Der Unterschied steckt im Rückgabetyp. Statt eines vollständig materialisierten []string-Slices liefert SplitSeq einen iter.Seq[string], also einen Range-over-Func-Iterator. Die Substrings werden nicht im Voraus berechnet und in eine Slice gepackt, sondern bei Bedarf während der range-Schleife produziert.
Dieses Lazy-Verhalten ist mehr als eine Mikro-Optimierung. Es eliminiert die Heap-Allokation für die Ergebnis-Slice, erlaubt sauberes early-break bei der ersten passenden Übereinstimmung und macht Pipelines lesbarer, weil das Zerlegen direkt in den Konsum-Loop fließt. SplitSeq ist damit der idiomatische Ersatz für Split, wenn man den Slice ohnehin nur durchläuft.
Die Signatur von SplitSeq ist bewusst nah am bekannten Split gehalten. Beide nehmen denselben Eingabe-String und denselben Separator entgegen — nur der Rückgabewert unterscheidet sich. Wo Split ein []string zurückgibt, liefert SplitSeq einen iter.Seq[string], also eine Funktion, die mit range über die Substrings iteriert.
package main
import (
"fmt"
"strings"
)
func main() {
for part := range strings.SplitSeq("a,b,c,d", ",") {
fmt.Println(part)
}
}a
b
c
dDer Typ iter.Seq[string] ist ein Alias für func(yield func(string) bool). Praktisch bedeutet das: der Compiler verdrahtet die range-Schleife so, dass jeder produzierte Substring sofort im Schleifenrumpf landet — ohne Zwischenspeicher.
Range-over-Func ist das Sprachfeature aus Go 1.23, das SplitSeq überhaupt erst sinnvoll macht. Eine iter.Seq[T] ist nichts anderes als eine Generator-Funktion: sie ruft intern eine yield-Callback auf, der Compiler übersetzt das range darüber in die passende Callback-Logik. Für die Aufrufseite fühlt es sich an wie ein normaler for-range-Loop.
package main
import (
"fmt"
"strings"
)
func main() {
// SplitSeq liefert NUR den Wert (keine Indizes wie Slice-Range).
i := 0
for part := range strings.SplitSeq("alpha::beta::gamma", "::") {
fmt.Printf("%d -> %q\n", i, part)
i++
}
}0 -> "alpha"
1 -> "beta"
2 -> "gamma"Wichtig: SplitSeq ist eine iter.Seq[string], keine iter.Seq2[int, string]. Ein eigener Zähler bleibt also notwendig, falls die Position gebraucht wird — bewusst minimalistisch gehalten, weil die meisten Aufrufer den Index gar nicht brauchen.
Der zentrale Unterschied liegt in der Allokation. Split baut intern eine []string-Slice auf, zählt vorab die Treffer, allokiert das Backing-Array und füllt es. Bei großen Eingaben oder hoher Aufrufrate ist das messbar. SplitSeq umgeht die komplette Slice-Allokation: es gibt kein Array, keine Längenberechnung, nur sequenzielle Aufrufe der yield-Funktion.
package main
import (
"fmt"
"strings"
)
func main() {
csv := "id,name,email,role,created_at"
parts := strings.Split(csv, ",")
fmt.Printf("Split -> %d Felder, type=%T\n", len(parts), parts)
seq := strings.SplitSeq(csv, ",")
fmt.Printf("SplitSeq -> type=%T\n", seq)
count := 0
for range seq {
count++
}
fmt.Printf("SplitSeq -> %d Felder durchlaufen\n", count)
}Split -> 5 Felder, type=[]string
SplitSeq -> type=iter.Seq[string]
SplitSeq -> 5 Felder durchlaufenWer trotzdem eine Slice braucht, kann mit slices.Collect(seq) jederzeit eine erzeugen — dann ist man semantisch wieder bei Split, aber explizit. Die Default-Empfehlung in Go 1.24+ lautet: SplitSeq nehmen, es sei denn die Slice wird wirklich gebraucht (Sortieren, Indexzugriff, mehrfaches Iterieren).
Die schönste Eigenschaft von SplitSeq zeigt sich, wenn man die Iteration vorzeitig abbrechen will. Bei Split muss der gesamte String zerlegt werden, bevor das erste Element überhaupt sichtbar ist — die Slice ist immer vollständig. Bei SplitSeq produziert der Iterator nur so viele Substrings, wie tatsächlich konsumiert werden. Ein break stoppt den Generator nach dem aktuellen Element.
package main
import (
"fmt"
"strings"
)
func main() {
huge := strings.Repeat("token,", 1_000_000) + "stop"
// Wir wollen nur das erste Feld. Mit Split würde der gesamte String
// zerlegt und eine Slice mit 1.000.001 Elementen allokiert.
for first := range strings.SplitSeq(huge, ",") {
fmt.Printf("erstes Feld: %q\n", first)
break // Iterator stoppt SOFORT — kein weiterer Scan, keine Slice
}
}erstes Feld: "token"Genau dieser Pattern — „nur die ersten paar Treffer brauchen" — war mit Split historisch ein Allokations-Albtraum. SplitN(s, sep, 2) war der klassische Workaround, aber unhandlich. SplitSeq plus break ist deutlich klarer und produziert keine Throwaway-Slice.
Das Verhalten bei leerem Separator ist identisch zu Split: wenn sep == "", iteriert SplitSeq Rune für Rune über den Eingabe-String. Jeder yield-Aufruf liefert genau eine UTF-8-Rune als String. Das ist konsistent mit dem dokumentierten Edge-Case von Split und damit erwartbar.
package main
import (
"fmt"
"strings"
)
func main() {
// Mehrbyte-Runen werden korrekt erkannt — kein Byte-für-Byte-Verhalten
for r := range strings.SplitSeq("Go!", "") {
fmt.Printf("%q (%d Byte)\n", r, len(r))
}
fmt.Println("---")
for r := range strings.SplitSeq("äöü", "") {
fmt.Printf("%q (%d Byte)\n", r, len(r))
}
}"G" (1 Byte)
"o" (1 Byte)
"!" (1 Byte)
---
"ä" (2 Byte)
"ö" (2 Byte)
"ü" (2 Byte)Für reine Rune-Iteration ist die direkte for _, r := range s-Schleife allerdings idiomatischer und liefert rune statt string. SplitSeq(s, "") ist eher der Spezialfall, wenn man den gleichen Code-Pfad für „normalen" und „leeren" Separator braucht.
Auffällig: es gibt zwar SplitSeq und SplitAfterSeq, aber kein SplitNSeq. Das ist Absicht. Die Limit-Semantik von SplitN(s, sep, n) lässt sich beim Iterator trivial mit einem Zähler und break nachbauen — eine eigene Funktion wäre redundant und würde das API-Surface unnötig aufblähen.
Wer allerdings die echte SplitN-Semantik mit „Rest verschmolzen am Ende" braucht — also SplitN("a,b,c,d", ",", 3) ergibt ["a", "b", "c,d"] —, sollte bei SplitN bleiben. SplitSeq ist auf gleichmäßige Iteration ausgelegt: der „nimm die ersten N und ignoriere den Rest"-Fall ist trivial, das „verschmilz alles ab Index N"-Verhalten dagegen umständlich nachzubilden.
In Microbenchmarks zeigt sich das erwartbare Bild: SplitSeq allokiert pro Aufruf null Bytes auf dem Heap (modulo Compiler-Devirtualisierung), während Split mindestens einmal die Slice-Backing-Allokation produziert. Bei kleinen Strings ist der Unterschied gering, bei großen Eingaben mit vielen Trennern wird er deutlich — und im Hot Path eines Servers, der Millionen Zeilen parst, ist das spürbar.
Die exakten Zahlen variieren je nach Go-Version und CPU, aber die Größenordnung ist robust: SplitSeq ist allokationsfrei, Split zahlt die Slice-Kosten. Für GC-empfindliche Pfade — Log-Parser, Streaming-Importer, hochfrequente Request-Verarbeitung — ist das ein klarer Gewinn.
Ein klassischer Anwendungsfall: aus jeder Zeile einer großen CSV-Datei wird nur das erste Feld (zum Beispiel die ID) gebraucht, etwa um vorhandene Datensätze in einer Datenbank zu prüfen. Mit Split würde jede Zeile vollständig zerlegt — komplette Verschwendung. SplitSeq liefert das erste Feld, ein return stoppt sofort.
package main
import (
"bufio"
"fmt"
"strings"
)
// firstField liest das erste Komma-getrennte Feld einer Zeile.
func firstField(line string) string {
for f := range strings.SplitSeq(line, ",") {
return f // erstes yield, dann return — Iterator stoppt automatisch
}
return ""
}
func main() {
data := `1001,Anna Berger,anna@example.de,admin,2024-03-12
1002,Ben Cordes,ben@example.de,user,2024-04-01
1003,Carla Decker,carla@example.de,user,2024-05-22`
scanner := bufio.NewScanner(strings.NewReader(data))
for scanner.Scan() {
id := firstField(scanner.Text())
fmt.Printf("ID: %s\n", id)
}
}ID: 1001
ID: 1002
ID: 1003Der Funktionsrumpf bleibt extrem kurz, weil ein return aus dem range-Block automatisch den Iterator beendet. Kein manueller Index, keine Throwaway-Slice — und semantisch glasklar: „Gib mir das erste Feld, mehr interessiert mich nicht."
Ein zweites realistisches Szenario: in einer URL oder einem Dateipfad wird nach einem bestimmten Segment gesucht. Sobald es gefunden ist, kann der Loop abbrechen. SplitSeq macht die Suche linear in der gefundenen Position — nicht in der Gesamtlänge des Pfads.
package main
import (
"fmt"
"strings"
)
// hasSegment prüft, ob ein Pfad das gegebene Segment enthält.
func hasSegment(path, want string) bool {
for seg := range strings.SplitSeq(path, "/") {
if seg == want {
return true
}
}
return false
}
// indexOfSegment liefert den Segment-Index oder -1.
func indexOfSegment(path, want string) int {
i := 0
for seg := range strings.SplitSeq(path, "/") {
if seg == want {
return i
}
i++
}
return -1
}
func main() {
p := "/api/v2/users/42/sessions/active"
fmt.Printf("enthält 'users'? -> %v\n", hasSegment(p, "users"))
fmt.Printf("Index von 'users' -> %d\n", indexOfSegment(p, "users"))
fmt.Printf("Index von 'tokens' -> %d\n", indexOfSegment(p, "tokens"))
}enthält 'users'? -> true
Index von 'users' -> 3
Index von 'tokens' -> -1Anmerkung zum Pfad: da der Eingabe-Pfad mit / beginnt, liefert das erste Segment einen Leerstring — analog zu strings.Split. Wer das vermeiden will, ruft strings.TrimPrefix(p, "/") vor dem Iterieren auf. Die Logik selbst bleibt unverändert: Iterator durchlaufen, beim Treffer zurückkehren, beim Fehlschlag -1.
Signatur
func SplitSeq(s, sep string) iter.Seq[string] — Rückgabetyp ist ein Range-over-Func-Iterator, kein Slice.
Allokation null
SplitSeq allokiert keine []string-Slice — Substrings werden lazy yieldet.
Early break ist gratis
Ein break im range-Loop stoppt den Iterator sofort; restliche Substrings werden nie berechnet.
Leeres sep
SplitSeq(s, "") iteriert Rune für Rune — identisch zu Split(s, ""), jeder yield ist ein UTF-8-String.
Kein SplitNSeq
Es gibt keine Limit-Variante; „nimm die ersten N" per Zähler plus break selbst nachbauen.
Slice wiederherstellen
slices.Collect(strings.SplitSeq(s, sep)) liefert exakt das Ergebnis von strings.Split(s, sep).
Go 1.24+
Verfügbar ab Go 1.24 — auf älteren Versionen schlägt der Build fehl, kein Polyfill in der stdlib.
Default-Empfehlung
Wenn nur iteriert wird, ist SplitSeq der idiomatische Default; Split nur, wenn die Slice wirklich gebraucht wird.