← Fundamentals
Guided lesson 6 questions · C only

C integer types, promotion, and conversions

Minimum ranges, plain char, small types that become int, and the rules that turn -1 into a very large number.

C never does arithmetic in a type narrower than int: a value of a smaller type is converted to int first. When a signed and an unsigned operand meet, both usually become unsigned, and a negative number becomes a very large one. So a comparison or a subtraction can give a wrong answer without any error.

Written for C.

Before you begin

Why this matters

C changes the type of an integer before almost every operator, and it never reports an error for it. These rules decide whether a comparison is true, whether a subtraction gives a small number or a huge one, and whether a byte value from a file can be mistaken for the end of the file.

What this helps you reason about

  • Predict the type and value of arithmetic on small types such as uint8_t, and of ~ on them.
  • Predict a comparison between a signed and an unsigned value, and read gcc’s -Wsign-compare warning about it.
  • Spot a subtraction of sizes that goes below 0, and a char that is too small for the value it must hold.

In Java, Python, and Go, -1 < 1 is true whatever the types. In C, -1 < 1u is false: the u makes 1 an unsigned int, and C converts -1 to unsigned int before it compares. Integers, signs, and overflow showed that every C integer type has a fixed range, that unsigned types wrap around, and that signed overflow is undefined behavior. This lesson follows what C does before an operator runs: which type each operand gets, and what a value becomes when it moves into another type.

Already know this? The short version
  • C11 sets minimum ranges only (int at least 16 bits, long at least 32); the stepper (gcc 12, aarch64 Linux) has char 8, short 16, int 32, long and long long 64 bits, size_t (the type of sizeof and strlen) is unsigned long, and plain char is unsigned there (signed on x86-64).
  • Integer promotion (6.3.1.1): an operand of a type narrower than int becomes int (or unsigned int if int cannot hold all its values), so uint8_t + uint8_t is an int and ~ on a uint8_t sets 24 high bits.
  • Usual arithmetic conversions (6.3.1.8), with types ordered char < short < int < long < long long: if the unsigned operand’s type is as high or higher, the signed one becomes unsigned (-1 < 1u and -1 < strlen(s) are false); a higher signed type wins only if it holds every value of the unsigned one (-1L < 1u is true on the stepper, and long long against size_t is still unsigned).
  • Conversion to an unsigned type wraps by rule; to a signed type, an out-of-range value is the compiler’s choice (gcc keeps the low bits). Assignments convert silently under -Wall -Wextra; -Wconversion reports them.
  • In real programs, watch for size_t count-down loops, len - 1 with len 0, getchar stored in a char, a char used as an index, and uint16_t products that overflow as int: see “What goes wrong with integer types in real programs?”.

Go to the final check· Go to the practice tickets

Words in this lesson (7)
unsigned type
An integer type with no negative values, such as unsigned int. Its arithmetic keeps the remainder after dividing by 2 to the power of its bit count, so it wraps around instead of overflowing.
implementation-defined
Behavior that the C standard leaves to each compiler, which must document its choice, such as how a value too large for an int is converted to one.
size_t
The unsigned integer type that sizeof and strlen give, used for sizes and counts. It is declared in <stddef.h> and printed with %zu.
integer promotion
The conversion C applies to an integer operand of a type narrower than int, such as char, short, or uint8_t, before an operator uses it: the value becomes an int, or an unsigned int if int cannot hold every value of the type.
conversion rank
C’s order of the integer types, from char up through short, int, and long to long long. A signed type and its unsigned type have the same rank.
usual arithmetic conversions
The rules C uses to give the two operands of an operator one common type before it is applied, such as converting an int to a double in 2.5 * 3, or an int to unsigned int.
narrowing conversion
A conversion to a type that cannot hold every value of the old type, such as from a 64-bit integer to an 8-bit one, or from a number with a fraction to a whole number. A value that does not fit is changed.

Examples are focused snippets; imports, enclosing functions, or classes may be omitted. C examples use C11; Haskell examples use Haskell 2010. Expand the notes for language-specific depth.

