C's Flexible Integer Sizes were Not a Design Mistake

Word sizes, strange machines, and why int was never meant to mean 32 bits. C's flexible integer types were not a design mistake. They were how the language achieved portability in a world of 12, 18, 36, and 60-bit computers.

C integer sizes and portability

Here at the school, we work with a lot of retro machines and old game consoles, so it's no surprise that we are often the first point of contact for many developers learning the C programming language. Many of our students come come from languages like Python, JavaScript, Java, C#, Swift, and others.

It does not take long for beginners to realize that many aspects they took for granted in their primary language are not necessarily a given in C. Fairly often, we find ourselves asking questions such as:

  • How many bits does this variable occupy?
  • What is the padding in this structure?
  • Is this field properly aligned in memory?
Flexible-Size Integers

Another rite of passage for C beginners is to learn how to use and to make sense of the operator sizeof.

Observing the program output 4 when we ask for the sizeof(int) seems reasonable at first, since in most modern machines a signed integer occupies, indeed, four bytes in memory.

Unfortunately, I must also tell my students that, back when I first started learning how to program, if I asked for the sizeof(int), I'd see the machine output 2 bytes.

A common take in programming circles is that C's platform-dependent integer types were a design mistake. The argument goes like this: int might be 16 bits on one machine and 32 on another, long is 64 bits on Linux but 32 on 64-bit Windows, and all of this has caused decades of portability bugs. Today almost every CPU is a 64-bit machine that handles 8, 16, 32, and 64-bit arithmetic quickly. So why didn't C just fix the sizes from the start?

I think this take judges a 1970s design by 2020s conditions. C was built to be a portable systems programming language that could map efficiently onto very different computer architectures. The flexible type sizes weren't an oversight. They were how C achieved that portability.

To see why, you have to remember what "a computer" meant when C was designed.

The World Before 8-bit Bytes Won

Today we take a lot for granted. Almost every machine you'll touch has:

  • 8-bit bytes
  • Byte-addressable memory
  • 32 or 64-bit registers
  • Two's-complement integers
  • A flat, conventional memory model

None of this was guaranteed in the 1960s and 70s. The industry had not converged, and machine word sizes were all over the map:

Machine Word size Notes
DEC PDP-8 12 bits Hugely popular minicomputer
DEC PDP-7 18 bits Where UNIX was born, in assembly
DEC PDP-11 16 bits Byte-addressed; where C grew up
DEC PDP-10 / DECSYSTEM-20 36 bits Characters were often packed 7 or 9 bits at a time
Honeywell 6000 series 36 bits 9-bit characters; an early C target
UNIVAC 1100 / Unisys 2200 36 bits Ones'-complement arithmetic, 9-bit chars; still has a C compiler today
IBM 7090 / 7094 36 bits 6-bit character codes
SDS 940, ICL 1900, Harris 24 bits ICL used 6-bit characters
Burroughs B5000 family 48 bits Tagged, stack-oriented architecture
CDC 6600 60 bits 6-bit characters, no byte addressing at all
Cray-1 64 bits Word-addressed; in C, short, int, and long could all be 64 bits
Data General Nova 16 bits Word-addressed; byte pointers had a different representation than word pointers
Intel 8086 16 bits Segmented memory; near and far pointers

Characters weren't consistent either. There were 6-bit character sets, 7-bit ASCII, 9-bit bytes on 36-bit machines, and EBCDIC on IBM mainframes. Negative numbers could be stored as two's complement, ones' complement, or sign-magnitude. Some machines could address individual bytes. Others could only address whole words, so a "pointer to a character" had to be a word address plus an offset.

A language that wanted to be both efficient and portable across these machines couldn't assume much.

Where C's Types Came From

C's integer philosophy makes more sense once you look at its ancestors.

BCPL (Martin Richards, 1967) and B (Ken Thompson, around 1969) were typeless languages. There was only one kind of value: the machine word. A variable held a word. You could treat it as an integer, an address, or a bit pattern depending on the operator you applied. On the word-addressed machines these languages targeted, that was elegant and efficient.

Then Bell Labs got a PDP-11. The PDP-11 was byte-addressed, with 16-bit words, and was about to get floating-point hardware. Dennis Ritchie describes in The Development of the C Language (1993) how badly B's "everything is a word" model fit that machine. Handling characters was clumsy, pointers had to be scaled between word and byte addresses, and floating-point values didn't fit in a word.

C's type system was created to fix that mismatch. char gave you the byte. int kept the spirit of BCPL's word: the natural integer of the machine. That idea became part of the language. Even today, the C standard (C11 §6.2.5) says:

