skip to content

In Go, what does uint8(n) give when n is 300, and why is that dangerous in a parser?

level: seniorimportance: nice to knowfreq 38%

answer

  1. the conversion never fails
  2. keeps the low bits, drops the rest
  3. 300 in one byte is not 255
  4. only literals are checked
  5. validate before you narrow, not after

basics

~20 s

It gives 44. A Go conversion between integer types never fails: it keeps the low bits and discards the rest. In a parser a hostile large value silently becomes a small plausible one that passes validation.

solid answer

~50 s

A numeric conversion in Go is a reinterpretation, not a checked cast: `uint8(n)` for an `int` variable holding 300 keeps the low eight bits and yields 44, and `int8(n)` for 200 yields -56. There is no error, no panic, and no vet diagnostic — only *constant* conversions such as the literal `uint8(300)` are rejected at compile time. In a parser reading untrusted input that turns a range check into a lie: a CIDR field of `/264` truncates to `/8`, and the scanner that was meant to walk 256 addresses walks sixteen million. The fix is to validate before narrowing — bound-check against `math.MaxUint8`, or parse at the target width with `strconv.ParseUint(s, 10, 8)`, which returns an out-of-range error instead of truncating. To find existing cases, write a fuzz target with `testing.F` asserting an invariant on the parsed value; boundary inputs like 256 surface within seconds.

code

go · 8 lines
go
bits, err := strconv.Atoi(field) // field = "264"
if err != nil {
	return err
}
prefix := uint8(bits) // silently 8
if prefix > 32 {
	return errBadPrefix // never reached
}

go deeper

for a junior

Recall the core fact: converting between integer types keeps the low bits and never reports a problem, so uint8 of 300 is 44 and not an error.

for a middle

Explain the bit-level result for both signedness directions and the constant-versus-variable asymmetry — why the literal form is a compile error while the variable form compiles silently.

for a senior

Show where the check belongs relative to the conversion, name the parsing functions that carry a width, and describe how you would hunt for existing cases with a fuzz target and boundary-value tests rather than hoping a linter finds them.

for a principal

Own the policy for parsing untrusted input across services: which types cross the boundary, whether decoding is hand-rolled or generated, and how you fund a sweep of existing parsers once one such truncation has been found.