01 / Types

Which integer types does C have, and how wide are they?

The C standard sets a minimum range for each integer type, not an exact size. char has at least 8 bits. short and int each hold at least -32,767 to 32,767, so at least 16 bits. long has at least 32 bits, and long long at least 64. The stepper builds with gcc 12 on a 64-bit ARM machine (aarch64) running Linux. There, char is 8 bits, short 16, int 32, and long and long long 64.

char comes in three types: signed char, unsigned char, and plain char. Whether plain char is signed is implementation-defined: the C standard leaves the choice to each compiler, which must document it. On the stepper, plain char is unsigned and holds 0 to 255. On x86-64 Linux, it is signed and holds -128 to 127.

When the exact width matters, <stdint.h> has types named by their bits: int8_t, uint8_t, int16_t, uint16_t, and so on up to 64. One more type matters in every program: size_t, the unsigned integer type that sizeof and strlen give, used for sizes and counts. It is declared in <stddef.h> and printed with %zu. On the stepper it is unsigned long, 64 bits.

main.c
#include <limits.h>
#include <stdint.h>

printf("%d %d\n", CHAR_MIN, CHAR_MAX); /* 0 255 here */
printf("%zu %zu\n", sizeof(int), sizeof(long)); /* 4 8 */
uint8_t level = 255;         /* exactly 8 bits */
size_t bytes = sizeof level; /* 1 */

C promises minimum ranges, not sizes, and plain char may be signed or unsigned; use signed char, unsigned char, or a <stdint.h> type when the sign matters.

Coming from Java: Java’s byte is always signed and its char is an unsigned 16-bit type. A C char is a byte, and whether it is signed depends on the platform.

Precisely: C11 5.2.4.2.1, 6.2.5, and 7.20

5.2.4.2.1 lists the minimum magnitudes: SCHAR_MAX at least 127, SHRT_MAX and INT_MAX at least 32767, LONG_MAX at least 2147483647, LLONG_MAX at least 9223372036854775807; the minimums are written -32767 and so on, because C11 still allowed representations other than two’s complement. 6.2.5p15: plain char has the same range as either signed char or unsigned char, but it is a distinct type. gcc chooses per target, and -fsigned-char or -funsigned-char overrides the choice. 7.20.1.1: an exact-width type such as int8_t exists only if the machine has such a type with no padding bits (and two’s complement, for the signed ones); int_least8_t and int_fast8_t always exist. In gcc on Linux, uint8_t is a typedef for unsigned char, so it is a character type and the debugger shows it as one. 7.19 declares size_t; SIZE_MAX is at least 65535. The sizes above are observations of gcc 12 on aarch64 Linux, an LP64 system (long and pointers are 64 bits); 64-bit Windows is LLP64, where long stays at 32 bits.

Experiment

Store -1 in a plain char

On the stepper’s machine, a 64-bit ARM (aarch64), plain char is unsigned and holds 0 to 255. -1 does not fit, so it is converted, and a conversion to an unsigned type adds 256: -1 becomes 255. signed char holds -128 to 127, so -1 fits and is stored unchanged. On x86-64 Linux, plain char is signed, and the same program prints -1 -1.

Why it matters Write signed char or unsigned char (or int8_t or uint8_t) when a small integer needs a sign rule. Plain char is for characters.

Predict both numbers. Then change char change = -1; to signed char change = -1; and predict again.

#include <stdio.h>

int main(void) {
    setvbuf(stdout, NULL, _IOLBF, 0); // line by line, as in a terminal
    char change = -1; // one moth fewer than last night
    signed char drop = -1;
    printf("%d %d\n", change, drop);
    return 0;
}
What to look for

Step past line 5: change shows 255 '\377'. The debugger shows a char as its number and then as a character; '\377' is the byte 255 written in octal (base 8), because no printable character has that code. Step past line 6: drop shows -1 '\377', the same byte read as signed. The run prints 255 -1, and gcc gives no warning. After the edit, change shows -1 '\377', and the run prints -1 -1.

