Floating-Point Formatting

Reference index · Summary of this topic

Go’s default rendering of a float — %v, %g, and the bare Println/Print/Sprint paths — is strconv.FormatFloat(f, 'g', -1, bitSize): the shortest decimal digits that round-trip back to the same float, laid out in 'e' form when the decimal exponent is below -4 or at/above 6, and 'f' form otherwise. The exponent is lowercase, always signed, and always at least two digits.

.NET’s default/"R" formatting also produces shortest-round-trip digits, but presents them differently: it flips to exponent form on its own thresholds and writes an unpadded, uppercase exponent. The two agree far more often than they disagree, which is what makes the disagreement easy to miss — the gap only opens at the ends:

fmt.Println(1000000.0)                   // Go: 1e+06        .NET default: 1000000
fmt.Println(1e-5)                        // Go: 1e-05        .NET default: 1E-05
fmt.Println(2.2250738585072014e-308)     // Go: …e-308       .NET default: …E-308
fmt.Println(999999.0)                    // Go: 999999       .NET default: 999999   (agree)

The 6 is the whole story for %v: it is why 1000000.0 prints as 1e+06 while 999999.0 prints in full, and it comes from strconv’s formatDigits, which pins the exponent threshold to a flat 6 whenever the digits were the shortest round-trip (if shortest { eprec = 6 }) rather than to the requested precision. The threshold is not 21 — that figure appears in an older reading of the rule and matches JavaScript’s Number.toString, not Go’s.

The conversion: the baseline stub fmt (src/core/fmt/format.cs) reproduces strconv’s layout rather than delegating to .NET’s presentation. It leans on an empirical equivalence, verified by differential fuzzing (below): .NET and Go produce the same digits. .NET’s "R" is the same shortest round-trip, and its "E<n>" rounds the exact binary value to n+1 significant digits with the same round-half-to-even that strconv’s shouldRoundUp applies — including denormals, and exactly (not zero-padded) well past 17 digits, so %.40e of 0.1 agrees digit-for-digit. Only the presentation differs. So FormatFloat takes .NET’s digits, reduces them to strconv’s decimalSlice shape (DecomposeDigits: significant digits, sign and trailing zeros stripped, scaled so the value is 0.<digits> × 10^dp), and lays them out through direct ports of strconv’s fmtE and fmtF. Fixed-point (%f with a precision) is the one case handed to .NET whole — "F<n>" already matches fmtF exactly, and it wants digits at a decimal place rather than a significant-digit count.

Verb defaults follow Go’s fmt: %v renders as %g, %F as %f; %e/%f default to a precision of 6 and %v/%g to the shortest round-trip; an explicit precision overrules either. float32 resolves its digits as a single (((float)value).ToString(…), never widened to double first), so float32(1.0/3.0) prints Go’s 0.33333334 and not the double’s 0.3333333333333333. Two Go quirks that a straight reading of the rule misses, both caught by the fuzz:

Verification. Beyond the fixed cases, a differential fuzz compared the stub against go run over random float64/float32 bit patterns (so NaNs, infinities and denormals arise naturally), precisions 0–20 across %g/%e/%f/%G/%E, and values clustered on the threshold where the form flips: ~50,000 formatted values over six seeds, all byte-identical. The %.0g promotion above was found this way — the hand-picked cases all passed without it.

Scope. This is the hand-written baseline stub fmt, the proxy the behavioral corpus builds against. The full conversion’s fmt calls the converted strconv, which is Go’s own digit code and needs none of this. (Guarded by the FloatFormatExponent behavioral test — both thresholds from either side, 1e20/1e21, 1000000.0, the math.MaxFloat64/SmallestNonzeroFloat64 values, float32 counterparts, negative zero, and every verb with and without precision, byte-compared against go run; ±Inf/NaN flag interactions live in PrintfWidthFlags. The extreme values are spelled as literals because the converted math package’s constants are themselves rendered lossily — MaxFloat64 as 1.79769e+308 — which is a separate converter defect, deliberately not conflated with this one.)

A folded constant of a NAMED type carries its type in the fold

overflowingConstLiteral materializes a compile-time integer constant whose value falls outside the C# int32 range, because C# would otherwise evaluate the operator expression in int32 and overflow (CS0220). It read the constant’s type through Underlying(), so a constant of a defined type folded to a bare basic literal and the Go type was simply lost:

d := 8 * time.Hour
secondsEastOfUTC := int((8 * time.Hour).Seconds())
var d = 28800000000000L;                            // a C# long, not a Duration
nint secondsEastOfUTC = (nint)(28800000000000L).Seconds();   // CS1929 — long has no Seconds

The compile error is the loud half; the silent half is d, which is now a long and prints as its digit count where a Duration prints 8h0m0s. The fold now carries the named type in the same parenthesized (T)(…) shape the native-int arm uses — (time.Duration)(28800000000000L) — which wholeExprIsCastOfType already recognizes, so enclosing paths do not re-wrap it. The [GoType] wrapper converts implicitly from its underlying, so the cast is always legal, and Go’s own parentheses around a method-call receiver keep the postfix .M() binding to the cast rather than to the literal. Only constants outside int32 reach this arm at all, so the corpus footprint is the handful of computed time.Duration-class constants above that magnitude. (Guarded by the PackageNameShadowing behavioral test, case 4.)


← Named Numeric Types and Constant Contexts · Index · Nil and Zero Values →