8.20 strconv — Middle¶
Audience. You can call
Atoi/ItoaandParseFloat. Now you're writing the hot inner loop of a parser, marshaler, or CSV ingester, andfmt.Sprintf("%d", x)shows up in the profile. You need to knowAppend*, whenQuoteis the right tool, whyParseUintexists whenParseIntwould seem enough, and how to beat the general-purpose APIs by a factor of 10×.
1. The Append* family — appending without allocation¶
Every Format* function has an Append* twin that takes a []byte and writes into it instead of allocating a new string:
func AppendInt(dst []byte, i int64, base int) []byte
func AppendUint(dst []byte, i uint64, base int) []byte
func AppendFloat(dst []byte, f float64, fmt byte, prec, bitSize int) []byte
func AppendBool(dst []byte, b bool) []byte
func AppendQuote(dst []byte, s string) []byte
func AppendQuoteRune(dst []byte, r rune) []byte
These are the foundation of every fast Go JSON encoder. The pattern:
b := make([]byte, 0, 64)
b = append(b, '{', '"', 'i', 'd', '"', ':')
b = strconv.AppendInt(b, int64(user.ID), 10)
b = append(b, ',', '"', 'n', 'a', 'm', 'e', '"', ':')
b = strconv.AppendQuote(b, user.Name)
b = append(b, '}')
One growing slice, no intermediate strings, no fmt parsing. Typical speedup over fmt.Sprintf is 3–10×.
2. The Itoa shortcut vs FormatInt¶
strconv.Itoa(i) is exactly strconv.FormatInt(int64(i), 10). Both allocate a fresh string. For the hottest paths, use AppendInt into a pooled buffer.
s := strconv.Itoa(42) // "42", allocates
s := strconv.FormatInt(42, 10) // "42", allocates
b := strconv.AppendInt(nil, 42, 10) // []byte("42"), allocates
The third form is interesting because passing nil causes Append to allocate fresh — same cost as Itoa. The win comes from passing a pre-allocated buffer.
3. Bases other than 10¶
FormatInt(i, base) and ParseInt(s, base, bitSize) accept any base from 2 to 36.
strconv.FormatInt(255, 16) // "ff"
strconv.FormatInt(255, 2) // "11111111"
strconv.FormatInt(255, 36) // "73"
strconv.ParseInt("ff", 16, 64) // 255, nil
strconv.ParseInt("0xff", 0, 64) // 255, nil (base 0 = auto-detect)
strconv.ParseInt("0o755", 0, 64) // 493
strconv.ParseInt("0b1010", 0, 64) // 10
Base 0 is the auto-detect mode:
0x...→ base 160b...→ base 2 (Go 1.13+)0o...→ base 8 (Go 1.13+)0...→ base 8 (legacy C-style; rare but still works)- otherwise → base 10
This is the right choice when parsing user input where the format varies.
For uppercase hex (FF instead of ff), use the fmt family (fmt.Sprintf("%X", 255)) or post-process with bytes.ToUpper. strconv.FormatInt always emits lowercase digits.
4. ParseUint — why it exists¶
ParseInt returns an int64. The maximum value is 2^63 − 1. If you're parsing a uint64 from a network protocol or a database column, the top bit of legitimate values won't fit:
v, err := strconv.ParseInt("18446744073709551615", 10, 64)
// err: strconv.ParseInt: parsing "18446744073709551615": value out of range
v, err := strconv.ParseUint("18446744073709551615", 10, 64)
// v == math.MaxUint64, err == nil
Same reasoning for hex addresses (0xFFFFFFFFFFFFFFFF), Unix timestamps in microseconds, sequence numbers — anything with the high bit set. Use ParseUint.
5. The bitSize parameter, explained¶
ParseInt(s, base, bitSize) checks that the result fits in bitSize bits. Valid values: 0, 8, 16, 32, 64.
strconv.ParseInt("128", 10, 8) // err: out of range (int8 max is 127)
strconv.ParseInt("128", 10, 16) // 128, nil
bitSize == 0 means "size of int on this platform". Always pass the destination size; it makes range errors fail fast at parse time instead of corrupting silently after assignment.
The return type is always int64; you cast to the smaller type after the range check:
v, err := strconv.ParseInt(s, 10, 32)
if err != nil { return err }
i32 := int32(v) // safe: parse already validated the range
6. ParseFloat: 32-bit vs 64-bit¶
f, err := strconv.ParseFloat("3.14", 64) // f is float64
f, err := strconv.ParseFloat("3.14", 32) // f is float64 but bit-pattern matches float32
Always returns float64, but the bitSize parameter controls rounding. With bitSize == 32, ParseFloat rounds to a value representable as float32, so the cast to float32 is exact:
With bitSize == 64, the result has full float64 precision. If you cast that to float32, you risk double rounding:
For most code, this distinction doesn't matter. For numerical correctness (financial calculations, scientific data), match bitSize to the destination type.
7. Quote, Unquote, and Go string literals¶
Quote emits a string in Go syntax: surrounded by ", with \n, \t, \", \\, and non-printable runes properly escaped.
strconv.Quote("hello\nworld") // "\"hello\\nworld\""
strconv.Quote(`a"b`) // "\"a\\\"b\""
strconv.QuoteRune('é') // "'é'" (é)
Unquote reverses it, returning the original Go string value:
The most common use is logging or debugging: when you want to print a string with explicit byte boundaries, Quote shows it. For JSON-style escaping, use encoding/json; Quote follows Go syntax, not JSON (close, but not identical — JSON doesn't allow \xNN, but Quote may emit it).
Variants:
| Function | Output |
|---|---|
Quote | Go syntax; non-printable chars escaped |
QuoteToASCII | Same, but all non-ASCII runes escaped too |
QuoteToGraphic | Quotes only non-printable runes (keeps printable Unicode) |
QuoteRune | Single-rune version with ' quotes |
AppendQuote | Appends to []byte |
CanBackquote(s) reports whether s can be written as a raw string literal (`like this`). It's true when s contains no backquote and no non-printable characters except \t.
8. NumError and structured parse errors¶
Every Parse* failure returns a *strconv.NumError:
type NumError struct {
Func string // "ParseFloat", "ParseInt", "ParseUint"
Num string // the input
Err error // strconv.ErrSyntax or strconv.ErrRange
}
func (e *NumError) Error() string
func (e *NumError) Unwrap() error
Use errors.Is to distinguish:
_, err := strconv.ParseInt(s, 10, 64)
switch {
case errors.Is(err, strconv.ErrSyntax):
// s isn't a number at all
case errors.Is(err, strconv.ErrRange):
// s is a number but out of range
case err != nil:
// shouldn't happen, but defensive
}
NumError.Unwrap() lets errors.Is traverse the chain.
NumError.Num is the offending input, useful for error messages — but redact it if the input is sensitive (password, token, PII): the default Error() includes it.
9. Parsing optional bool, int, float¶
Web APIs often have optional fields. Parse* requires a value:
b, err := strconv.ParseBool(query.Get("verbose"))
// If "verbose" is absent, query.Get returns "", and ParseBool fails.
Wrap with a default-aware helper:
func parseBoolDefault(s string, def bool) bool {
if s == "" {
return def
}
v, err := strconv.ParseBool(s)
if err != nil {
return def
}
return v
}
ParseBool accepts: "1", "t", "T", "TRUE", "true", "True" for true; "0", "f", "F", "FALSE", "false", "False" for false. It does NOT accept "yes", "on", "y", "enabled", etc. Build a custom helper if your input has those.
10. Batch parsing with pre-allocated slice¶
For converting a slice of strings to ints:
Before¶
func parseInts(parts []string) ([]int, error) {
var out []int
for _, p := range parts {
v, err := strconv.Atoi(p)
if err != nil { return nil, err }
out = append(out, v)
}
return out, nil
}
Each append may grow the slice; up to log2(N) reallocations.
After¶
func parseInts(parts []string) ([]int, error) {
out := make([]int, len(parts))
for i, p := range parts {
v, err := strconv.Atoi(p)
if err != nil { return nil, &numErrAt{i, err} }
out[i] = v
}
return out, nil
}
One allocation. The &numErrAt wrapper carries the index that failed — far more useful than the string alone.
11. strconv.IntSize and platform-dependent ints¶
strconv.IntSize is 32 or 64 depending on the build target's word size. When you accept the size-zero parameter to ParseInt(s, base, 0), the implementation falls back to IntSize.
Avoid this in cross-platform code. Always specify 32 or 64 explicitly — your code's behavior should not depend on whether the binary is built for amd64 or arm32.
12. Choosing between strconv, fmt, and encoding/json¶
For a single number → string, all three work:
Relative speed (approximate):
| Function | ns/op |
|---|---|
strconv.Itoa | 30 |
strconv.AppendInt(buf, 42, 10) | 12 |
fmt.Sprintf("%d", 42) | 150 |
json.Marshal(42) | 400 |
For one call per second the differences are invisible. For 100k+ calls per second they matter.
fmt shines when you need formatting flags (padding, width, precision combinations). strconv shines when you need raw number-to-string with no format string overhead. encoding/json adds type-driven dispatch and quoting on top.
13. Floats: format codes that matter¶
FormatFloat(f, fmt, prec, bitSize) takes a format byte:
| Code | Output for 3.14159 with prec=2 |
|---|---|
'f' | "3.14" (fixed-point) |
'e' | "3.14e+00" (scientific lowercase) |
'E' | "3.14E+00" (scientific uppercase) |
'g' | "3.1" (shortest of 'e'/'f') |
'G' | "3.1" (uppercase variant of 'g') |
'b' | "7074029114692207p-51" (binary exponent, no rounding) |
'x' | "0x1.91eb86p+1" (hex float, Go 1.12+) |
'X' | "0X1.91EB86P+1" |
The prec parameter:
- For
'f','e','E': digits after the decimal point. - For
'g','G': total significant digits. - For
'b','x','X': ignored. -1means "shortest representation that round-trips throughParseFloat".
The 'g' format with prec=-1 is what fmt.Sprintf("%v", f) produces. It's the most compact human-readable form and the only one with the round-trip guarantee.
14. The "round-trip" guarantee¶
f := 1.0 / 3.0
s := strconv.FormatFloat(f, 'g', -1, 64)
f2, _ := strconv.ParseFloat(s, 64)
// f == f2 // ALWAYS true
This guarantee is enforced by the Ryū algorithm (Go 1.12+): the shortest decimal string that, when parsed back, produces the exact same float64 bit pattern. This is what makes JSON serialization of floats lossless across Go ↔ Go.
It is NOT true for arbitrary prec values:
s := strconv.FormatFloat(0.1, 'f', 2, 64) // "0.10"
f2, _ := strconv.ParseFloat(s, 64)
// f2 != 0.1 (0.10 has a different bit pattern)
For data interchange, use prec=-1 unless you have a fixed-format requirement.
15. CSV parsing example¶
Parsing a single CSV row of numbers:
func parseRow(row string) ([]float64, error) {
var out []float64
for row != "" {
field, rest, _ := strings.Cut(row, ",")
v, err := strconv.ParseFloat(field, 64)
if err != nil {
return nil, fmt.Errorf("parse %q: %w", field, err)
}
out = append(out, v)
row = rest
}
return out, nil
}
For real CSV with quoting, use encoding/csv. The strconv primitive shines for known-good numeric data (output of another Go program, internal protocols).
16. Common middle-tier mistakes¶
16.1 Ignoring the parse error¶
Atoi returns 0 with the error; if you ignore the error, you get 0 silently. Always check or wrap.
16.2 Wrong bitSize¶
Pass 32 to ParseInt to let it validate.
16.3 FormatFloat with wrong precision¶
Use prec=-1 for round-trippable output.
16.4 Quote confused with JSON encoding¶
Quote produces Go syntax, not JSON. For JSON-escaped strings, use encoding/json.
16.5 Forgetting that ParseBool accepts "1" and "0"¶
// Surprising:
strconv.ParseBool("1") // true, nil
strconv.ParseBool("2") // false, error
strconv.ParseBool("on") // false, error
If your protocol uses "1/0" booleans, this is the right tool. If it uses "yes/no", build your own helper.
17. Where to go next¶
The senior file covers:
- How
ParseFloatactually implements Ryū / Grisu3. - The bit layout of
NumErrorand where allocations come from. - The assembly-level fast paths for small integers.
- Escape analysis of
ItoavsFormatIntvsAppendInt.
The professional file is "running this in production at scale": zero-allocation parsing pipelines, profiling techniques, and the build tools that catch Sprintf regressions.