Skip to content

C lesson 2 of 8

Variables and Types in C

Why every C variable must be given a type up front - int, double and char, what sizeof really measures, the integer division trap, and how overflow behaves differently for signed and unsigned values.

Published · Every example on this page was run before it was published.

In C you cannot create a variable without saying what kind of value it will hold. That is not bureaucracy: the type is how the compiler knows how many bytes to set aside, how to interpret those bytes, and which machine instruction to use when you do arithmetic. Adding two ints and adding two doubles are genuinely different operations at the hardware level, and C makes you choose. This lesson covers the types you will use constantly, the tool for measuring them, and the two arithmetic behaviours that surprise people most.

Declaring a Variable

A declaration is a type name, then a variable name, then usually an initial value.

C
#include <stdio.h>

int main(void) {
    int crates = 24;
    double unit_mass = 1.75;
    char bay = 'C';

    printf("%d crates in bay %c\n", crates, bay);
    printf("each weighs %.2f kg\n", unit_mass);
    printf("total %.2f kg\n", crates * unit_mass);
    return 0;
}
Output
24 crates in bay C
each weighs 1.75 kg
total 42.00 kg

Once a variable has a type it keeps it for life. crates is an int and will be an int on every line of the program; there is no way to later put text in it. Assigning to it again changes the value, never the type:

C
#include <stdio.h>

int main(void) {
    int tally = 5;
    printf("%d\n", tally);

    tally = 12;
    printf("%d\n", tally);

    tally = tally + 3;
    printf("%d\n", tally);
    return 0;
}
Output
5
12
15

You may also declare a variable without giving it a value, but then its contents are whatever happened to be in that memory - indeterminate, in the standard's word. Reading such a variable before writing to it is undefined behaviour, which means the language makes no promise at all about what happens: you might get zero, you might get an old value, and the compiler is free to assume the situation never arises and optimise accordingly. That is why this lesson never prints an uninitialised variable, and why the safe habit is to initialise at the point of declaration. If you genuinely have nothing to put there yet, int total = 0; costs nothing and removes the whole question.

Variable names may use letters, digits and underscores, and may not start with a digit. C is case-sensitive, so total and Total are two different variables. Lowercase words joined by underscores - unit_mass, total_crates - is the most common style in C code and the one used throughout these lessons.

The Types You Will Actually Use

C has a family of integer types and a family of floating-point types. Four of them cover almost everything a beginner writes:

  • int - a whole number, positive or negative. The default choice for counting anything.
  • double - a number with a fractional part, stored in the format most hardware uses for double-precision floating point. The default choice for measurements, prices and averages.
  • char - a single byte, normally used to hold one character. It is an integer type, which has consequences we will come back to.
  • unsigned int - a whole number that cannot be negative, and which therefore reaches twice as high as an int of the same size.

There are also short and long and long long for integers, and float for a smaller, less precise floating-point number. You will meet them in other people's code. Reach for int and double until you have a reason not to; float in particular buys you very little on modern hardware and costs you precision.

sizeof Measures Storage

sizeof reports how many bytes a type or a value occupies. It is an operator, not a function, and it is answered by the compiler rather than at run time. Its result has the type size_t, an unsigned integer type big enough to measure any object, and the matching printf specifier is %zu.

C
#include <stdio.h>

int main(void) {
    printf("char   %zu\n", sizeof(char));
    printf("int    %zu\n", sizeof(int));
    printf("long   %zu\n", sizeof(long));
    printf("double %zu\n", sizeof(double));
    return 0;
}
Output
char   1
int    4
long   4
double 8

Those are the sizes on this site's target, which is 32-bit WebAssembly. Only one of them is fixed by the language: sizeof(char) is 1 by definition, because a byte in C is defined as however much space a char takes. Everything else is up to the platform. The standard sets floors - an int is at least 2 bytes, a long at least 4, a long long at least 8 - and guarantees the ordering charshortintlonglong long, but that is all. On a typical 64-bit desktop compiler a long is 8 bytes rather than the 4 you see above. Code that quietly assumes a size is code that breaks when it moves, so write sizeof instead of a number whenever a size is needed.

The limits that go with each type live in <limits.h> as named constants, which is far better than memorising numbers:

C
#include <stdio.h>
#include <limits.h>

int main(void) {
    printf("largest int:          %d\n", INT_MAX);
    printf("smallest int:         %d\n", INT_MIN);
    printf("largest unsigned int: %u\n", UINT_MAX);
    return 0;
}
Output
largest int:          2147483647
smallest int:         -2147483648
largest unsigned int: 4294967295

Those values follow directly from int being 4 bytes here: 32 bits give 2³² distinct patterns, split either side of zero for a signed type or all above it for an unsigned one. Note the %u specifier for unsigned int; %d is for signed values only.

Integer Division Throws the Remainder Away

This is the single most common arithmetic surprise in C. When both operands of / are integers, the result is an integer, and any fractional part is discarded - not rounded, truncated toward zero.