Expected output

255 -1

02 / Promotion

What happens to a small integer type in arithmetic?

Before arithmetic, char and short values become int; this is called integer promotion. The rule covers every type narrower than int, including uint8_t, int16_t, and uint16_t on the stepper. A type whose values do not all fit in int becomes unsigned int instead, as unsigned short does on a machine where int is 16 bits. On the stepper, int holds every value of every narrower type, so they always become int.

So uint8_t + uint8_t is an int. 200 + 100 is 300, not 44. The value wraps only if 300 is later stored in a uint8_t.

The operators ~, unary -, and the shifts promote their operand too. Masks, shifts, and bitwise operations used ~ to flip bits. On a uint8_t, ~ flips all 32 bits of the promoted int, not 8. With low = 0x0f, ~low is 0xfffffff0, the int -16, so ~low == 0xf0 is false. gcc warns: comparison of promoted bitwise complement of an unsigned value with constant. Convert back to 8 bits first: (uint8_t) ~low is 0xf0.

main.c
uint8_t a = 200;
uint8_t b = 100;
int sum = a + b;    /* 300: computed as int */
uint8_t low = 0x0f;
int flipped = ~low; /* -16, not 240 (0xf0) */
int same = (uint8_t) ~low == 0xf0; /* 1 */

No arithmetic happens in a type narrower than int: a uint8_t or a short is promoted to int first.

Precisely: C11 6.3.1.1 and 6.5.2.2

6.3.1.1p2 applies the promotion to every integer type whose conversion rank is lower than int’s, and to bit-fields; 6.3.1.1p3 says it keeps the value, sign included. It is applied to the operand of unary +, -, and ~, to each operand of << and >> separately (the result has the promoted type of the left operand), and as the first step of the usual arithmetic conversions. 6.5.2.2p6–7: arguments that match the ... of printf get the default argument promotions, so a uint8_t passed for %d arrives as an int. On a machine with a 16-bit int, such as many 8-bit and 16-bit microcontrollers, uint16_t promotes to unsigned int instead, so the same expression is unsigned there and signed here. A shift shows why this matters: with uint8_t b = 0x80, b << 24 is an int shift whose result does not fit in int, which is undefined behavior; write (uint32_t) b << 24.

Experiment

Add two uint8_t counts

Both operands are promoted to int before the addition, so the sum is the int 300, and sizeof gives the size of an int, 4 bytes. Nothing wraps during the addition. The wrap happens when 300 is stored in kept, a uint8_t: 300 - 256 = 44. The edit converts the sum to uint8_t before it is stored in total, so total gets 44 too.

Why it matters A sum of two small types does not wrap at their size. It wraps only where it is converted back to a small type.

Two nights of moths, 200 and 100, are each stored in a uint8_t. Predict total, kept, and the size of the sum. Then change int total = first_night + second_night; to int total = (uint8_t) (first_night + second_night); and predict again.

#include <stdio.h>
#include <stdint.h>

int main(void) {
    setvbuf(stdout, NULL, _IOLBF, 0); // line by line, as in a terminal
    uint8_t first_night = 200; // moths in the trap
    uint8_t second_night = 100;
    int total = first_night + second_night;
    uint8_t kept = first_night + second_night;
    printf("%d %d %zu\n", total, kept,
           sizeof(first_night + second_night));
    return 0;
}
What to look for

uint8_t is unsigned char in gcc, so the panel shows it like a char: first_night shows 200 '\310'. Step past line 8: total shows 300. Step past line 9: kept shows 44 ',' (44 is the code of a comma). The run prints 300 44 4. After the edit, total shows 44, and the run prints 44 44 4.

Expected output

300 44 4

03 / Signed and unsigned

Which type wins when a signed and an unsigned value meet?

After promotion, an operator’s two operands can still have different types. The usual arithmetic conversions then give both one common type. Which type is chosen depends on conversion rank: C’s order of the integer types, from char up through short, int, and long to long long. A signed type and its unsigned type have the same rank. For two integer operands, the rules are tried in this order:

1. Both signed, or both unsigned: the one of lower rank is converted to the other. 2. The unsigned operand has the same rank or a higher one: the signed operand is converted to the unsigned type. 3. The signed operand has the higher rank, and its type can hold every value of the unsigned type: the unsigned operand is converted to the signed type. 4. Otherwise, both are converted to the unsigned type of the signed operand’s rank.

Converting a negative number to an unsigned type adds 2 to the power of its bit count, so -1 becomes the largest value. In -1 < 1u, rule 2 applies, -1 becomes 4,294,967,295, and the result is false. On the stepper, -1L < 1u is true: a 64-bit long holds every unsigned int, so rule 3 applies. size_t is unsigned long here, so an int compared with strlen(s) or sizeof meets rule 2.

Subtraction follows the same rules. strlen(s) - 1 is a size_t. When s is the empty string, 0 - 1 wraps to 18,446,744,073,709,551,615.

The fix is to keep each comparison in one kind of type: test a signed value for a negative number before you convert it, and keep sizes and counts in size_t.

main.c
size_t len = strlen("");     /* 0 */
printf("%d\n", -1 < 1u);    /* 0: -1 becomes 4294967295 */
printf("%d\n", -1L < 1u);   /* 1 here: long holds every
                                unsigned int value */
printf("%d\n", -1 < 1ul);   /* 0 */
printf("%zu\n", len - 1);   /* 18446744073709551615 */

When a signed and an unsigned operand meet, the unsigned type usually wins, and a negative value becomes a very large one.

Coming from Go: Go refuses to compare an int with a uint until one of them is converted in the code. C converts on its own, and only warns with -Wextra.

Coming from Java: Java has no unsigned int or long, so a comparison never turns a negative number into a large one. In C, the other operand’s type decides.

Precisely: C11 6.3.1.8 and the warnings

6.3.1.8p1 applies the integer promotions to both operands first, then the four rules above. 6.3.1.1p1 defines rank: long long > long > int > short > signed char, each unsigned type equal to its signed type, char equal to signed and unsigned char, and _Bool the lowest. Rule 3 depends on the sizes, so -1L < 1u is true on LP64 Linux and false on 64-bit Windows and on 32-bit systems, where long and unsigned int are both 32 bits. In the experiment’s edit, long long against unsigned long falls to rule 4: both become unsigned long long. gcc’s -Wsign-compare is part of -Wextra in C, and the stepper builds with -std=c11 -Wall -Wextra -O0. It stays quiet when it can prove that the signed operand is not negative, as in n < 3 with an unsigned n, and it never warns about a subtraction such as len - 1.

Experiment

Compare -1 with a string length

strlen returns a size_t, which is unsigned long on the stepper, and unsigned long has a higher conversion rank than int. So pos is converted to unsigned long: -1 becomes 18446744073709551615, which is not less than 3. long long has a higher rank than unsigned long, but it cannot hold every unsigned long value, because both are 64 bits. Then both operands become unsigned long long, and -1 is still the largest value.

Why it matters A wider signed type does not fix a mixed comparison. Test for a negative value first, or keep both operands unsigned or both signed.

pos is -1, a common mark for “no position yet”, and strlen(tree) is 3. Predict which line prints. Then change int pos = -1; to long long pos = -1; (a 64-bit signed type) and predict again.

#include <stdio.h>
#include <string.h>

int main(void) {
    setvbuf(stdout, NULL, _IOLBF, 0); // line by line, as in a terminal
    const char *tree = "oak";
    int pos = -1; // -1: no letter chosen yet
    if (pos < strlen(tree)) {
        printf("inside the name\n");
    } else {
        printf("past the end\n");
    }
    return 0;
}
What to look for

Step past line 7: pos shows -1. From the if line, the next stop is line 11, in the else block, and the run prints past the end. Under Compiler warnings, gcc reports warning: comparison of integer expressions of different signedness: ‘int’ and ‘size_t’ {aka ‘long unsigned int’} [-Wsign-compare]. After the edit, the stops and the output are the same, and the warning now names ‘long long int’ and ‘size_t’.

