Wer Zahlen aus Strings liest oder Zahlen für Ausgabe, Logs oder Protokolle formatiert, greift in Go selten zu fmt.Sprintf oder fmt.Sscanf — beide nutzen Reflection, allozieren großzügig und sind für simple Konvertierungen schlicht zu teuer. Das Paket strconv ist die spezialisierte Alternative: handgeschriebene, allokationsarme Parser und Formatierer für int, float, bool und Go-String-Literale.
Der Geschwindigkeitsunterschied ist keine Mikrooptimierung. In Hot Paths — JSON-Marshaling, Log-Pipelines, Metrics-Export — entscheidet die Wahl zwischen strconv.FormatInt und fmt.Sprintf über den Durchsatz einer ganzen Anwendung. Gleichzeitig ist die API breiter und präziser: Basis-Auswahl (binär, oktal, hex), bitSize-Kontrolle für Range-Checks, getrennte Fehlerklassen für Syntax- und Range-Probleme, sowie Quote/Unquote für korrekte Go-String-Literale.
Die kompakten Helfer
strconv.Atoi und strconv.Itoa sind die kürzesten Wege zwischen string und int. Die Namen stammen aus der C-Standardbibliothek — ASCII to integer und integer to ASCII — und Go hat sie aus Gewohnheit übernommen, obwohl sie in einer ansonsten gesprächigen API auffallen.
Atoi(s string) (int, error) parst einen dezimalen String als int der nativen Größe der Plattform (32 oder 64 Bit). Es ist eine Bequemlichkeits-Hülle um ParseInt(s, 10, 0). Itoa(n int) string ist das Gegenstück und ein Alias für FormatInt(int64(n), 10).
package main
import (
"fmt"
"strconv"
)
func main() {
n, err := strconv.Atoi("42")
if err != nil {
fmt.Println("Fehler:", err)
return
}
fmt.Printf("Wert: %d (%T)\n", n, n)
s := strconv.Itoa(-17)
fmt.Printf("String: %q\n", s)
if _, err := strconv.Atoi("3.14"); err != nil {
fmt.Println("Fehler:", err)
}
}Wert: 42 (int)
String: "-17"
Fehler: strconv.Atoi: parsing "3.14": invalid syntaxWichtig: Atoi akzeptiert weder führende Whitespaces noch Plus-Vorzeichen-Schreibweisen mit Komma. Der String muss exakt die kanonische Form -?[0-9]+ haben. Wer mehr Toleranz braucht — etwa Strings aus User-Input mit Leerzeichen — trimmt vorher mit strings.TrimSpace.
Basis und bitSize
ParseInt(s string, base int, bitSize int) (int64, error) ist die volle API. Sie liefert immer ein int64, prüft aber gegen den Wertebereich des angegebenen bitSize. Der Rückgabewert lässt sich anschließend per Type-Cast in den Zieltyp überführen — bei Erfolg ohne Datenverlust.
Der Parameter base steuert die Zahlenbasis. Werte 2 bis 36 sind erlaubt; Ziffern jenseits 9 werden mit Buchstaben a-z/A-Z geschrieben (case-insensitiv). Die magische Basis 0 aktiviert die automatische Erkennung anhand des Präfixes: 0x oder 0X für hex, 0o/0O für oktal, 0b/0B für binär, sonst dezimal.
bitSize | Erlaubter Wertebereich | Typischer Cast-Ziel |
|---|---|---|
| 0 | wie int (32 oder 64 Bit) | int |
| 8 | -128 … 127 | int8 |
| 16 | -32 768 … 32 767 | int16 |
| 32 | -2 147 483 648 … 2 147 483 647 | int32 |
| 64 | -9 223 372 036 854 775 808 … +… | int64 |
package main
import (
"fmt"
"strconv"
)
func main() {
n, _ := strconv.ParseInt("ff", 16, 16)
fmt.Printf("ff (base 16) = %d\n", n)
for _, s := range []string{"0xff", "0o17", "0b1010", "42"} {
v, err := strconv.ParseInt(s, 0, 64)
fmt.Printf("%-8s -> %d err=%v\n", s, v, err)
}
if _, err := strconv.ParseInt("200", 10, 8); err != nil {
fmt.Println("Range:", err)
}
u, _ := strconv.ParseUint("ffff", 16, 16)
fmt.Printf("ffff (uint16) = %d\n", u)
}ff (base 16) = 255
0xff -> 255 err=<nil>
0o17 -> 15 err=<nil>
0b1010 -> 10 err=<nil>
42 -> 42 err=<nil>
Range: strconv.ParseInt: parsing "200": value out of range
ffff (uint16) = 65535ParseUint mit identischer Signatur deckt vorzeichenlose Werte ab — wichtig, weil ParseInt ein Minus-Zeichen erlaubt und für unsigned Eingaben die obere Hälfte des Wertebereichs nicht erreicht.
Gleitkommazahlen lesen
ParseFloat(s string, bitSize int) (float64, error) akzeptiert die übliche Float-Notation inklusive Dezimalpunkt, wissenschaftlicher Schreibweise (1.5e10), Hexadezimal-Floats (0x1.fp10), sowie die Spezialwerte NaN, Inf, +Inf und -Inf (case-insensitiv).
bitSize ist hier kein Range-Check, sondern eine Rundungs-Anweisung: bei 32 rundet das Ergebnis auf eine Genauigkeit, die in float32 darstellbar ist, der Rückgabetyp bleibt aber float64. Wer in float32 speichert, castet nach erfolgreichem Parse.
package main
import (
"fmt"
"math"
"strconv"
)
func main() {
inputs := []string{
"3.14159",
"1.5e10",
"-0.0001",
"NaN",
"+Inf",
"0x1.fp4",
}
for _, s := range inputs {
v, err := strconv.ParseFloat(s, 64)
switch {
case err != nil:
fmt.Printf("%-10s -> Fehler: %v\n", s, err)
case math.IsNaN(v):
fmt.Printf("%-10s -> NaN\n", s)
default:
fmt.Printf("%-10s -> %v\n", s, v)
}
}
v32, _ := strconv.ParseFloat("0.1", 32)
v64, _ := strconv.ParseFloat("0.1", 64)
fmt.Printf("0.1 als float32: %.20f\n", v32)
fmt.Printf("0.1 als float64: %.20f\n", v64)
}3.14159 -> 3.14159
1.5e10 -> 1.5e+10
-0.0001 -> -0.0001
NaN -> NaN
+Inf -> +Inf
0x1.fp4 -> 31
0.1 als float32: 0.10000000149011611938
0.1 als float64: 0.10000000000000000555Die unterschiedlichen Nachkommastellen für 0.1 sind kein Bug, sondern die unvermeidbare Eigenheit binärer Floats — siehe IEEE 754. ParseFloat liefert immer die am nächsten darstellbare Zahl, mehr ist mit endlicher Bit-Breite nicht möglich.
Ein strikter Boolean-Parser
ParseBool ist absichtlich knauserig: er akzeptiert genau elf Tokens und sonst nichts. Die wahren Werte sind "1", "t", "T", "TRUE", "true", "True"; die falschen sind "0", "f", "F", "FALSE", "false", "False". Alles andere — "yes", "on", "y", leerer String — produziert einen Fehler.
Diese Strenge ist gewollt. In Konfigurations-Parsern und CLI-Flags wäre lockere Akzeptanz eine Quelle stiller Bugs, weil "yes" mal wahr und mal falsch interpretiert würde. Wer die toleranteren Synonyme braucht, schreibt einen eigenen Wrapper, der strings.ToLower und ein Switch davorschaltet.
package main
import (
"fmt"
"strconv"
)
func main() {
for _, s := range []string{"true", "1", "T", "False", "yes", ""} {
b, err := strconv.ParseBool(s)
fmt.Printf("%-6q -> %v err=%v\n", s, b, err)
}
}"true" -> true err=<nil>
"1" -> true err=<nil>
"T" -> true err=<nil>
"False" -> false err=<nil>
"yes" -> false err=strconv.ParseBool: parsing "yes": invalid syntax
"" -> false err=strconv.ParseBool: parsing "": invalid syntaxDas Rückgabe-Tupel bei Fehler ist (false, err) — false ist hier kein bedeutungsvoller Wert, sondern der Nullwert. Wer den Fehler ignoriert, programmiert sich ein Loch in den Bauch.
Zahlen zu Strings
Die Format*-Familie ist das Gegenstück zu Parse*. FormatInt(i int64, base int) string schreibt einen Integer in der gewünschten Basis (2 bis 36). FormatUint macht dasselbe für uint64. FormatBool(b bool) string liefert "true" oder "false".
Interessanter ist FormatFloat(f float64, fmt byte, prec, bitSize int) string. Das Format-Verb steuert die Darstellung: f für Dezimal ohne Exponent, e/E für wissenschaftliche Notation, g/G für die kompakteste Form, b für Mantisse-Exponent in Basis 2, x für Hexadezimal-Float. prec ist die Anzahl der Nachkommastellen. Der Sonderwert -1 bedeutet minimale Anzahl, die nötig ist, um den Float beim Parsen exakt zu rekonstruieren — die kanonische Round-Trip-Form.
package main
import (
"fmt"
"strconv"
)
func main() {
n := int64(255)
fmt.Println("dezimal: ", strconv.FormatInt(n, 10))
fmt.Println("hex: ", strconv.FormatInt(n, 16))
fmt.Println("binär: ", strconv.FormatInt(n, 2))
fmt.Println("base 36: ", strconv.FormatInt(1_000_000, 36))
f := 3.14159265358979
fmt.Println("f, prec 2: ", strconv.FormatFloat(f, 'f', 2, 64))
fmt.Println("e, prec 6: ", strconv.FormatFloat(f, 'e', 6, 64))
fmt.Println("g, prec -1: ", strconv.FormatFloat(f, 'g', -1, 64))
s := strconv.FormatFloat(f, 'g', -1, 64)
back, _ := strconv.ParseFloat(s, 64)
fmt.Println("identisch? ", back == f)
}dezimal: 255
hex: ff
binär: 11111111
base 36: lfls
f, prec 2: 3.14
e, prec 6: 3.141593e+00
g, prec -1: 3.14159265358979
identisch? trueprec = -1 ist nicht nur kürzer, sondern garantiert verlustfrei: jeder so formatierte Float lässt sich mit ParseFloat bit-exakt zurücklesen. Wer Floats in JSON, CSV oder ein Logfile schreibt, sollte diese Variante standardmäßig nehmen.
Null-Allokation in Hot Paths
Jeder FormatInt-Aufruf produziert einen frischen String — also eine Heap-Allokation. In einem Marshaling-Loop, der zehntausende Zahlen schreibt, summieren sich diese Allokationen zu spürbarem GC-Druck.
Die Append*-Varianten umgehen das. Sie nehmen einen []byte als ersten Parameter und hängen die Textdarstellung an. Der Slice wird wiederverwendet — wenn die Kapazität reicht, allokiert die Funktion gar nichts.
package main
import (
"fmt"
"strconv"
)
func main() {
buf := make([]byte, 0, 128)
buf = append(buf, "id="...)
buf = strconv.AppendInt(buf, 42, 10)
buf = append(buf, " ratio="...)
buf = strconv.AppendFloat(buf, 0.95, 'f', 2, 64)
buf = append(buf, " ok="...)
buf = strconv.AppendBool(buf, true)
fmt.Println(string(buf))
for i := 1; i <= 3; i++ {
buf = buf[:0]
buf = append(buf, "row "...)
buf = strconv.AppendInt(buf, int64(i), 10)
fmt.Println(string(buf))
}
}id=42 ratio=0.95 ok=true
row 1
row 2
row 3Genau dieses Muster steckt unter der Haube von encoding/json, Loggern wie zap und vielen Hochleistungs-Bibliotheken. Wer einen eigenen Serializer schreibt und Allokationen messen kann, sollte zuerst hier ansetzen.
Strings als Go-Literale
Quote(s string) string verpackt einen String in das, was ein Go-Compiler als String-Literal akzeptieren würde — inklusive Anführungszeichen und Escapes für Steuerzeichen, Backslashes und Anführungszeichen.
Das ist nützlich für drei Zwecke: erstens Debug-Ausgaben, in denen Whitespace und unsichtbare Zeichen lesbar werden sollen; zweitens Code-Generation, bei der man Strings sicher in generierten Go-Quelltext einbetten will; drittens REPL-artige Tools, die User-Input zurückspiegeln.
package main
import (
"fmt"
"strconv"
)
func main() {
s := "Hallo\tWelt\n\"Zitat\""
fmt.Println("original: ", s)
fmt.Println("Quote: ", strconv.Quote(s))
fmt.Println("QuoteASCII:", strconv.QuoteToASCII("Café 北京"))
fmt.Println("QuoteRune: ", strconv.QuoteRune('\n'))
q := strconv.Quote(s)
back, err := strconv.Unquote(q)
fmt.Println("Unquote ok:", err == nil, " identisch:", back == s)
}original: Hallo Welt
"Zitat"
Quote: "Hallo\tWelt\n\"Zitat\""
QuoteASCII: "Café 北京"
QuoteRune: '\n'
Unquote ok: true identisch: trueQuoteToASCII erzwingt reine ASCII-Ausgabe, was für portable Logdateien hilfreich ist; nicht-ASCII-Zeichen werden als \uXXXX-Escapes geschrieben. Unquote akzeptiert beide Stile sowie Backtick-Strings (Raw-Literals) und gibt den ursprünglichen String zurück.
Strukturierte Fehler
Alle Parse*-Funktionen geben bei Fehler einen *strconv.NumError zurück. Der Typ trägt drei Felder: Func (Name der Aufrufer-Funktion, etwa "ParseInt"), Num (die Original-Eingabe) und Err (die zugrundeliegende Fehlerursache).
Es gibt zwei vordefinierte Sentinel-Errors: strconv.ErrSyntax (Eingabe entspricht nicht der erwarteten Grammatik) und strconv.ErrRange (Grammatik passt, aber der Wert liegt außerhalb des bitSize-Bereichs). Über errors.Is lassen sich beide unterscheiden — wichtig, weil sie unterschiedliche User-Reaktionen verdienen: bei Syntax ist die Eingabe selbst falsch, bei Range ist sie nur zu groß.
package main
import (
"errors"
"fmt"
"strconv"
)
func diagnose(s string) {
_, err := strconv.ParseInt(s, 10, 8)
switch {
case err == nil:
fmt.Printf("%-6s -> ok\n", s)
case errors.Is(err, strconv.ErrSyntax):
fmt.Printf("%-6s -> Syntax: %q ist keine gültige Zahl\n", s, s)
case errors.Is(err, strconv.ErrRange):
fmt.Printf("%-6s -> Range: %q passt nicht in int8\n", s, s)
default:
fmt.Printf("%-6s -> unbekannt: %v\n", s, err)
}
var ne *strconv.NumError
if errors.As(err, &ne) {
fmt.Printf(" Func=%s Num=%q Err=%v\n", ne.Func, ne.Num, ne.Err)
}
}
func main() {
diagnose("42")
diagnose("abc")
diagnose("500")
}42 -> ok
abc -> Syntax: "abc" ist keine gültige Zahl
Func=ParseInt Num="abc" Err=invalid syntax
500 -> Range: "500" passt nicht in int8
Func=ParseInt Num="500" Err=value out of rangeerrors.As extrahiert die typisierte Struktur, falls man die Original-Eingabe in einer User-Meldung wiedergeben will. errors.Is reicht für die reine Kategorisierung — und genau das ist meistens die Frage, die ein Caller stellen will.
Robuste Konvertierung aus os.Args
Ein typisches Praxis-Szenario: ein kleines CLI-Tool nimmt zwei Argumente — eine Anzahl (int) und einen Schwellenwert (float) — und soll bei jedem denkbaren Fehler eine verständliche Meldung produzieren statt einfach in panic zu kippen.
package main
import (
"errors"
"fmt"
"os"
"strconv"
)
func parseInt(s string, def int) (int, error) {
if s == "" {
return def, nil
}
n, err := strconv.Atoi(s)
switch {
case errors.Is(err, strconv.ErrSyntax):
return 0, fmt.Errorf("%q ist keine Zahl", s)
case errors.Is(err, strconv.ErrRange):
return 0, fmt.Errorf("%q ist zu groß für int", s)
case err != nil:
return 0, err
}
return n, nil
}
func parseFloat(s string, def float64) (float64, error) {
if s == "" {
return def, nil
}
f, err := strconv.ParseFloat(s, 64)
if err != nil {
var ne *strconv.NumError
errors.As(err, &ne)
return 0, fmt.Errorf("Wert %q ungültig (%v)", s, ne.Err)
}
return f, nil
}
func arg(a []string, i int) string {
if i < len(a) {
return a[i]
}
return ""
}
func main() {
args := []string{"prog", "100", "0.75"}
count, err := parseInt(arg(args, 1), 10)
if err != nil {
fmt.Fprintln(os.Stderr, "count:", err)
os.Exit(2)
}
threshold, err := parseFloat(arg(args, 2), 0.5)
if err != nil {
fmt.Fprintln(os.Stderr, "threshold:", err)
os.Exit(2)
}
fmt.Printf("count=%d threshold=%.3f\n", count, threshold)
}count=100 threshold=0.750Die Trennung von Syntax- und Range-Fehler erlaubt zielgenaue Meldungen. Default-Werte werden über leeren String signalisiert — das gleiche Muster funktioniert mit os.Getenv, das bei fehlender Variable ebenfalls "" liefert.
Wie viel schneller ist strconv wirklich
Ein kleines Benchmark zeigt den Unterschied messbar. strconv.FormatInt arbeitet ohne Reflection und ohne Format-String-Interpretation — beides Kostenpunkte, die fmt.Sprintf immer trägt, egal wie simpel das Verb ist.
package strconvbench
import (
"fmt"
"strconv"
"testing"
)
var sink string
func BenchmarkSprintf(b *testing.B) {
for i := 0; i < b.N; i++ {
sink = fmt.Sprintf("%d", 1234567)
}
}
func BenchmarkFormatInt(b *testing.B) {
for i := 0; i < b.N; i++ {
sink = strconv.FormatInt(1234567, 10)
}
}
func BenchmarkAppendInt(b *testing.B) {
buf := make([]byte, 0, 32)
for i := 0; i < b.N; i++ {
buf = strconv.AppendInt(buf[:0], 1234567, 10)
}
sink = string(buf)
}goos: darwin
goarch: arm64
BenchmarkSprintf-8 28_654_318 41.2 ns/op 16 B/op 2 allocs/op
BenchmarkFormatInt-8 158_421_004 7.5 ns/op 8 B/op 1 allocs/op
BenchmarkAppendInt-8 402_117_550 2.9 ns/op 0 B/op 0 allocs/opDie genauen Zahlen variieren je nach Hardware, aber die Verhältnisse sind robust: FormatInt ist auf dieser Maschine rund 5-6x schneller als Sprintf, und AppendInt ist nochmal 2-3x schneller bei null Allokationen. In einem Logger, der Millionen Events pro Sekunde schreibt, bedeutet das den Unterschied zwischen „läuft" und „GC frisst die CPU".
Interessantes
Atoi für int, ParseInt für Kontrolle
strconv.Atoi ist die kurze Variante für plattformnatives int. Sobald die Zielgröße (int8, int32, int64) oder eine andere Basis gebraucht wird, ist ParseInt(s, base, bitSize) der richtige Einstieg.
base 0 erkennt Präfixe automatisch
Mit ParseInt(s, 0, 64) liest Go 0x, 0o und 0b automatisch als hex, oktal und binär. Praktisch für Konfigurations-Werte, in denen Nutzer die Schreibweise wählen dürfen.
FormatFloat mit prec=-1
Der Sonderwert -1 produziert die kürzeste Darstellung, aus der ParseFloat den ursprünglichen Float bit-exakt rekonstruieren kann. Standardwahl für jede persistente Float-Serialisierung.
AppendInt/AppendFloat in Hot Paths
Die Append*-Varianten schreiben in einen vorhandenen []byte und allokieren bei ausreichender Kapazität nichts. Pflicht in Serializern, Loggern und allem, was auf Durchsatz getunt ist.
strconv schlägt fmt.Sprintf um Faktor 3-10
Ohne Reflection und Format-String-Parsing arbeitet strconv deutlich schneller als fmt.Sprintf. Bei simplen Konvertierungen ist strconv immer die richtige Wahl — fmt glänzt nur, wenn das Format dynamisch oder gemischt ist.
ParseBool akzeptiert nur konkrete Tokens
Erlaubt sind ausschließlich 1/0, t/f, T/F und die Wörter true/false in drei Schreibweisen. "yes", "on", "y" gibt es nicht — wer das braucht, baut einen eigenen Wrapper.
errors.Is gegen ErrSyntax vs ErrRange
Beide Sentinel-Errors haben verschiedene Bedeutungen: Syntax = Eingabe formal falsch, Range = formal okay aber zu groß. errors.Is(err, strconv.ErrRange) trennt sie zuverlässig.
strconv.Quote für Debug-Ausgabe
Quote macht Steuerzeichen, Anführungszeichen und unsichtbare Whitespaces sichtbar — exakt im Go-Literal-Stil. Erste Wahl, wenn User-Strings in Logs landen sollen.
Weiterführende Ressourcen
Externe Quellen
strconv— Go Documentationstrconv.NumError- Go Blog: Strings, bytes, runes and characters
- IEEE 754 floats (Wikipedia)