"A 'plain' int object has the natural size suggested by the architecture of the execution environment."

This is a deliberate design statement. int was never meant to be "32 bits". It meant "whatever this machine is fastest and most comfortable with".

C Escapes the PDP-11

The real test came in 1977 and 1978, when Ritchie and Steve Johnson ported UNIX and C to the Interdata 8/32, a 32-bit machine that was quite different from the PDP-11. They wrote about it in Portability of C Programs and the UNIX System (Bell System Technical Journal, 1978). Around the same time, Johnson's Portable C Compiler (pcc) made retargeting C to new architectures practical, and C spread to a wide range of hardware.

The first edition of The C Programming Language (Kernighan & Ritchie, 1978) includes a table I like to point people to. It lists the type sizes on four machines C already ran on:

Type DEC PDP-11 Honeywell 6000 IBM 370 Interdata 8/32
char 8 bits 9 bits 8 bits 8 bits
short 16 36 16 16
int 16 36 32 32
long 32 36 32 32
float 32 36 32 32
double 64 72 64 64

In 1978 C already ran on machines with 16-bit ints, 32-bit ints, and 36-bit ints, with 8-bit and 9-bit chars. The flexible sizes were there from the start, and they worked. The same language, and largely the same programs, ran natively and efficiently on all of these machines.

Why Flexible Sizes Were Useful

Suppose C had required int to be exactly 32 bits, two's complement, wrapping on overflow, the way many modern languages do. Here is what that would have cost.

1. On a 16-bit Machine (PDP-11, 8086)

Every int operation would need two machine instructions instead of one. Adding two 32-bit values on a PDP-11 means an ADD on the low words followed by an ADC (add with carry) on the high words. Comparisons, shifts, and multiplications all get worse. Every array index and every loop counter would cost twice the registers and twice the instructions.

int i;
for (i = 0; i < n; i++)
    total += table[i];

With a "natural" int, this loop compiles to single-register operations on a PDP-11, an IBM 370, a Honeywell 6000, or a Cray. With a mandated 32-bit int, the PDP-11 version is roughly twice as slow, and the PDP-11 was the machine C was written for.

2. On a 36-bit Machine (PDP-10, Honeywell, UNIVAC)

A fixed 32-bit int is arguably worse here, because the hardware is bigger than the type. To keep exact 32-bit wraparound, the compiler would have to mask after nearly every arithmetic operation:

/* what "a = a + b" would need to become for exact 32-bit semantics */
a = (a + b) & 0xFFFFFFFF;
/* ...plus manual sign-extension from bit 31 if a is signed */

That's extra instructions on every add, subtract, and multiply, and 4 bits wasted in every word. Letting int be 36 bits costs nothing.

3. On a Ones'-Complement Machine (UNIVAC 1100 / Unisys 2200, CDC 6600)

If the language required two's-complement wraparound, every signed operation on a ones'-complement machine would need software emulation. C avoided this by allowing all three signed representations (two's complement, ones' complement, sign-magnitude) and by making signed overflow undefined behavior.

Note: People today often read that undefined behavior as a gift to optimizers. It started as a way of not forcing one machine's overflow behavior onto every other machine.
4. On Machines Without 8-bit Bytes (CDC, PDP-10, Word-Addressed DSPs)

C requires char to have at least 8 bits (CHAR_BIT >= 8), not exactly 8. On a 36-bit machine, 9-bit chars pack four to a word with nothing left over. On a machine that can only address whole words, char can simply be the word.

This is also why C never promised that all pointers have the same representation. On the Data General Nova, a char * and an int * pointing to the same place held different bit patterns. C's rules on pointer conversion (and the need for void * and explicit casts) exist because of machines like that.

5. On Small 8-bit Micros (Z80, 6502, 8080)

C doesn't let int be as small as the machine's 8-bit registers. The standard sets a minimum range of -32767 to 32767, so int must be at least 16 bits. This shows the balance the design struck: the size adapts to the machine, but there is a floor so programs can rely on something.

The Design Pattern: Minimums, Not Exact Sizes

When ANSI standardized C in 1989, it wrote this philosophy down. You don't get exact sizes, you get guaranteed minimum ranges, exposed through <limits.h>:

Type Minimum guaranteed
char 8 bits (CHAR_BIT >= 8)
short 16 bits
int 16 bits
long 32 bits
long long (C99) 64 bits

A portable C program asks "what's the smallest type guaranteed to hold my range?", not "what type is exactly N bits?". If you need values up to 100,000, you use long, because long is guaranteed to hold them everywhere. If your values fit in ±32767, int is fine and will be the fastest choice on every machine.

