skip to content

Why does strconv.FormatFloat with precision -1 round-trip exactly through ParseFloat?

level: middleimportance: must knowfreq 48%

answer

  1. minus one is not a digit count
  2. the fewest digits nobody else shares
  3. the parser rounds to the nearest float
  4. one string, one possible float64
  5. the last argument names the population

basics

~20 s

Precision -1 makes strconv.FormatFloat emit the fewest digits that no other float64 shares, so strconv.ParseFloat reads the string back as the identical value. Any fixed precision can print a string that parses back as a different number.

solid answer

~50 s

`strconv.FormatFloat(f, 'g', -1, 64)` asks for the smallest number of digits that uniquely identifies `f` among all `float64` values. Because no neighbouring float shares that decimal string, `strconv.ParseFloat(s, 64)` — which rounds correctly to the nearest float64 — must land back on the same bit pattern. The guarantee holds for every finite value and for the infinities; it is the reason `%v` output can be re-read safely. A fixed precision has no such property: `%.2f` of two distinct floats can produce the same string, and `%.17f` can produce a string whose nearest float is a different value than you started with for very small or very large magnitudes. The `bitSize` argument picks which set of values must be distinguished: pass 32 for a value that came from a `float32`, so the shortest string round-trips as a `float32`.

code

go · 12 lines
go
a, b := 0.1, 0.2
f := a + b

s := strconv.FormatFloat(f, 'g', -1, 64)
// s == "0.30000000000000004"
back, _ := strconv.ParseFloat(s, 64)
// back == f

lossy := strconv.FormatFloat(f, 'f', 2, 64)
// lossy == "0.30"
other, _ := strconv.ParseFloat(lossy, 64)
// other != f

go deeper

for a junior

Know that strconv.FormatFloat takes a precision argument and that passing -1 means 'as many digits as needed and no more', and that this is what fmt.Println already does for floats.

for a middle

Explain why shortest-unique implies an exact round trip: the string falls inside one float's rounding interval, and ParseFloat rounds to nearest. Be able to say what the bitSize argument selects and why 32 gives a shorter string.

for a senior

Demonstrate the judgment about output contracts: choose shortest-unique for anything a program re-reads, choose a fixed scale only where the scale is the defined contract, and know that NaN needs math.IsNaN in the verification test.

for a principal

Own the rule across services: which numeric fields are reconstructible by contract, where rounding is allowed to happen exactly once, and how a change of output precision is treated — as a breaking schema change rather than a formatting tweak.

