skip to content

Why can strconv.ParseInt return a huge value together with a non-nil error?

level: seniorimportance: nice to knowfreq 31%

answer

  1. the error is not the only output
  2. valid digits, impossible value
  3. two sentinels, two different numbers back
  4. saturated, never truncated or wrapped
  5. the clamp is what makes it dangerous

basics

~20 s

Because an out-of-range input still returns a number: strconv.ParseInt hands back the largest value of the requested width and sign along with a *strconv.NumError whose Err field is strconv.ErrRange. Malformed text instead returns zero with ErrSyntax.

solid answer

~50 s

`strconv` reports two distinct failures through the `Err` field of a `*strconv.NumError`. `strconv.ErrSyntax` means the text was not a number at all, and the numeric result is 0. `strconv.ErrRange` means the digits were valid but the value does not fit the requested width, and the numeric result is the **clamped** maximum-magnitude value for that width and sign, so `ParseInt("99999999999999999999", 10, 64)` returns 9223372036854775807 with an error. That asymmetry is what makes a discarded error dangerous in a configuration loader: one class of typo gives you a limit of 0, the other gives you a limit of maxint64, and neither looks like a parse failure downstream. The `NumError` also carries `Func` and `Num`, and its message already quotes the offending input, so logging the error plus the setting's name is enough to diagnose it from a startup log.

code

go · 5 lines
go
v, err := strconv.ParseInt("99999999999999999999", 10, 64)
// v is 9223372036854775807, not 0
// err is a *strconv.NumError with Err == strconv.ErrRange
fmt.Println(v, err)
// 9223372036854775807 strconv.ParseInt: parsing "99999999999999999999": value out of range

go deeper

for a junior

The takeaway to remember is simple: a non-nil error does not always mean the number is zero. Check the error first and never use the value when it is set, whatever the value looks like.

for a middle

Explain the two sentinels and the results that go with them: ErrSyntax with a zero value, ErrRange with the clamped maximum for the requested width. Name the NumError fields Func, Num and Err.

for a senior

Show the diagnosis. Describe how an ignored range error puts maxint64 into a limit, why that is worse than a zero, and how a startup log line listing every resolved setting with its source turns a multi-hour investigation into a one-line read.

for a principal

Own the policy: whether bad configuration aborts startup or falls back, who is allowed to define a default, and what the standard startup log looks like across services so that any on-call engineer can confirm what a process actually resolved without reading its code.

