Go bietet zwei grundverschiedene Wege, Eingabe in Werte zu verwandeln. Die fmt-Scan-Familie (Scan, Scanln, Scanf, Sscan, Fscan und Verwandte) ist type-aware und reflection-getrieben: man übergibt Pointer auf Zielvariablen, und die Bibliothek übernimmt Tokenisierung sowie Konvertierung in einem Schritt. Der bufio.Scanner ist demgegenüber bewusst low-level: er liefert Bytes oder Strings zeilen- bzw. tokenweise, das Parsen bleibt explizit beim Aufrufer (typischerweise via strconv).
Diese Konzept-Seite arbeitet heraus, warum die kürzere fmt-Variante in Production fast immer der unterlegene Pfad ist — und wann sie trotzdem ihren Platz hat. Das Ergebnis vorweg: für robustes Tooling, CSV-Verarbeitung, Logs und Protokolle ist bufio.Scanner + strconv praktisch alternativlos. fmt.Scan bleibt nützlich für Coding-Challenges und schnelle Lehrbuch-Beispiele.
Der grundlegende Unterschied liegt in der Verantwortungsteilung. fmt.Scan macht zwei Dinge gleichzeitig: es liest Bytes aus einer Quelle und konvertiert sie über Reflection in den durch den Pointer-Typ vorgegebenen Zieltyp. Whitespace ist dabei impliziter Token-Trenner, Newlines werden in den meisten Varianten wie gewöhnlicher Whitespace behandelt.
bufio.Scanner trennt diese beiden Aufgaben sauber. Er beschäftigt sich ausschließlich mit der Frage: „Wo endet das nächste Stück Eingabe?" Die Antwort hängt von der SplitFunc ab — standardmäßig ScanLines, optional ScanWords, ScanRunes, ScanBytes oder eine eigene Funktion. Das Ergebnis ist immer ein string (oder []byte); was damit geschieht, entscheidet der Aufrufer.
Diese Trennung wirkt zunächst nach Mehrarbeit, ist aber der entscheidende Hebel für Robustheit: Lesefehler, Splitting-Verhalten und Konvertierungsfehler sind drei klar voneinander unterscheidbare Fehlerquellen.
Die folgende Tabelle stellt die wichtigsten Dimensionen gegenüber. Sie ist bewusst praxisnah formuliert — nicht „Feature X vs. Feature Y", sondern „Was passiert, wenn die Eingabe nicht ideal ist?".
| Aspekt | fmt-Scan-Familie | bufio.Scanner |
|---|---|---|
| Trennlogik | Whitespace-getrennte Tokens, intern festgelegt | konfigurierbar via SplitFunc |
| Zielwert | direkt typisiert via Pointer + Reflection | immer string/[]byte, Konvertierung separat |
| Strings mit Leerzeichen | bricht beim ersten Whitespace ab | volle Kontrolle |
| Zeilenorientierung | unzuverlässig (Scanln rudimentär) | nativer Standardmodus |
| Fehlermeldungen | knapp, ohne Kontext | exakt zuordenbar (Lesen/Split/Parse) |
| Encoding-Robustheit | empfindlich gegen BOM, CRLF, Sonderzeichen | toleranter, da bytestreambasiert |
| Testbarkeit | mittel (Pointer-Mocks) | hoch (jede Stufe einzeln) |
| Performance bei großen Inputs | langsam (Reflection pro Token) | schnell (kein Reflection-Overhead) |
| Typische Domäne | Coding-Challenges, Mini-CLI | Production, CSV, Logs, Protokolle |
Schon diese Aufstellung macht deutlich, dass die beiden APIs nicht „zwei Wege zum selben Ziel" sind, sondern für unterschiedliche Klassen von Eingabe gedacht.
Es gibt einen klar umrissenen Sweet Spot für fmt.Scan: streng strukturierte Eingaben mit fest definierter Token-Reihenfolge, ohne Strings mit Leerzeichen, ohne mehrzeilige Felder und ohne nennenswerte Fehlerbehandlung. Das ist exakt die Welt der Coding-Challenges auf Plattformen wie Codeforces oder Advent of Code: erste Zeile enthält n, dann folgen n Zahlen.
package main
import "fmt"
func main() {
var n int
fmt.Scan(&n)
sum := 0
for i := 0; i < n; i++ {
var x int
fmt.Scan(&x)
sum += x
}
fmt.Println("Summe:", sum)
}3
10 20 30
Summe: 60Der Code ist knapp, lesbar, und das Eingabeformat passt perfekt zur API: nur Integer, durch Whitespace getrennt, keine Sonderfälle. In dieser Nische ist fmt.Scan legitim — und genau so wird es in den meisten Go-Tutorials eingeführt.
Sobald die Eingabe realistischer wird, brechen die Annahmen von fmt.Scan schnell. Strings mit Leerzeichen, mehrere Felder pro Zeile, Encoding-Eigenheiten wie CRLF oder BOM, und partielle Reads decken die Schwächen rasch auf. Der folgende Code demonstriert vier typische Stolperfallen in einem einzigen Programm.
package main
import "fmt"
func main() {
// Problem 1: Strings mit Leerzeichen
var name string
fmt.Print("Name: ")
fmt.Scan(&name) // "Max Mustermann" -> nur "Max"
fmt.Println("Gelesen:", name)
// Problem 2: Newline wird wie Whitespace behandelt
var a, b int
fmt.Print("Zwei Zahlen: ")
fmt.Scan(&a, &b) // funktioniert auch ueber zwei Zeilen — oft unerwuenscht
// Problem 3: Pointer-Pflicht ist leicht zu vergessen
var c int
fmt.Scan(c) // Compile-Fehler bzw. Runtime-Problem: kein Pointer
// Problem 4: Fehler sind kontextarm
var n int
_, err := fmt.Scan(&n) // "abc" -> "expected integer"
if err != nil {
fmt.Println("Fehler:", err)
}
fmt.Println(a, b, c)
}Name: Max Mustermann
Gelesen: Max
Zwei Zahlen: 1
2
Fehler: expected integer
1 2 0Jedes dieser vier Probleme tritt in echten CLI-Tools regelmäßig auf. Besonders heimtückisch ist Problem 2: fmt.Scan und fmt.Scanln behandeln Newlines unterschiedlich, und Scanln ist alles andere als robust gegenüber CRLF. Wer hier auf Windows-Eingaben trifft, debuggt schnell stundenlang.
Die bufio-Variante kehrt die Verantwortung um: zuerst lesen, dann parsen. Der Scanner produziert pro Scan()-Aufruf genau eine Zeile (oder ein Token, je nach SplitFunc). Die Konvertierung übernimmt strconv, das deutlich aussagekräftigere Fehler liefert als die Reflection-Engine von fmt.
package main
import (
"bufio"
"fmt"
"os"
"strconv"
"strings"
)
func main() {
sc := bufio.NewScanner(os.Stdin)
// Erste Zeile: Anzahl
sc.Scan()
n, err := strconv.Atoi(strings.TrimSpace(sc.Text()))
if err != nil {
fmt.Fprintln(os.Stderr, "ungueltige Anzahl:", err)
os.Exit(1)
}
// Zweite Zeile: n Zahlen, durch Spaces getrennt
sc.Scan()
fields := strings.Fields(sc.Text())
if len(fields) != n {
fmt.Fprintf(os.Stderr, "erwartet %d Werte, bekam %d\n", n, len(fields))
os.Exit(1)
}
sum := 0
for i, f := range fields {
v, err := strconv.Atoi(f)
if err != nil {
fmt.Fprintf(os.Stderr, "Feld %d (%q): %v\n", i, f, err)
os.Exit(1)
}
sum += v
}
if err := sc.Err(); err != nil {
fmt.Fprintln(os.Stderr, "Lesefehler:", err)
os.Exit(1)
}
fmt.Println("Summe:", sum)
}3
10 20 30
Summe: 60Auf den ersten Blick wirkt der Code länger — und das ist er auch. Dafür benennt er jeden Fehlerfall einzeln: ungültige Anzahl, falsche Feldanzahl, falsches Format pro Feld, Lesefehler des Scanners. Im Production-Tooling ist genau diese Granularität Gold wert, weil Bug-Reports von Nutzern aussagekräftig werden.
Der konzeptionelle Kern lautet: Lese-Pfad und Parse-Pfad getrennt halten. Das Lesen liefert Bytes oder Strings, das Parsen interpretiert sie. Beide Stufen sind isoliert testbar.
Ein Beispiel zeigt, was das praktisch bedeutet. Stellen wir uns eine Funktion vor, die Konfigurationszeilen der Form key=value einliest. Mit fmt.Scanf("%s=%s", ...) ist die Funktion an os.Stdin (oder ein io.Reader) gekoppelt, der gemockt werden muss. Mit der bufio-Variante ist die reine Parse-Funktion stringbasiert und damit trivial im Unit-Test isolierbar.
package main
import (
"bufio"
"fmt"
"io"
"strings"
)
// parseEntry ist rein stringbasiert — perfekt testbar.
func parseEntry(line string) (key, value string, err error) {
idx := strings.IndexByte(line, '=')
if idx < 0 {
return "", "", fmt.Errorf("ungueltiges Format: %q", line)
}
return strings.TrimSpace(line[:idx]), strings.TrimSpace(line[idx+1:]), nil
}
// readEntries kuemmert sich nur um Lesen + Iteration.
func readEntries(r io.Reader) (map[string]string, error) {
sc := bufio.NewScanner(r)
out := make(map[string]string)
for sc.Scan() {
k, v, err := parseEntry(sc.Text())
if err != nil {
return nil, err
}
out[k] = v
}
return out, sc.Err()
}
func main() {
src := "host = localhost\nport = 8080\n"
cfg, err := readEntries(strings.NewReader(src))
if err != nil {
fmt.Println("Fehler:", err)
return
}
fmt.Println(cfg)
}map[host:localhost port:8080]parseEntry ist ein reiner Stringtransformator ohne io.Reader-Abhängigkeit. Tests bestehen aus Tabellen von Eingabe-String und erwartetem Ergebnis — kein Mocking nötig. Das ist mit fmt.Scan strukturell unmöglich, weil die API die Lese- und Parse-Stufe verschmilzt.
Wer ein bestehendes Programm umstellt, geht in drei Schritten vor: erst den Input-Strom in einen bufio.Scanner wickeln, dann pro Feld strconv ergänzen, schließlich Fehlerbehandlung pro Stufe schärfen. Die Diff-artige Gegenüberstellung zeigt das Muster.
package main
import "fmt"
func main() {
var n int
fmt.Scan(&n)
var name string
var alter int
for i := 0; i < n; i++ {
fmt.Scan(&name, &alter) // bricht bei Namen mit Leerzeichen
fmt.Printf("%s ist %d\n", name, alter)
}
}Die Vorher-Variante ist kurz, aber zerbricht an jedem Namen mit Leerzeichen — und die Fehlerausgabe ist nichtssagend. Die Nachher-Variante akzeptiert ein klares Format („Name in einer Zeile, Alter in der nächsten") und meldet, in welchem Feld der Fehler auftrat.
package main
import (
"bufio"
"fmt"
"os"
"strconv"
"strings"
)
func main() {
sc := bufio.NewScanner(os.Stdin)
sc.Scan()
n, err := strconv.Atoi(strings.TrimSpace(sc.Text()))
if err != nil {
fmt.Fprintln(os.Stderr, "Anzahl ungueltig:", err)
os.Exit(1)
}
for i := 0; i < n; i++ {
sc.Scan()
name := strings.TrimSpace(sc.Text())
sc.Scan()
alter, err := strconv.Atoi(strings.TrimSpace(sc.Text()))
if err != nil {
fmt.Fprintf(os.Stderr, "Eintrag %d Alter: %v\n", i, err)
os.Exit(1)
}
fmt.Printf("%s ist %d\n", name, alter)
}
if err := sc.Err(); err != nil {
fmt.Fprintln(os.Stderr, "Lesefehler:", err)
os.Exit(1)
}
}2
Max Mustermann
30
Erika Musterfrau
28
Max Mustermann ist 30
Erika Musterfrau ist 28Der Migrationsaufwand bleibt überschaubar, der Robustheitsgewinn ist erheblich: Namen mit Leerzeichen funktionieren, Fehler werden pro Feld lokalisiert, und das Eingabeformat ist explizit definiert.
fmt.Scan ist nicht „falsch" — nur falsch dimensioniert für viele Aufgaben. Es gibt drei legitime Einsatzgebiete:
- Coding-Challenges mit garantiert sauberem Eingabeformat (Codeforces, AoC, LeetCode-CLI).
- Lehrbuch-Beispiele und kleine REPL-artige Demos, wo die Eingabe vom Autor kontrolliert ist.
fmt.Sscanfmit explizitem Format-String (z. B."%d-%d-%d"für Datumsangaben) — hier ist das Format-Token tatsächlich das Werkzeug der Wahl, weil es ein kleines Pattern eindeutig beschreibt.
Außerhalb dieser drei Fälle gewinnt bufio.Scanner + strconv praktisch immer: bessere Fehler, klare Trennung, höhere Robustheit und in der Regel auch bessere Performance.
Ein realistischer Lackmustest ist ein einfacher CSV-Parser. CSV bricht praktisch alle Annahmen von fmt.Scan: Felder enthalten Leerzeichen, Trennzeichen sind nicht Whitespace, und leere Felder sind erlaubt. Erst der direkte Vergleich macht den Unterschied greifbar.
Die fmt.Sscan-Variante kommt schon bei einer harmlosen Zeile aus dem Tritt. Sie ignoriert das Komma als Trennzeichen vollständig — Sscan kennt nur Whitespace —, und ein Feld mit Leerzeichen reißt das Token-Mapping endgültig auseinander.
package main
import "fmt"
func main() {
line := "Max Mustermann,30,Berlin"
var name, ort string
var alter int
n, err := fmt.Sscan(line, &name, &alter, &ort)
fmt.Printf("gelesen=%d name=%q alter=%d ort=%q err=%v\n",
n, name, alter, ort, err)
}gelesen=0 name="Max" alter=0 ort="" err=expected integerDas Ergebnis ist desolat: fmt.Sscan hat den Vornamen als ersten Token gelesen, scheitert am zweiten Token „Mustermann,30,Berlin" (kein Integer) und bricht ab. Es gibt keine sinnvolle Möglichkeit, das mit der fmt-Scan-Familie zu reparieren — die API kennt keine konfigurierbaren Trenner.
Die bufio-Variante bewältigt die gleiche Eingabe ohne Aufwand, weil strings.Split das Trennzeichen explizit macht und jedes Feld einzeln parsbar ist.
package main
import (
"bufio"
"fmt"
"strconv"
"strings"
)
type Record struct {
Name string
Alter int
Ort string
}
func parseRecord(line string) (Record, error) {
parts := strings.Split(line, ",")
if len(parts) != 3 {
return Record{}, fmt.Errorf("erwartet 3 Felder, bekam %d", len(parts))
}
alter, err := strconv.Atoi(strings.TrimSpace(parts[1]))
if err != nil {
return Record{}, fmt.Errorf("Feld Alter: %w", err)
}
return Record{
Name: strings.TrimSpace(parts[0]),
Alter: alter,
Ort: strings.TrimSpace(parts[2]),
}, nil
}
func main() {
src := "Max Mustermann,30,Berlin\nErika Musterfrau,28,Hamburg\n"
sc := bufio.NewScanner(strings.NewReader(src))
for sc.Scan() {
rec, err := parseRecord(sc.Text())
if err != nil {
fmt.Println("Fehler:", err)
continue
}
fmt.Printf("%+v\n", rec)
}
}{Name:Max Mustermann Alter:30 Ort:Berlin}
{Name:Erika Musterfrau Alter:28 Ort:Hamburg}Der Unterschied könnte kaum deutlicher sein: die bufio-Variante liefert korrekte Strukturen, eine Fehlermeldung pro Feld und behandelt Namen mit Leerzeichen problemlos. Für echte CSV-Daten würde man später noch auf encoding/csv wechseln — auch das ist eine bufio-artige API, kein Reflection-Mechanismus.
bufio.Scanner ist der Production-Default
Für CLI-Tooling, Daten-Pipelines, Logs und Protokolle ist bufio.Scanner + strconv die Standardwahl in Go — robust, schnell und klar testbar.
fmt.Scan ist type-aware, aber fragil
Reflection liefert knappe Syntax, kollabiert aber an Strings mit Leerzeichen, CRLF, BOM und partiellen Reads.
String-Reads nur token-weise
fmt.Scan(&s) liest immer nur bis zum nächsten Whitespace — niemals „die ganze Zeile". Wer Letzteres braucht, ist mit Scanner.Text() besser bedient.
Partial-Reads als stille Fehlerquelle
fmt.Scan gibt zurück, wie viele Items gelesen wurden — ein Wert kleiner als erwartet bleibt leicht unbemerkt, wenn der Rückgabewert ignoriert wird.
Encoding-Probleme treffen vor allem fmt.Scan
BOMs und CRLF-Zeilenenden führen bei fmt.Scan/Scanln zu schwer diagnostizierbarem Verhalten; bufio.Scanner arbeitet bytestreambasiert und ist toleranter.
strconv liefert bessere Fehler
strconv.Atoi & Co. zeigen exakt, welcher String nicht konvertierbar war — fmt-Errors bleiben generisch wie „expected integer".
Tests werden mit bufio einfacher
Reine Parse-Funktionen sind stringbasiert und brauchen keinen io.Reader-Mock — Tabellengetriebene Unit-Tests werden trivial.
fmt.Sscanf bleibt für Format-Strings nützlich
Bei klar definierten kleinen Patterns ("%d-%d-%d", "%s:%d") ist Sscanf weiterhin ein legitimes Werkzeug — punktuell, nicht als Eingabe-Strategie.