## The signature and the sentinel ``` func FormatFloat(f float64, fmt byte, prec, bitSize int) string func ParseFloat(s string, bitSize int) (float64, error) ``` `prec` is normally a digit count — places after the decimal point for `'f'` and `'e'`, significant digits for `'g'`. The special value **-1** means something categorically different: *use the smallest number of digits necessary to represent the value uniquely*. ## Why "uniquely" implies a round trip A `float64` is one of a finite set of binary values. Each one owns an interval of real numbers that round to it. "Shortest unique" means: emit the shortest decimal string that falls inside this value's interval and inside no neighbour's. `ParseFloat` is a correctly-rounded parser — it returns the float64 nearest to the decimal string. Feed it a string that lies in exactly one float's interval and it must return that float. So for every finite `f`: ``` back, err := strconv.ParseFloat(strconv.FormatFloat(f, 'g', -1, 64), 64) // err == nil and back == f, bit for bit ``` This is a *guarantee*, not a statistical property, and it is why the shortest form is the right choice for any text that a program will read back: JSON fixtures, CSV exports, log lines a parser consumes, golden files in tests. ## Why a fixed precision cannot promise this A fixed precision is a lossy projection. Two different float64 values can format to the same `%.2f` string, and once they do, no parser can recover which one you had. Going the other way, printing *more* digits than necessary is harmless for correctness but not a guarantee you can reason about by eye: 17 significant digits are always enough for float64, but `%.17f` counts places *after the point*, not significant digits, so it prints garbage precision for large magnitudes and nothing at all for tiny ones. `%.17g` does round-trip, at the cost of ugly output like `0.10000000000000001`. Shortest-unique gives you `0.1` and the same guarantee. ## bitSize is about which neighbours matter The last argument to `FormatFloat` is **not** a request to convert the value. `f` is always a `float64`. `bitSize` says which population the string must be unique within: - `64` — unique among float64 values. - `32` — unique among float32 values, i.e. the caller promises `f` was obtained by converting a `float32`, and the shorter string is fine because float32 neighbours are further apart. So a value that came from a `float32` should be formatted with `bitSize` 32: `float64(float32(0.1))` formatted at 64 gives `0.10000000149011612`, formatted at 32 gives `0.1`. Both are correct for their population; using 64 on a float32-derived value prints noise digits that were never measured. `ParseFloat`'s `bitSize` is symmetric: with 32 it rounds to float32 precision and returns the result widened to `float64`, so `float32(v)` is exact. The round-trip pair for float32 data is `ParseFloat(FormatFloat(f, 'g', -1, 32), 32)`. ## Format byte still matters for the shape Precision -1 works with `'f'`, `'e'` and `'g'` alike — it controls how many digits, not which layout. `'g'` is the usual choice because it also picks a compact layout. `'f'` with -1 keeps plain decimal notation and still round-trips, which is handy when a downstream parser dislikes exponents. There is also `'x'`, hexadecimal floating point (`0x1.999999999999ap-04`), which is exact by construction and independent of decimal rounding entirely — useful in tests and bug reports. ## The values that need special handling The infinities format as `+Inf` and `-Inf` and parse back to the same infinities, so they round-trip fine. Negative zero formats as `-0` and parses back with the sign bit set. **NaN is the exception you must code around**: it formats as `NaN` and parses back to a NaN, but `NaN != NaN`, so a test written as `if back != f { fail }` reports a false failure on every NaN. Compare with `math.IsNaN` on both sides instead. Comparing raw bits with `math.Float64bits` is also not right for NaN, because the parsed NaN need not carry the original payload bits. ## Where this shows up in real code `fmt`'s `%v` on a float is `%g` at default precision, which is the same shortest-unique rule — so `fmt.Sprint(f)` is already round-trippable, and `encoding/json` marshals float64 the same way. The moment you reach for `%.2f` or `%.6f` in a path that another program parses, you have chosen a fixed decimal contract, and it is worth being deliberate about it: fine for a currency column whose scale is defined, wrong for a measurement you intend to reconstruct. ## Cost Shortest-unique formatting is not free — it does real work to find the minimal digit string — but it is a well-optimised path in `strconv` and a rounding error you cannot debug costs far more. If a hot loop formats millions of floats, `strconv.AppendFloat` into a reused byte slice avoids the allocation of a fresh string per value.

  • What does the bitSize argument to strconv.FormatFloat change?
    Which set of values the string must be unique within, not the type of the input — `f` is always a `float64`. Pass 32 for a value that came from a `float32` and you get `0.1` instead of `0.10000000149011612`, because float32 neighbours are further apart and fewer digits separate them.
  • Does a round trip through strconv.FormatFloat with precision -1 work for NaN and the infinities?
    Textually yes: they format as `NaN`, `+Inf` and `-Inf`, and `strconv.ParseFloat` accepts all three. The infinities compare equal after the trip. NaN does not — `NaN != NaN` — so a test must branch on `math.IsNaN` rather than compare with `==` or with raw bits.
  • When is a fixed precision the right choice despite being lossy?
    When the fixed scale is the contract rather than an accident: a currency column defined to two decimals, a report a human reads, a schema that states its precision. The rule is to round once, at the source, so the stored value already is the value you print — not to round on the way out of a value you still consider authoritative.

saying these in an interview costs you the question

  • Thinks precision -1 means the platform default digit count
  • Believes %.17f round-trips any float64
  • Says bitSize 32 converts the value to a float32
  • Assumes any fixed precision is safe if it is large enough
  • Compares a round-tripped NaN with == and calls it a bug in strconv