## Two failures, two very different results Every parsing function in `strconv` fails through the same type: ```go type NumError struct { Func string // "ParseInt", "Atoi", "ParseBool", ... Num string // the input that failed Err error // ErrSyntax or ErrRange } ``` `Err` is one of exactly two package-level sentinels, and which one you got determines what the *numeric* result means: - **`strconv.ErrSyntax`** - the string was not a number in the requested base. The value returned is 0. - **`strconv.ErrRange`** - the string was a perfectly good number, but too large or too small for the requested `bitSize`. The value returned is the maximum-magnitude value of that width and sign: `math.MaxInt64` for a 64-bit positive overflow, `math.MinInt64` for a negative one, `127` for `bitSize` 8, `65535` for `ParseUint` at 16. So the answer to "why is the value huge when the error is non-nil" is that `strconv` deliberately gives you something useful in the overflow case: the closest representable value, saturated rather than truncated or zeroed. It never wraps around the way an integer conversion would. ## Why this is a production problem, not a trivia question Put it in the code that reads the environment at process start. Two deployment typos in the same variable: ``` MAX_BYTES=1e9 -> ErrSyntax -> value 0 MAX_BYTES=99999999999999999999 -> ErrRange -> value 9223372036854775807 ``` If the loader discards the error - and a loader written by someone porting from a language where a bad parse throws almost always does, because there is nothing to catch - the first typo makes the service reject everything, or, if the guard is written `if maxBytes > 0`, silently disables the limit. The second removes the limit outright while looking like a deliberately generous configuration. Neither logs anything. The symptom arrives hours later as a memory blow-up or as a service that refuses all work, and the parse is nowhere near the stack you are looking at. The pair of defences is cheap: 1. **Never discard the error.** If a setting cannot be parsed, fail startup. A process that refuses to start is a five-minute incident; a process that starts with maxint64 is a long one. 2. **Log every resolved setting with its source at startup.** One line per setting - name, resolved value, where it came from - printed before the server accepts traffic. That line is what turns "the service is behaving oddly" into "look, the limit says 9223372036854775807". ## Telling the two apart in code The sentinels are exported precisely so you can distinguish them, and `NumError` implements `Unwrap`, returning its `Err` field, so `errors.Is(err, strconv.ErrRange)` works through any wrapping you have added with `fmt.Errorf("%w", ...)`. The distinction is worth making when the two deserve different treatment - for example, a range failure on a user-supplied page size might reasonably clamp to a documented maximum, while a syntax failure is a 400. Most of the time, though, the better move is not to classify at all: pick a `bitSize` that matches the domain so the range check is the validation, and treat every parse failure as a rejected input. ## The message is already good `NumError.Error()` renders as: ``` strconv.ParseInt: parsing "99999999999999999999": value out of range ``` The function name is there, the input is there and already quoted with `strconv.Quote`, and the cause is spelled out. You do not need to append the raw string again - doing so produces log lines with the value in twice. What the message does *not* know is which setting it came from, so that is the one thing your wrapper should add: `fmt.Errorf("MAX_BYTES=%q: %w", v, err)` is one duplicate too many, while `fmt.Errorf("MAX_BYTES: %w", err)` reads exactly right. ## One inconsistency worth knowing Not everything in the package returns a `NumError`. `strconv.Unquote`, which takes a quoted Go string literal such as `"\"a b\""` and returns its value, reports a malformed literal by returning `strconv.ErrSyntax` **directly**, with no `NumError` wrapper - there is no number to report. Code that type-asserts to `*strconv.NumError` to build an error message will silently miss that case. This matters in the same configuration loader, where quoted environment values are unwrapped with `Unquote` and numbers with `ParseInt`. ## What a strong answer sounds like Name the type and its three fields, name both sentinels, and then say the operational consequence in one sentence: an ignored `ErrRange` does not give you zero, it gives you the maximum, which is the more dangerous of the two because it looks intentional. Candidates who have actually debugged this reach for the startup log line without being prompted.

  • Why is the clamped value more dangerous than a zero in a configuration loader?
    A zero usually breaks something immediately and visibly, so someone notices during the first request. A saturated maxint64 looks like a deliberately generous limit, so the guard is effectively removed and nothing complains until the resource it was protecting runs out. Both come from the same ignored error.
  • Does strconv.Unquote also return a *strconv.NumError?
    No. `Unquote` takes a single-quoted, double-quoted or backquoted Go string literal and returns `strconv.ErrSyntax` directly when the literal is malformed, because there is no number to report. Code that type-asserts to `*strconv.NumError` to extract the offending input will miss that path entirely.
  • What does NumError.Error() print, and should you add the input to your own message?
    It renders as `strconv.ParseInt: parsing "...": value out of range`, with the input already quoted via `strconv.Quote`. Repeating the raw string in your wrapper duplicates it in the log. Add the thing the error cannot know - which setting or field it came from - and wrap with `%w`.
  • How do you distinguish the two failures in code when they deserve different handling?
    `NumError` implements `Unwrap`, so the exported sentinels `strconv.ErrRange` and `strconv.ErrSyntax` can be compared against the returned error even after you wrap it. Often the better design is to avoid the question by choosing a bitSize that matches the domain, so the range check is the validation.

A range error is a scale that pegs at its limit. It still shows you a reading, and the reading is a real number, but it is the scale's maximum rather than the weight of the thing you put on it.

saying these in an interview costs you the question

  • Assumes the value is always zero when the error is non-nil
  • Thinks ErrRange means the string was malformed
  • Believes an overflow wraps around like an integer conversion
  • Says the out-of-range value is truncated to the low bits
  • Expects ParseInt to panic on a number too large to represent
  • Logs the parse error without naming which setting produced it