Mit Go 1.24 hält in strings eine neue Familie an Iterator-Funktionen Einzug, die sich konsequent an das Range-over-Func-Protokoll aus Go 1.23 anlehnt. strings.Lines ist das Herzstück dieser Familie: die Funktion liefert einen iter.Seq[string], der den Eingabestring zeilenweise durchläuft — ohne vorher ein Slice aller Zeilen aufzubauen und ohne den darunterliegenden Speicher zu kopieren. Wer früher strings.Split(s, "\n") geschrieben hat, bekommt mit Lines eine semantisch genauere, deutlich speicherschonendere Alternative.
Eine Besonderheit, die man beim Umstieg sofort verinnerlichen sollte: jede vom Iterator gelieferte Zeile enthält den abschließenden Zeilenumbruch — also "\n" oder bei CRLF-Eingaben "\r\n". Das mag auf den ersten Blick irritieren, ist aber bewusst gewählt: es macht das Verhalten verlustfrei (Konkatenation der Zeilen rekonstruiert das Original) und erlaubt es, anhand des Suffixes zwischen vollständigen und unvollständigen letzten Zeilen zu unterscheiden.
Die Funktion ist in Go 1.24 in das Paket strings aufgenommen worden und folgt der neuen Iterator-Konvention. Sie ist Teil einer ganzen Gruppe — Lines, SplitSeq, SplitAfterSeq, FieldsSeq, FieldsFuncSeq — die alle einen iter.Seq[string] zurückgeben. Anders als die klassischen Split-Varianten allokieren diese Funktionen kein []string als Zwischenergebnis, sondern liefern Zeilen on-demand.
Der Iterator endet, sobald das Ende des Strings erreicht ist. Ein leerer Eingabestring produziert keine Iteration — der for range-Body wird also nicht ausgeführt. Damit unterscheidet sich Lines deutlich von strings.Split(s, "\n"), das für einen leeren String immer noch ein Slice mit einem leeren Element liefert.
package main
import (
"fmt"
"strings"
)
func main() {
s := "alpha\nbeta\ngamma\n"
for line := range strings.Lines(s) {
fmt.Printf("%q\n", line)
}
}"alpha\n"
"beta\n"
"gamma\n"Die Signatur ist denkbar schlicht: func Lines(s string) iter.Seq[string]. Ein einziger Parameter, ein einziger Rückgabetyp — und doch verbirgt sich dahinter ein Verhalten, das man genau verstehen muss, um spätere Überraschungen zu vermeiden.
Range-over-Func ist seit Go 1.23 stabil. Ein iter.Seq[T] ist nichts anderes als ein func(yield func(T) bool) — also eine Funktion, die einen Callback erhält, mit dem sie Werte ausspuckt. Aus Anwendungssicht relevant ist aber nur die for range-Syntax, die der Compiler in den passenden Yield-Aufruf übersetzt.
Das Schöne daran: der Iterator lebt nur so lange, wie die for-Schleife läuft. Wer mittendrin break oder return verwendet, beendet auch den Iterator sofort — es wird keine weitere Zeile mehr extrahiert. Das ist ein fundamentaler Unterschied zu strings.Split, das immer den gesamten String zerlegt, bevor man auch nur die erste Zeile betrachten kann.
package main
import (
"fmt"
"strings"
)
func main() {
input := "Zeile A\nZeile B\nZeile C\n"
// Index manuell mitführen — der Iterator selbst liefert keinen
i := 0
for line := range strings.Lines(input) {
fmt.Printf("[%d] %s", i, line)
i++
}
}[0] Zeile A
[1] Zeile B
[2] Zeile CBeachte: strings.Lines liefert einen iter.Seq[string] mit nur einem Wert pro Iteration — anders als z. B. maps.All, das ein iter.Seq2 produziert. Wer einen Zeilenindex braucht, führt ihn wie oben gezeigt manuell mit.
Der wichtigste Verhaltensaspekt: jede Zeile enthält ihren Terminator. Bei Unix-Newlines ist das ein "\n" am Ende, bei Windows-CRLF ein "\r\n". Die Funktion erkennt CRLF nicht aktiv als „eine Einheit", sondern verlässt sich darauf, dass \n der eigentliche Trenner ist — das \r davor bleibt einfach Teil der Zeile.
Praktisch heißt das: wer plattformneutral arbeiten möchte, sollte den Terminator gezielt mit strings.TrimRight(line, "\r\n") entfernen. Trimmen über strings.TrimSpace funktioniert in vielen Fällen ebenfalls, beseitigt aber auch führende und folgende Leerzeichen, was nicht immer gewünscht ist.
package main
import (
"fmt"
"strings"
)
func main() {
mixed := "unix\nwindows\r\nohne-newline-am-ende"
for line := range strings.Lines(mixed) {
trimmed := strings.TrimRight(line, "\r\n")
fmt.Printf("roh=%q bereinigt=%q\n", line, trimmed)
}
}roh="unix\n" bereinigt="unix"
roh="windows\r\n" bereinigt="windows"
roh="ohne-newline-am-ende" bereinigt="ohne-newline-am-ende"Die letzte Zeile zeigt einen wichtigen Edge Case: endet die Eingabe nicht mit \n, liefert Lines die letzte Zeile ohne Terminator. Das ist ein verlustfreies Signal — anhand des Suffixes weiß der Aufrufer, ob die Quelle sauber abgeschlossen war. Genau diese Information geht bei strings.Split(s, "\n") verloren.
Der vielleicht stärkste Vorteil gegenüber Split: Lines ist lazy. Solange die for-Schleife nicht weiter iteriert, wird auch keine weitere Zeile aus dem String herausgelöst. Wer also nur die ersten drei Zeilen einer megabyte-großen Eingabe braucht, zahlt auch nur drei Iterationen an Overhead.
Mit break oder einem return aus der enthaltenden Funktion lässt sich der Iterator jederzeit verlassen. Der Compiler übersetzt das in einen yield-Rückgabewert false, der Iterator selbst beendet seine innere Schleife sauber. Es gibt keine Ressourcen, die man manuell freigeben müsste — der String bleibt unverändert, der Iterator ist ein flüchtiges Konstrukt auf dem Stack.
package main
import (
"fmt"
"strings"
)
func main() {
log := "INFO startup\nWARN slow query\nERROR timeout\nINFO ok\nINFO done\n"
// Nur bis zum ersten ERROR lesen
for line := range strings.Lines(log) {
fmt.Print(line)
if strings.HasPrefix(line, "ERROR") {
break
}
}
}INFO startup
WARN slow query
ERROR timeoutBei strings.Split hätte der gesamte String bereits in ein Slice zerlegt werden müssen, bevor die Schleife auch nur die erste Zeile sieht. Bei Lines wird das "INFO ok\n" und "INFO done\n" schlicht nie extrahiert.
strings.Split(s, "\n") ist der klassische Weg, doch er bringt mehrere Nachteile mit: er allokiert immer ein []string, er entfernt den Terminator (Information geht verloren), und er kann nicht zwischen „Datei endet mit Newline" und „Datei endet ohne Newline" unterscheiden — in beiden Fällen enthält das Ergebnis-Slice am Ende einen leeren bzw. einen unvollständigen String.
strings.Lines hingegen ist verlustfrei und allokationsfrei. Die einzelnen Zeilen sind Substrings der Eingabe — sie teilen sich den darunterliegenden Speicher, es findet kein Kopieren statt. Bei großen Eingaben ist der Unterschied dramatisch.
package main
import (
"fmt"
"strings"
)
func main() {
s := "a\nb\nc\n"
parts := strings.Split(s, "\n")
fmt.Printf("Split-Ergebnis (%d Elemente): %q\n", len(parts), parts)
fmt.Println("Lines-Ergebnis:")
for line := range strings.Lines(s) {
fmt.Printf(" %q\n", line)
}
}Split-Ergebnis (4 Elemente): ["a" "b" "c" ""]
Lines-Ergebnis:
"a\n"
"b\n"
"c\n"Man sieht den Unterschied unmittelbar: Split produziert vier Elemente, weil der Trailing-Newline einen leeren letzten String hinterlässt. Lines liefert drei Zeilen — jede mit ihrem Terminator. Die Lines-Variante ist näher an dem, was man intuitiv als „Zeile" versteht.
bufio.Scanner mit ScanLines ist das Pendant für io.Reader-Quellen — also Dateien, Netzwerk-Streams, Pipes. Wer Zeilen aus einem *os.File lesen will, greift weiterhin zum Scanner. strings.Lines ist ausschließlich für bereits im Speicher vorhandene Strings gedacht.
Ein weiterer Unterschied: bufio.Scanner.Text() entfernt den Zeilenumbruch automatisch, strings.Lines behält ihn. Wer von einem zum anderen migriert, muss die Verarbeitung entsprechend anpassen.
package main
import (
"bufio"
"fmt"
"strings"
)
func main() {
s := "eins\nzwei\ndrei\n"
fmt.Println("bufio.Scanner (Terminator weg):")
sc := bufio.NewScanner(strings.NewReader(s))
for sc.Scan() {
fmt.Printf(" %q\n", sc.Text())
}
fmt.Println("strings.Lines (Terminator bleibt):")
for line := range strings.Lines(s) {
fmt.Printf(" %q\n", line)
}
}bufio.Scanner (Terminator weg):
"eins"
"zwei"
"drei"
strings.Lines (Terminator bleibt):
"eins\n"
"zwei\n"
"drei\n"Faustregel: für Strings im RAM nimm strings.Lines. Für Streams nimm bufio.Scanner. Wer einen Scanner über einen String laufen lässt, wickelt ihn in strings.NewReader — was funktioniert, aber unnötigen Overhead bedeutet.
Ein realistisches Szenario: eine Konfigurations-Komponente lädt eine kleine Log-Datei vollständig in den Speicher und möchte daraus alle ERROR- und WARN-Zeilen für die Anzeige in einem Admin-Dashboard extrahieren. Statt einer manuellen Split-Iteration mit Trimming reicht ein for range über strings.Lines.
Da jede Zeile noch ihren Terminator trägt, kann das Filter-Ergebnis direkt per strings.Builder wieder zu einem Block-String zusammengesetzt werden — die Newline-Struktur bleibt verlustfrei erhalten.
package main
import (
"fmt"
"strings"
)
func filterCritical(raw string) string {
var out strings.Builder
for line := range strings.Lines(raw) {
if strings.HasPrefix(line, "ERROR") || strings.HasPrefix(line, "WARN") {
out.WriteString(line)
}
}
return out.String()
}
func main() {
log := "INFO 12:00 startup\n" +
"WARN 12:01 disk 80% voll\n" +
"INFO 12:02 user login\n" +
"ERROR 12:03 db timeout\n" +
"INFO 12:04 retry ok\n"
fmt.Print(filterCritical(log))
}WARN 12:01 disk 80% voll
ERROR 12:03 db timeoutDer Code ist nicht nur kompakter als die Split-Variante, er ist auch effizienter: bei einer Log-Datei mit hunderttausenden Zeilen wird kein einziges Zwischen-Slice angelegt — nur die tatsächlich übernommenen Zeilen landen im Builder.
Ein zweiter Anwendungsfall: Parser für ein einfaches Text-Protokoll, etwa eine HTTP-Header-Sektion oder eine key: value-Konfigurationszeile pro Zeile. Hier kommt der CRLF-Aspekt zum Tragen — HTTP nutzt \r\n, und unsere Implementierung sollte robust mit beiden Varianten umgehen.
Das Pattern: pro Zeile zuerst den Terminator trimmen, dann an : splitten und beide Hälften trimmen. Eine leere Zeile (nur noch \r\n oder \n) markiert das Ende der Header-Sektion — perfekter Anlass für ein break.
package main
import (
"fmt"
"strings"
)
func parseHeaders(raw string) map[string]string {
headers := make(map[string]string)
for line := range strings.Lines(raw) {
clean := strings.TrimRight(line, "\r\n")
if clean == "" {
break // Leerzeile = Ende der Header-Sektion
}
key, value, ok := strings.Cut(clean, ":")
if !ok {
continue
}
headers[strings.TrimSpace(key)] = strings.TrimSpace(value)
}
return headers
}
func main() {
req := "Host: example.org\r\n" +
"User-Agent: mibeon/1.0\r\n" +
"Accept: text/html\r\n" +
"\r\n" +
"BODY-IGNORIERT"
for k, v := range parseHeaders(req) {
fmt.Printf("%s = %s\n", k, v)
}
}Host = example.org
User-Agent = mibeon/1.0
Accept = text/htmlDas Beispiel demonstriert mehrere Stärken auf einmal: CRLF-Robustheit durch TrimRight, vorzeitiger Abbruch bei der Leerzeile (Body wird gar nicht erst zerlegt), und die Kombination mit strings.Cut als idiomatischer Begleiter-Funktion.
Signatur
func Lines(s string) iter.Seq[string] — ein Argument, ein Iterator-Rückgabewert. Verfügbar ab Go 1.24.
Terminator inklusive
Jede gelieferte Zeile enthält ihren Zeilenumbruch (\n oder \r\n). Das macht das Verhalten verlustfrei — Konkatenation rekonstruiert das Original.
Letzte Zeile ohne Newline
Endet die Eingabe nicht mit \n, hat die letzte Zeile keinen Terminator. Signal für „unvollständig abgeschlossen".
Lazy Evaluation
Iterator extrahiert Zeilen on-demand. break beendet sofort — keine unnötige Arbeit für nicht-benötigte Zeilen.
Keine Allokation
Zeilen sind Substrings der Eingabe, kein []string-Zwischenergebnis. Bei großen Strings drastische Speicher-Ersparnis gegenüber strings.Split.
CRLF abstreifen
Plattformneutral mit strings.TrimRight(line, "\r\n") arbeiten — TrimSpace entfernt mehr als gewünscht.
Strings vs. Streams
strings.Lines ist für In-Memory-Strings. Für io.Reader-Quellen weiterhin bufio.Scanner mit ScanLines nutzen.
Familie
Teil der Iterator-Familie in Go 1.24: Lines, SplitSeq, SplitAfterSeq, FieldsSeq, FieldsFuncSeq — alle liefern iter.Seq[string].