C
#include <stdio.h>

int main(void) {
    printf("%d\n", 7 / 2);
    printf("%d\n", 7 % 2);
    printf("%d\n", -7 / 2);
    printf("%d\n", -7 % 2);
    printf("%f\n", 7 / 2.0);
    return 0;
}
Output
3
1
-3
-1
3.500000

7 / 2 is 3, not 3.5 and not 4. The % operator, called modulo or just "remainder", gives what was thrown away: 7 % 2 is 1. For negative numbers, C truncates toward zero, so -7 / 2 is -3 rather than -4, and the remainder takes the sign of the left-hand operand: -7 % 2 is -1. The two operators always agree, in the sense that (a / b) * b + (a % b) reproduces a. The last line escapes integer division entirely, because 2.0 is a double and one double operand is enough to make the whole division a floating-point one.

Here is the trap as it usually appears in real code:

C
#include <stdio.h>

int main(void) {
    int scored = 17;
    int possible = 20;

    double wrong = scored / possible;
    double right = (double) scored / possible;

    printf("wrong: %.2f\n", wrong);
    printf("right: %.2f\n", right);
    return 0;
}
Output
wrong: 0.00
right: 0.85

On the wrong line, the division happens first, between two ints, and produces 0. Only then is that 0 widened to a double and stored - by which point the information is gone. Declaring the destination as a double does nothing, because the destination is not part of the division.

The fix is (double), a cast: an explicit request to treat a value as another type. (double) scored makes the left operand a double, and C then converts the right operand to match, so the division is a floating-point one from the start. A cast binds more tightly than /, so no extra parentheses are needed. Casting only one of the two operands is enough, and casting the wrong one - (double) (scored / possible)

  • brings the bug straight back.

Integer division is not a defect; it is exactly what you want when you are asking "how many whole boxes" or "which row is this index on". Just make sure you are asking that question when you write it.

Mixing Types: Conversions, Silent and Asked-For

When an operator receives two different arithmetic types, C converts one to the other before doing anything. The rough rule is that the "narrower" type is widened: int meets double and becomes a double, char meets int and becomes an int. Going the other way loses information, and C will do that silently on an assignment:

C
#include <stdio.h>

int main(void) {
    double price = 19.99;
    int whole_units = price;
    int cast_units = (int) price;

    printf("%.2f\n", price);
    printf("%d\n", whole_units);
    printf("%d\n", cast_units);
    return 0;
}
Output
19.99
19
19

Both int variables hold 19. Converting a double to an integer type discards the fractional part - again truncation toward zero, not rounding, so 19.99 becomes 19 and -19.99 would become -19. The two lines do exactly the same thing; the difference is that the cast says out loud that you meant it. Writing the cast is worth the keystrokes, because a reader can then tell a deliberate truncation from an accident, and most compilers can be asked to warn about the version without one.

If you actually want rounding rather than truncation, <math.h> has round, floor and ceil.

char Is a Small Integer

A char holds one byte, and C treats that byte as a number. Which number a letter corresponds to is decided by the execution character set - in practice ASCII, where 'A' is 65 and the uppercase letters run consecutively upward from there.

C
#include <stdio.h>

int main(void) {
    char letter = 'A';

    printf("%c\n", letter);
    printf("%d\n", letter);
    printf("%c\n", letter + 2);
    printf("%d\n", 'Z' - 'A');
    return 0;
}
Output
A
65
C
25

The same variable printed with %c shows a letter and with %d shows a number, because those bytes are both things at once. letter + 2 is arithmetic on 65, giving 67, and %c turns 67 back into 'C'. 'Z' - 'A' is the distance between two letters, 25, which is how you turn a letter into a zero-based position - a genuinely useful trick once you are working with text.

Whether a plain char is signed or unsigned is left to the compiler, which is a real portability wart. When you want a byte to be a number rather than a character, say signed char or unsigned char explicitly and the ambiguity disappears.

Floating Point Is Approximate

A double stores a number in binary, with a fixed number of bits. Most decimal fractions have no exact binary representation, in the same way that one third has no exact decimal one, so what gets stored is the nearest value the format can express. Tiny errors then accumulate through arithmetic.

C
#include <stdio.h>

int main(void) {
    double sum = 0.1 + 0.2;

    printf("%.1f\n", sum);
    printf("%.17f\n", sum);
    printf("%d\n", sum == 0.3);
    return 0;
}
Output
0.3
0.30000000000000004
0

Printed to one decimal place it looks like 0.3, because printf rounds for display. Asked for seventeen places, the truth comes out. And the comparison sum == 0.3 is 0, meaning false: the stored result of 0.1 + 0.2 is a slightly different number from the stored value of 0.3.

The practical rules that follow are short. Never compare two floating-point values with ==; compare the size of their difference against a small tolerance instead. Never store money as a double if exact cents matter - count whole cents in an integer. And treat the digits beyond the ones you printed as noise, because that is what they are.

