Signed vs Unsigned Sizes
- title
- Signed vs Unsigned Sizes
- type
- concept
- summary
- The language-design choice between signed and unsigned size types โ the bug classes each enables, and the languages that picked each side
- tags
- language-design, type-systems
- created
- 2026-05-03
- updated
- 2026-05-03
Whether a language's default integer type for sizes, lengths, and indices is signed or unsigned. C made it unsigned (size_t) and most successors copied; Java dropped unsigned types entirely; Go picked signed; C3 flipped from unsigned to signed in 2026 after five years on the unsigned default.
The decision propagates through the type system: unsigned sizes mean unsigned indexing, which means unsigned arithmetic in any code that touches array lengths, which means signed-unsigned mixing everywhere, which forces the language into either implicit conversion (silent bugs) or pervasive casting (cast-spam culture).
Bug classes the unsigned default enables
- Reverse-counting infinite loop.
for (uint x = 10; x >= 0; x--)โ the comparison can never be false. The pattern is so common that C3 special-cased>= 0as a compile error for unsigned types. - Signed-unsigned comparison flip.
uint a = 0; int b = -1; if (a > b)โ C promotes to unsigned, sobbecomes a huge positive number and the comparison is wrong. Languages that kept implicit conversion need an explicit "safe-comparison" rule to fix this. - Plausible-looking overflow. Signed overflow produces an invalid-looking negative number; unsigned overflow produces a plausible-looking positive number that's just wrong. The unsigned case survives casual testing.
- Ring-buffer wrap.
((start - offset_back) % length + length) % lengthโ the natural negative-offset wrap pattern is silently broken with unsigned values, and no type-system rule can flag it. The correct unsigned form is uglier and less natural. - Modulo and division asymmetry.
(foo + a) % 2gives one answer whenfoois signed and a different one when it's promoted to unsigned. Even with safe-conversion rules,/and%expose the underlying signedness in a way+,-,*don't.
Bug classes the signed default enables
- Negative array index attempts. Caught at runtime as bounds-check failures; the language can't catch them statically without a richer type system.
- Off-by-one near zero. Possible, but the failure mode is usually a clean negative number rather than a wrap-around to a billion.
- Theoretical range loss. Half the representable range goes to negative values you mostly don't use. On 64-bit, you exhaust memory long before reaching 2^63 โ so the lost range doesn't bite. On 32-bit, indexing past 2 GB is rare enough that languages like Go just accept it.
Who picked what
| Language | Sizes | Notes |
|---|---|---|
| C | unsigned (size_t) |
The choice that propagated |
| C++ | unsigned | inherited from C; signed indexing has been argued for by Stroustrup and Sutter |
| Rust | unsigned (usize) |
overflow is a runtime error, not wrap |
| Zig | unsigned (usize) |
same convention as Rust |
| Java | only signed | no unsigned types at all |
| Go | signed (int) |
by the team that knew C++'s pain firsthand |
| C3 | signed (sz) since 0.8.0 |
switched in 2026 after 5 years on unsigned |
The cast-spam vs minimize-casts dichotomy
A separate but linked design choice: when signed and unsigned types meet, does the language insert silent conversions or require explicit casts?
- Cast-spam approach. Casts are cheap to write, "they document the conversion." Downside: a cast that was correct when written becomes silently truncating when an upstream type changes. Casts become "silence all warnings."
- Minimize-casts approach. Casts mean "here be dragons." Cleaner in principle, harder to define rules for. C3 took this side, which forced the unsigned-sizes problem to bite โ because unsigned sizes mean signed-unsigned conversion is constantly needed, and a minimize-casts language has to make most of those conversions implicit.
The Rust experience is illustrative: unsigned sizes plus an "explicit conversions everywhere" stance produces a codebase full of as usize and as i64 calls. Mechanically inserted, they accumulate, and they don't actually catch the bugs they were supposed to document.
The "unsigned encodes a precondition" argument
The intuition behind unsigned-by-default is that "this value can't be negative" is a useful precondition โ making the type unsigned encodes it in the type system. This argument doesn't survive contact with verification tools: unsigned types encode "this wraps modulo 2^N," not "this is in [0, N)." The precondition was always an informal convention, and the type-system "guarantee" it provides is the wrong guarantee.
The internalized-cost observation
The most interesting non-technical claim from the C3 retrospective is that experienced C/C++ programmers stop seeing the cost of unsigned-everywhere โ they pay the cognitive overhead automatically and forget it's there. This is the laziness-as-virtue argument applied in reverse: the discipline that compensates for a tool's defect becomes invisible, and removing the defect feels like removing the discipline.
See also
- unsigned-sizes-c3-mistake โ the canonical worked example of this concept
- c3-lang โ current language case study
- zig, zig-functional-programmers โ Zig kept unsigned sizes; useful contrast
- type-systems-vocabulary โ adjacent debate about type-system terminology
- no-silver-bullet โ language-design tradeoffs in general