## What a Go conversion actually does `T(x)` between integer types is defined as taking the value and, if the destination is narrower, discarding the high bits; the remaining bits are then interpreted according to the destination's signedness. It is not a cast that can fail. It does not clamp. It does not report anything. ```go n := 300 fmt.Println(uint8(n)) // 44 (300 - 256) m := 200 fmt.Println(int8(m)) // -56 (0xC8 read as signed) var big int64 = 1 << 33 fmt.Println(int32(big)) // 0 (every surviving bit is zero) ``` The last one is the nastiest: the truncated value is not merely wrong, it is *innocuous*. Zero, or 44, or 8 — small numbers that pass every sanity check written downstream. ## The constant exception, and why it hides the bug Go does check conversions of untyped constants. `uint8(300)` written literally is a compile error, because the constant must be representable in the destination type. So the naive form of this mistake never gets committed — the compiler catches it the first time. What gets committed is the same conversion applied to a *variable*, and that is exactly the case where the value came from outside the process. The compiler is silent, `go vet` has no narrowing-conversion analyzer (its `shift` check will flag a shift wider than the type, but nothing watches conversions), and the code reads as if a check has happened because a type name appears. ## The parser scenario Consider a scanner that walks CIDR ranges supplied by a caller, and parses `address/bits` by hand: ```go bits, err := strconv.Atoi(field) // field = "264" if err != nil { return err } prefix := uint8(bits) // 264 becomes 8 if prefix > 32 { return errBadPrefix } ``` The validation is there. It is simply validating the wrong number. `264` passes as `8`, and the range that should have been rejected expands to sixteen million addresses — a scan the operator never asked for, aimed at hosts they may not own. Every variation of this shape exists in the wild: a length prefix truncated so a bounds check passes while the real body is longer, a count truncated to zero so a loop never runs, a port number truncated into a different service. Note the ordering error at the heart of it: the code converts and *then* validates. Once the conversion has happened the evidence is gone. ## Getting it right **Validate in the wide type, before narrowing.** ```go if bits < 0 || bits > 32 { return errBadPrefix } prefix := uint8(bits) ``` **Or parse directly at the destination width.** `strconv.ParseUint(s, 10, 8)` returns a `uint64` but rejects anything that would not fit in eight bits with an `ErrRange` error, so the range check and the parse are the same step and cannot drift apart. `strconv.ParseInt(s, 10, 16)` does the same for signed 16-bit fields. **Or do not narrow at all.** Carry the wide type end to end and let the struct field be `int` unless a fixed width is genuinely part of the format. The narrowing then happens once, at the encoder, where the value is already known to be in range. **Compare against the named limits** — `math.MaxUint8`, `math.MaxInt32`, `math.MaxUint32` — rather than open-coded magic numbers, so the check is obviously tied to the destination type. ## Finding the ones already in the tree This is where fuzzing earns its keep, because the failing inputs are boundary values that a hand-written table test almost never contains — 255, 256, 257, 65535, 65536, and the negative mirrors. ```go func FuzzParsePrefix(f *testing.F) { f.Add("10.0.0.0/8") f.Fuzz(func(t *testing.T, s string) { p, err := parsePrefix(s) if err == nil && p.bits > 32 { t.Fatalf("accepted %q as /%d", s, p.bits) } }) } ``` Run with `go test -fuzz=FuzzParsePrefix`. The invariant is the important part: assert something that must hold for every accepted input — a parsed length never exceeds the input length, a re-encode round-trips, an accepted prefix is within range. A fuzz target with no invariant only finds panics, and truncation does not panic. Failing inputs are written into `testdata/fuzz/`, so the corpus becomes a permanent regression test. The `-race` detector is no help here (it finds data races, not value errors), and `go build -gcflags=-m` reports escape analysis, not conversions. ## The reviewer's heuristic When reviewing a parser for hostile input, grep for narrowing conversions and ask one question at each: *where is the check, and is it before or after this line?* A conversion applied to anything derived from the network, a file, a header or a query parameter is a validation site until proven otherwise. And prefer parsing functions that carry the width — `strconv.ParseUint(s, 10, 8)` over `strconv.Atoi` plus a cast — so the check cannot be forgotten in the first place.

  • Why does the compiler reject uint8(300) but accept uint8(n) where n holds 300?
    300 written literally is an untyped constant, evaluated at compile time with arbitrary precision, and the spec requires a converted constant to be representable in the destination type — so it is an error. A variable's value is not known at compile time, so the conversion becomes a machine instruction that keeps the low bits. That asymmetry is precisely why the dangerous form is the one that survives to production.
  • Would go vet or the race detector catch this?
    Neither. go vet ships no narrowing-conversion analyzer — its shift check flags a shift wider than the operand's type, which is a different mistake — and the race detector reports concurrent unsynchronised access, not wrong values. The tools that do work are a fuzz target with an invariant, boundary-value table tests around 255, 256, 65535 and 65536, and code review focused on where the range check sits relative to the conversion.
  • What invariant would you assert in the fuzz target?
    Something that must hold for every input the parser accepts, not merely absence of panic. For a length prefix: the parsed length never exceeds the remaining input. For a CIDR prefix: an accepted value is within 0..32. For a record: re-encoding the parsed form reproduces the accepted bytes. Without such an assertion the fuzzer only finds crashes, and a silent truncation does not crash.

It is pouring a litre into a 250ml measure. Nothing objects, nothing overflows onto the bench in a way you will notice later — you are simply left holding a quarter of a litre and a reading that looks reasonable.

saying these in an interview costs you the question

  • Expects a Go conversion to panic or return an error when the value does not fit
  • Thinks a narrowing conversion saturates at the destination's maximum
  • Believes go vet or the compiler flags narrowing conversions on variables
  • Validates the value after converting rather than before
  • Assumes converting from int64 to int is always lossless