Overflow: Wrapping, and Worse

Every integer type has a range, and arithmetic that leaves that range overflows. What happens next depends entirely on whether the type is signed.

For unsigned types the behaviour is defined precisely: the result wraps around, as if arithmetic were done modulo one more than the maximum.

C
#include <stdio.h>
#include <limits.h>

int main(void) {
    unsigned int counter = 0;

    printf("%u\n", counter);
    printf("%u\n", counter - 1);
    printf("%u\n", UINT_MAX + 1u);
    return 0;
}
Output
0
4294967295
0

Subtracting one from an unsigned zero gives the largest value the type can hold, and adding one to that largest value gives zero again. This is guaranteed, not luck, and it is occasionally useful. It is also the reason a loop like for (unsigned int i = count - 1; i >= 0; i--) never ends: an unsigned value is never less than zero, so the condition is always true.

For signed types, overflow is undefined behaviour. Not "wraps around", not "gives a strange number" - undefined, meaning the standard imposes no requirement whatever, and compilers legitimately optimise on the assumption that it cannot happen. You may see wrapping, you may see a comparison that seems to contradict itself, and you may see the same expression behave differently at two optimisation levels. This is why no example on this page adds one to INT_MAX and shows you the answer: there is no correct answer to show.

Avoiding the problem is mostly about checking before you act rather than looking at the result afterwards. If a and b are non-negative ints, a > INT_MAX - b tells you that a + b would overflow, and it does so without overflowing anything. Choosing a wider type - long long for a running total that might get large - is the other half of the answer.

A Worked Example

A till receipt, using an int for a count, a double for money, a char for a symbol, and one cast to avoid integer division.

C
#include <stdio.h>

int main(void) {
    int items = 3;
    double unit_price = 4.25;
    char currency = '$';
    double tax_rate = 0.08;

    double subtotal = items * unit_price;
    double tax = subtotal * tax_rate;
    double total = subtotal + tax;

    printf("Items:    %d\n", items);
    printf("Subtotal: %c%.2f\n", currency, subtotal);
    printf("Tax:      %c%.2f\n", currency, tax);
    printf("Total:    %c%.2f\n", currency, total);
    printf("Per item: %c%.2f\n", currency, total / items);
    return 0;
}
Output
Items:    3
Subtotal: $12.75
Tax:      $1.02
Total:    $13.77
Per item: $4.59

items * unit_price mixes an int with a double, so the int is widened and the result is 12.75 - no cast needed, because the conversion goes the safe way. The last line divides a double by an int, which is also a floating-point division for the same reason; had both been ints it would have needed the cast from earlier in this lesson.

Every money figure is printed with %.2f, which rounds for display only. The stored values are approximations, as always with double, and the totals are computed from the full stored values rather than from the two-decimal versions on screen - which is why a receipt built this way can occasionally show a penny that does not appear to add up. Counting cents in an int and dividing by 100 only at print time is the cure when that matters.

Common Mistakes

Dividing two integers and expecting a decimal. The headline mistake of this lesson. int done = 3; int total = 4; double ratio = done / total; puts 0.0 in ratio. Cast one operand: (double) done / total.

Using %d for a double, or %f for an int. The specifier tells printf how to interpret the bytes it was handed, and it has no way to check. Mismatch them and the behaviour is undefined - anything may be printed, including something that looks plausible. %d for int, %u for unsigned int, %f for double, %c for char, %s for a string, %zu for a sizeof result.

Assuming a type's size. sizeof(long) is 4 here and 8 on many desktop compilers. Never write the number; write sizeof, and use <limits.h> for ranges.

Comparing doubles with ==. Two values that are mathematically equal can be stored as different bit patterns. Compare fabs(a - b) < 0.000001 or similar, with a tolerance that suits the magnitudes you are working with.

Relying on signed overflow to wrap. It might appear to on one compiler at one optimisation level. It is undefined behaviour, and the moment a compiler starts reasoning about it your program can change meaning. Check first, or use a wider type, or use an unsigned type if wrapping is genuinely what you want.

Reading a variable you never wrote to. int total; total += 5; is not "5" - it is undefined behaviour, because total had no value to add to. Initialise on declaration.

Confusing = with ==. A single = assigns; a double == compares. In C an assignment is itself an expression with a value, so if (x = 3) is legal, assigns 3 to x, and is always true. The control-flow lesson comes back to this.

Next Steps

In the C playground, print sizeof for every type named in this lesson and see how the numbers line up against the guarantees above. Then write two variables holding ints and print their ratio three ways: with no cast, with a cast on the numerator, and with a cast on the whole division. One of the three is right, and getting it wrong on purpose now means recognising it instantly later.

The next lesson is control flow: choosing between paths with if and switch, and repeating work with while, do-while and for.

Write it yourself

Reading about code and writing it are different skills. These exercises practise exactly what this lesson covered; they run in this tab and need no account.