The ANSI C Rationale summed up the "spirit of C" in a few principles, and two of them apply directly here:

"Trust the programmer."
"Make it fast, even if it is not guaranteed to be portable."

C tried to be portable and fast by letting each implementation map the language onto its hardware in the cheapest way possible. That's a trade-off, not a blunder.

A Case Study in Portable C: Lua

If you want to see this philosophy used well in modern code, read the Lua source. Lua runs on everything from 64-bit servers to microcontrollers and old, unusual toolchains. It's written in what its authors call "Clean C": the common subset of ANSI C (C89) and C++. For a long time it avoided depending on <stdint.h> entirely, and it still follows the older approach: ask the compiler what it has, using <limits.h>, and choose types based on guaranteed ranges.

Here are a few examples from Lua 5.4 (excerpts may differ slightly between versions).

Detecting Whether int is Big Enough, Without int32_t

In luaconf.h:

/*
@@ LUAI_IS32INT is true iff 'int' has (at least) 32 bits.
*/
#define LUAI_IS32INT	((UINT_MAX >> 30) >= 3)

This is pure C89. It doesn't ask "is int exactly 32 bits?", because on a 36-bit machine that would be the wrong question. It asks "does unsigned int hold at least 32 bits of range?". It works on 16-bit, 32-bit, 36-bit, and 64-bit ints alike.

VM Instructions: "At Least 4 Bytes", Not "Exactly 4 Bytes"

In llimits.h:

/*
** type for virtual-machine instructions;
** must be an unsigned with (at least) 4 bytes (see details in lopcodes.h)
*/
#if LUAI_IS32INT
typedef unsigned int l_uint32;
#else
typedef unsigned long l_uint32;
#endif

typedef l_uint32 Instruction;

The type is named l_uint32, but look at the comment: it only needs to be at least 32 bits. On a 16-bit-int machine it falls back to long, which the standard guarantees is at least 32 bits. The instruction decoder in lopcodes.h extracts fields with shifts and masks, so any extra high bits on wider machines are simply ignored.

Small Numbers and Memory Counts
/* chars used as small naturals (so that 'char' is reserved for characters) */
typedef unsigned char lu_byte;
typedef signed char ls_byte;

Lua uses unsigned char where a modern codebase would write uint8_t. That's correct everywhere, including on machines where a byte is 9 or 16 bits. uint8_t wouldn't even exist on those machines.

The memory accounting types follow the same pattern: size_t/ptrdiff_t when int is 32 bits or more, and unsigned long/long on 16-bit machines where size_t might be too small to count total memory usage.

Choosing lua_Integer From What the Platform Offers

Lua's integer type is configurable in luaconf.h (LUA_INT_INT, LUA_INT_LONG, LUA_INT_LONGLONG), with a LUA_32BITS option for small targets:

#if defined(LUA_32BITS)		/* { */
/*
** 32-bit integers and 'float'
*/
#if LUAI_IS32INT  /* use 'int' if big enough */
#define LUA_INT_TYPE	LUA_INT_INT
#else  /* otherwise use 'long' */
#define LUA_INT_TYPE	LUA_INT_LONG
#endif
#define LUA_FLOAT_TYPE	LUA_FLOAT_FLOAT

The default uses long long when the compiler provides it (checked via LLONG_MAX) and falls back to long for strict C89 compilers that don't.

Binary Serialization With CHAR_BIT

The most instructive example may be string.pack and string.unpack in lstrlib.c. They read and write binary integers byte by byte, and they don't assume a byte is 8 bits:

/* number of bits in a character */
#define NB	CHAR_BIT

/* mask for one character (NB 1's) */
#define MC	((1 << NB) - 1)

Integers are packed by repeatedly taking n & MC and shifting right by NB. Endianness is handled explicitly in the code instead of by casting a pointer and hoping. This is how you write binary I/O that works on any conforming C implementation.

"No fixed-size types" doesn't mean "no control over sizes". You express what you need (a range, a minimum width, a byte), check it with <limits.h>, and let the implementation choose the most efficient type that satisfies it.

This is the discipline C's design expected from programmers.

Being Fair: What Hurt