Expected output

past the end

04 / Narrower types

What happens when a value is stored in a narrower type?

An assignment, an argument, and a return value are all converted to the type that receives them, with no cast written. When that type is narrower, the conversion is a narrowing conversion. If the value fits, it is unchanged. If it does not fit, the rule depends on the target:

  • An unsigned type keeps the remainder after dividing by 2 to the power of its bit count. 300 stored in a uint8_t is 44, and -1 is 255. This is defined.
  • For a signed type, the result is implementation-defined. gcc keeps the low bits, so 200 stored in an int8_t is -56.

This is not the signed overflow of Integers, signs, and overflow, which is undefined behavior: a conversion always gives a value. gcc with -Wall -Wextra gives no warning for either case.

main.c
int grown = 200;
int change = -1;
uint8_t a = grown;  /* 200: it fits */
int8_t b = grown;   /* -56 with gcc */
uint8_t c = change; /* 255: wraps by rule */

A narrower type keeps the low bits: by rule for unsigned types, by the compiler’s choice for signed ones, and silently in both cases.

Precisely: C11 6.3.1.3 and the GCC manual

6.3.1.3p2: for an unsigned target, the value is converted “by repeatedly adding or subtracting one more than the maximum value that can be represented in the new type until the value is in the range of the new type”. 6.3.1.3p3: for a signed target, “either the result is implementation-defined or an implementation-defined signal is raised”. The GCC manual (Integers implementation) says the value is reduced modulo 2^N to fit, and no signal is raised. -Wconversion reports conversions that may change a value; it is not part of -Wall or -Wextra. A constant that does not fit is different: uint8_t c = 300; gets a -Woverflow warning by default. Converting a floating-point value whose whole part does not fit is undefined behavior (6.3.1.4).

Experiment

Store 200 in two 8-bit types

200 fits in a uint8_t (0 to 255), so it is stored unchanged. It does not fit in an int8_t (-128 to 127), and the C standard leaves that result to the compiler; gcc keeps the low 8 bits, which mean 200 - 256 = -56. 300 fits in neither: uint8_t gets 300 - 256 = 44 by rule, and gcc gives int8_t the same low 8 bits, which mean 44 there too.

Why it matters An assignment converts silently. Before storing in a narrower type, compare with its limits, such as INT8_MIN and INT8_MAX.

Predict as_unsigned and as_signed. Then change 200 to 300 and predict again.

#include <stdio.h>
#include <stdint.h>

int main(void) {
    setvbuf(stdout, NULL, _IOLBF, 0); // line by line, as in a terminal
    int grown_mm = 200; // a sunflower's growth this week
    uint8_t as_unsigned = grown_mm;
    int8_t as_signed = grown_mm;
    printf("%d %d\n", as_unsigned, as_signed);
    return 0;
}
What to look for

Step past line 7: as_unsigned shows 200 '\310'. Step past line 8: as_signed shows -56 '\310', the same byte. The run prints 200 -56, and gcc gives no warning. After the edit, both show 44 ',', and the run prints 44 44.

Expected output

200 -56

05 / In production

What goes wrong with integer types in real programs?

The rules above explain four bugs that C programmers meet in real code.

A count-down loop with size_t never stops: for (size_t i = n - 1; i >= 0; i--). i >= 0 is always true for an unsigned type, and when n is 0, n - 1 is already the largest size_t. gcc with -Wextra warns: comparison of unsigned expression in ‘>= 0’ is always true. Write for (size_t i = n; i-- > 0;), which tests i before each step down.

A guard with an unsigned subtraction passes when it should not. With size_t len and limit, if (len - 1 > limit) is true for an empty input, because len - 1 is the largest size_t, and gcc gives no warning, since both operands are size_t. Move the 1 to the other side: if (len > limit + 1).

A char that holds a byte value breaks on one platform. char c = getchar() can never equal EOF where char is unsigned, as on the stepper, so a read loop never ends. Where char is signed, as on x86-64, the byte 0xff reads as EOF and the input seems to end early. The same signed char used as an index, counts[c], is negative for every byte above 127; gcc’s -Wall warns: array subscript has type ‘char’. Convert to unsigned char first.

A uint16_t product can overflow as an int. uint16_t width = 50000; then width * width is promoted to int, and 2,500,000,000 does not fit, which is undefined behavior. Convert one operand: (uint32_t) width * width.

main.c
/* i takes n - 1 down to 0, and the loop
   does not run when n is 0 */
for (size_t i = n; i-- > 0;) {
    total += counts[i];
}
/* the byte as 0 to 255 on every platform */
counts[(unsigned char) c]++;

Keep getchar’s result in an int, test before an unsigned value steps below 0, and convert small types before an operation that needs their sign or their width.

Precisely: C11 7.21.7.1, 7.4p1, and the sanitizers

7.21.7.1: getchar returns the next character as an unsigned char converted to an int, or EOF, a negative int constant (7.21.1p3), -1 in glibc. 7.4p1: the <ctype.h> functions such as isalpha take an int whose value is EOF or fits in unsigned char; passing a negative char is undefined behavior, so isalpha((unsigned char) c) is the safe call. -Wchar-subscripts is part of -Wall, and -Wtype-limits of -Wextra. gcc and clang report the uint16_t overflow while the program runs with -fsanitize=signed-integer-overflow, part of -fsanitize=undefined; the experiment in this section stays defined, and no example here runs an overflow.

Experiment

Keep getchar’s result in a char

getchar returns an int: a byte from 0 to 255, or EOF, which is -1 in glibc. Stored in a plain char, which is unsigned here, -1 becomes 255. In c == EOF, c is promoted to the int 255, and 255 is never -1, so the test is always false and gcc says so. An int keeps -1, so c == EOF is true.

Why it matters Keep getchar’s result in an int until it has been compared with EOF. Only then store it in a char.

The stepper gives every program empty standard input, so getchar returns EOF at once. Predict both numbers. Then change char c = getchar(); to int c = getchar(); and predict again.

#include <stdio.h>

int main(void) {
    setvbuf(stdout, NULL, _IOLBF, 0); // line by line, as in a terminal
    char c = getchar(); // standard input is empty here
    printf("%d %d\n", c, c == EOF);
    return 0;
}
What to look for

Step past line 5: c shows 255 '\377'. The run prints 255 0. Under Compiler warnings, gcc reports warning: comparison is always false due to limited range of data type [-Wtype-limits]. After the edit, c shows -1, the warning is gone, and the run prints -1 1.

Expected output

255 0

06 / Try it

Can you predict each conversion?

Work out each operand’s type before the value: promotion first, then the usual arithmetic conversions. gcc warns about one of these comparisons.

main.c
#include <stdio.h>
#include <stdint.h>

int main(void) {
    uint8_t petals = 250;
    unsigned int seeds = 3;
    int frost = -2;
    printf("%d %d\n", petals + 10, (uint8_t) (petals + 10));
    printf("%d %d\n", frost < seeds, (long long) frost < seeds);
    return 0;
}

What does it print, and which comparison gets the warning?

Reveal the reasoning

260 4, then 0 1. petals is promoted to int, so petals + 10 is the int 260; converting it to uint8_t keeps 260 - 256 = 4. In frost < seeds, int and unsigned int have the same rank, so frost is converted to unsigned int and -2 becomes 4,294,967,294, which is not less than 3: 0. That comparison gets the -Wsign-compare warning. (long long) frost has a higher rank than unsigned int, and a 64-bit long long holds every unsigned int value, so seeds is converted to long long instead and -2 < 3 is true: 1. Nothing here is undefined.

Practice tickets

  1. C-401 · The frost count misses the coldest nights → Free
  2. C-402 · Some weeks show a negative number of hedgehog visits → Sign in · Pro
  3. C-403 · The soil average shows 0 or a huge number → Sign in · Pro

Keep exploring

Conditions and truthiness →

Primary references