I don't want to pretend flexible sizes came free. Some pain points are real:

  • People didn't write code the way Lua does. Much C code assumed int was 16 bits (in the DOS era) or 32 bits (later), assumed sizeof(int) == sizeof(void *), or stored pointers in ints. Moving to 32-bit and then 64-bit machines broke that code. The language allowed portability but didn't enforce it.
  • The 64-bit data model split. Unix-like systems chose LP64 (long and pointers are 64 bits), while 64-bit Windows chose LLP64 (long stays 32 bits and only long long and pointers are 64). Code that used long as "the big integer" or "the pointer-sized integer" behaves differently on the two.
  • Standard fixed-width types arrived late. <stdint.h> only came with C99, so for two decades every project wrote its own u32/INT32 typedefs with #ifdef forests. That's exactly the "use ifdefs" workaround people suggest today, and the standard eventually made it official.

Even <stdint.h> kept C's original philosophy. The exact-width types (int8_t, uint32_t, etc.) are optional: an implementation provides them only if it actually has a type of that exact width with no padding bits and two's-complement representation. The mandatory types are int_least32_t ("the smallest type with at least 32 bits") and int_fast32_t ("the fastest type with at least 32 bits"). These are the ANSI minimum-range idea with clearer names. The committee still refused to pretend every machine has an 8-bit byte.

Note: This isn't just history. Many DSPs still in production today have a CHAR_BIT of 16 or 32 (some TI and Analog Devices parts, for example), and C compilers exist for the 36-bit, ones'-complement Unisys 2200 line. On those systems uint8_t doesn't exist, and code written like Lua's still compiles and runs.

How Modern Languages Differ (and Why They Can)

Languages designed from the 1990s onward had a different starting point. The architecture wars were over. The 8-bit byte, byte-addressable memory, and two's-complement arithmetic had effectively won. Designers could build those assumptions into the language:

  • Java (1995) fixes everything: byte is 8 bits, short 16, int 32, long 64, all two's complement with defined wraparound. Java could do this partly because it targets a virtual machine. The JVM makes the hardware look uniform, and the cost of emulation (if any) is the JVM's problem.
  • C# follows the same model on the .NET runtime.
  • Rust has no plain int. You write i8, u16, i32, u64, i128, and so on, with explicit widths. Overflow is defined: it panics in debug builds and wraps in release builds by default, with explicit methods (wrapping_add, checked_add, saturating_add) when you need a specific behavior.
  • Zig goes further, with arbitrary-width integers like u7, i3, or u48, which are useful for bitfields and hardware registers. It also provides c_int, c_long, etc. for C interop, because it knows C's types vary.
  • Go fixes int8 to int64, but its plain int and uint are 32 or 64 bits depending on the platform.
  • Swift does the same: Int is word-sized, alongside explicit Int8 to Int64.

The last three points are telling. Even modern languages kept the idea of a natural, platform-sized integer: Rust's isize/usize, Go's int, Swift's Int, Zig's usize. The concept didn't go away. It narrowed from "the machine's natural word" to "the size of a pointer", because indexing memory is where word size still matters.

C's core intuition turned out to be correct. Modern languages kept it and pinned down everything else.

Even C has followed the hardware's convergence. C23 finally requires two's-complement representation for signed integers (C++20 did the same). The committee could do this in the 2020s because the ones'-complement and sign-magnitude machines that justified the old rule had become extremely rare. That's the right order of events: the standard followed reality once reality had settled.

Conclusion

A design decision should be judged by the problem it was solving and the world it was made for. C's problem in the 1970s was to be a systems language that could replace assembly on machines with 12, 16, 18, 24, 32, 36, 48, 60, and 64-bit words, with 6, 7, 8, and 9-bit characters, and with three different ways of representing negative numbers, while still generating code nearly as good as hand-written assembly.

Fixed integer sizes would have made C slow or impractical on many of those machines. Leaving the sizes open, backed by guaranteed minimum ranges, made C run nearly everywhere. That's a big part of why UNIX could be ported, and a big part of why C became the language nearly all later systems software was built on.

If you know for sure you'll only target modern 64-bit, 8-bit-byte, two's-complement machines, use a language designed for that world, or use <stdint.h> in C. But if, like me, you still write code for old and unusual machines, C's flexible integers are still doing the job they were designed for.

It wasn't a mistake. Portability was the whole point!

Further Reading
  • Dennis M. Ritchie, The Development of the C Language (HOPL-II, 1993)
  • S. C. Johnson and D. M. Ritchie, Portability of C Programs and the UNIX System (Bell System Technical Journal, 1978)
  • Kernighan and Ritchie, The C Programming Language, 1st ed. (1978), §2.2
  • Rationale for American National Standard for Information Systems, Programming Language C (ANSI X3.159-1989)
  • Lua 5.4 source: luaconf.h, llimits.h, lopcodes.h, lstrlib.c