Skip to main content
C · lesson 4 of 25
C beginner Lesson 4 of 25

Data Types in C

Explore C's built-in data types including integers, floating-point numbers, characters, and how to use sizeof and type limits.

By SkillByExample · Updated

Integer Types

C has several integer types of increasing size. The standard sets only minimums, so <limits.h> tells you the real range on the machine you compile for:

#include <stdio.h>
#include <limits.h>   /* INT_MAX, CHAR_MAX, etc. */

int main(void) {
    printf("char:      %zu bytes, max = %d\n",  sizeof(char),  CHAR_MAX);
    printf("short:     %zu bytes, max = %d\n",  sizeof(short), SHRT_MAX);
    printf("int:       %zu bytes, max = %d\n",  sizeof(int),   INT_MAX);
    printf("long:      %zu bytes, max = %ld\n", sizeof(long),  LONG_MAX);
    printf("long long: %zu bytes, max = %lld\n", sizeof(long long), LLONG_MAX);
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -o ints ints.c
$ ./ints
char:      1 bytes, max = 127
short:     2 bytes, max = 32767
int:       4 bytes, max = 2147483647
long:      8 bytes, max = 9223372036854775807
long long: 8 bytes, max = 9223372036854775807

On 64-bit Linux, long and long long are both 8 bytes. Compile the same file on 64-bit Windows and long drops to 4 bytes with a max of 2147483647. This is why code that stores a file size or a timestamp in long can break when it’s ported. %zu is the correct format for sizeof’s result type, size_t.

Unsigned Variants and Their Traps

unsigned removes negative values and doubles the positive range. Unsigned arithmetic wraps around instead of overflowing, and mixing signed with unsigned has a nasty surprise:

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

int main(void) {
    unsigned int x = 0;
    x--;                       /* wraps around: well-defined for unsigned */
    printf("0u - 1   = %u\n", x);
    printf("UINT_MAX = %u\n", UINT_MAX);

    unsigned char uc = 255;
    uc++;                      /* wraps to 0 */
    printf("255 + 1 as unsigned char = %d\n", uc);

    int n = -1;
    unsigned int len = 3;
    if (n < len) printf("-1 < 3\n");
    else         printf("-1 is NOT less than 3?!\n");
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -o unsigned unsigned.c
unsigned.c: In function 'main':
unsigned.c:16:11: warning: comparison of integer expressions of different signedness: 'int' and 'unsigned int' [-Wsign-compare]
   16 |     if (n < len) printf("-1 < 3\n");
      |           ^
$ ./unsigned
0u - 1   = 4294967295
UINT_MAX = 4294967295
255 + 1 as unsigned char = 0
-1 is NOT less than 3?!

The last line is the real bug. When an int meets an unsigned int in a comparison, the int is converted to unsigned, so -1 becomes 4294967295, which is not less than 3. -Wextra enables the warning that catches it. This is why loop counters compared against strlen() or sizeof (both unsigned) should be size_t, not int. Note also that only unsigned overflow is defined to wrap. Overflowing a signed int is undefined behavior.

Fixed-Width Integer Types (stdint.h)

When the exact size matters (network protocols, file formats, hardware registers), use the fixed-width types from <stdint.h>. <inttypes.h> supplies matching printf macros:

#include <stdio.h>
#include <stdint.h>
#include <inttypes.h>  /* PRId32 etc. for printf */

int main(void) {
    int8_t   a = INT8_MIN;
    uint8_t  b = UINT8_MAX;
    int32_t  e = INT32_MIN;
    uint64_t h = UINT64_MAX;

    printf("int8_t min:   %" PRId8  "\n", a);
    printf("uint8_t max:  %" PRIu8  "\n", b);
    printf("int32_t min:  %" PRId32 "\n", e);
    printf("uint64_t max: %" PRIu64 "\n", h);
    printf("sizeof(size_t) = %zu, sizeof(intptr_t) = %zu\n",
           sizeof(size_t), sizeof(intptr_t));
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -o stdint stdint.c
$ ./stdint
int8_t min:   -128
uint8_t max:  255
int32_t min:  -2147483648
uint64_t max: 18446744073709551615
sizeof(size_t) = 8, sizeof(intptr_t) = 8

PRIu64 expands to the right conversion for the platform ("lu" on this Linux build, something different on Windows), and string-literal concatenation glues it into the format. Also worth knowing:

  • size_t: the unsigned type sizeof returns, used for sizes and indices
  • intptr_t / uintptr_t: wide enough to hold a pointer
  • ptrdiff_t: the result of subtracting two pointers

Floating-Point Types

The same value of pi stored three ways shows how much precision each type keeps:

#include <stdio.h>
#include <float.h>  /* FLT_MAX, DBL_DIG, etc. */

int main(void) {
    float  f = 3.14159265358979f;
    double d = 3.14159265358979;
    long double ld = 3.14159265358979323846L;

    printf("float:       %.15f (%zu bytes, %d digits)\n", f, sizeof f, FLT_DIG);
    printf("double:      %.15f (%zu bytes, %d digits)\n", d, sizeof d, DBL_DIG);
    printf("long double: %.18Lf (%zu bytes, %d digits)\n", ld, sizeof ld, LDBL_DIG);

    printf("float max:  %e\n", FLT_MAX);
    printf("double max: %e\n", DBL_MAX);
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -o floats floats.c
$ ./floats
float:       3.141592741012573 (4 bytes, 6 digits)
double:      3.141592653589790 (8 bytes, 15 digits)
long double: 3.141592653589793239 (16 bytes, 18 digits)
float max:  3.402823e+38
double max: 1.797693e+308

The float goes wrong after 3.141592, its seventh significant digit (FLT_DIG guarantees 6). Everything after that is rounding noise, printed faithfully because we asked for 15 decimals. long double is 16 bytes on x86-64 Linux (80-bit extended precision plus padding). On MSVC it is the same as double, so check LDBL_DIG before relying on it.

Never Compare Floats with ==

#include <stdio.h>
#include <math.h>

int main(void) {
    double a = 0.1 + 0.2;
    double b = 0.3;

    printf("a = %.17f\n", a);
    printf("b = %.17f\n", b);
    printf("a == b: %s\n", a == b ? "yes" : "no");
    printf("close enough: %s\n", fabs(a - b) < 1e-9 ? "yes" : "no");
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -o fcompare fcompare.c -lm
$ ./fcompare
a = 0.30000000000000004
b = 0.29999999999999999
a == b: no
close enough: yes

Neither 0.1, 0.2 nor 0.3 exists exactly in binary. Printing 17 digits shows the two values land on opposite sides of 0.3. Compare within a tolerance instead. -lm links the math library that provides fabs (needed on Linux, harmless elsewhere).

The char Type

char holds one character, and it is also a small integer. 'A' is just the number 65 in ASCII:

#include <stdio.h>

int main(void) {
    char letter = 'A';
    printf("%c = %d\n", letter, letter);

    for (char c = 'a'; c <= 'z'; c++) {
        printf("%c", c);
    }
    printf("\n");

    char upper = 'q' - 32;   /* ASCII: lowercase is 32 above uppercase */
    int digit = '7' - '0';   /* digit characters are consecutive */
    printf("upper=%c, digit=%d\n", upper, digit);
    printf("'7' as a number: %d\n", '7');
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -o chars chars.c
$ ./chars
A = 65
abcdefghijklmnopqrstuvwxyz
upper=Q, digit=7
'7' as a number: 55

'7' - '0' works on every platform because the standard guarantees the digit characters are consecutive. The - 32 trick for uppercase is ASCII-specific, so real code uses toupper() from <ctype.h>. Whether plain char is signed or unsigned is implementation-defined (signed on x86, unsigned on ARM Linux). Write signed char or unsigned char when it matters.

The sizeof Operator

sizeof gives the size in bytes of a type or expression, worked out at compile time:

#include <stdio.h>

int main(void) {
    printf("sizeof(short)     = %zu\n", sizeof(short));
    printf("sizeof(long long) = %zu\n", sizeof(long long));
    printf("sizeof(float)     = %zu\n", sizeof(float));
    printf("sizeof(void *)    = %zu\n", sizeof(void *));

    int arr[10];
    printf("array: %zu bytes, %zu elements\n",
           sizeof arr, sizeof arr / sizeof arr[0]);

    int n = 5;
    size_t s = sizeof(n++);   /* operand is NOT evaluated */
    printf("s = %zu, n = %d\n", s, n);
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -o sizes sizes.c
$ ./sizes
sizeof(short)     = 2
sizeof(long long) = 8
sizeof(float)     = 4
sizeof(void *)    = 8
array: 40 bytes, 10 elements
s = 4, n = 5

n is still 5. sizeof only looks at the type of n++, so the increment never runs. sizeof arr / sizeof arr[0] is the standard element-count idiom. It only works on a real array, not on a pointer to one (the arrays lesson shows why).

Type Conversion

C converts operands to a common type in arithmetic. Whether the division is integer or floating-point depends on the operand types, not on the variable you store the result in:

#include <stdio.h>

int main(void) {
    int   i = 7;
    float f = 2.5f;

    float result = i + f;          /* i is converted to float */
    printf("7 + 2.5f      = %.1f\n", result);

    int quotient = 7 / 2;          /* both int: integer division */
    printf("7 / 2         = %d\n", quotient);

    double exact = (double)7 / 2;  /* one double operand: real division */
    printf("(double)7 / 2 = %.1f\n", exact);

    printf("(int)3.99     = %d\n", (int)3.99);   /* truncates */
    printf("(int)-3.99    = %d\n", (int)-3.99);  /* toward zero */
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -o convert convert.c
$ ./convert
7 + 2.5f      = 9.5
7 / 2         = 3
(double)7 / 2 = 3.5
(int)3.99     = 3
(int)-3.99    = -3

Converting to int truncates toward zero. It never rounds, so -3.99 becomes -3, not -4. The cast in (double)7 / 2 applies to 7 alone, before the division. (double)(7 / 2) would do the integer division first and give 3.0.

Type Qualifiers

const

const makes a variable read-only after initialization, and the compiler enforces it:

#include <stdio.h>

int main(void) {
    const double PI = 3.14159265358979;
    const int DAYS_IN_WEEK = 7;

    printf("PI = %.5f, days = %d\n", PI, DAYS_IN_WEEK);
    PI = 3.0;   /* not allowed */
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -o const const.c
const.c: In function 'main':
const.c:8:8: error: assignment of read-only variable 'PI'
    8 |     PI = 3.0;   /* not allowed */
      |        ^

The error happens at compile time. No executable is produced, so the bad write can never run. Delete the last assignment and the program prints PI = 3.14159, days = 7.

volatile

volatile tells the compiler a variable can change in ways it can’t see, such as from a signal handler, so every read must really happen. Here a signal handler sets a flag after one second while main waits for it:

#include <signal.h>
#include <stdio.h>
#include <unistd.h>

volatile sig_atomic_t stop = 0;

void on_alarm(int sig) {
    (void)sig;
    stop = 1;              /* changed "outside" the normal flow */
}

int main(void) {
    signal(SIGALRM, on_alarm);
    alarm(1);              /* deliver SIGALRM in one second */
    while (!stop) {
        /* busy-wait for the flag */
    }
    printf("flag seen, exiting\n");
    return 0;
}

This uses POSIX alarm(), so it runs on Linux or macOS. flag_plain.c is the same file with the flag declared as plain int stop = 0;. timeout 3 kills anything still running after three seconds and exits with status 124:

$ gcc -Wall -Wextra -std=c17 -o flag_plain flag_plain.c
$ timeout 3 ./flag_plain
flag seen, exiting
$ gcc -Wall -Wextra -std=c17 -O2 -o flag_plain flag_plain.c
$ timeout 3 ./flag_plain
$ echo $?
124
$ gcc -Wall -Wextra -std=c17 -O2 -o flag flag.c
$ timeout 3 ./flag
flag seen, exiting

The plain-int version works at -O0 and hangs forever at -O2, with no warning either way. The optimizer saw that nothing inside the loop changes stop, read it once, and turned the loop into while (1). The volatile version forces a real memory read on every iteration. sig_atomic_t is the integer type the standard guarantees a signal handler can write safely. volatile does not make code thread-safe. Between threads, use _Atomic or a mutex (see the concurrency lesson).

restrict (C99)

restrict on a pointer promises that, while the function runs, the memory it points to is accessed only through that pointer. The compiler can then vectorize loops without guarding against overlap:

#include <stdio.h>

void add_arrays(int *restrict dst, const int *restrict src, int n) {
    for (int i = 0; i < n; i++) {
        dst[i] += src[i];  /* compiler may assume dst and src never overlap */
    }
}

int main(void) {
    int totals[4] = {1, 2, 3, 4};
    int extra[4]  = {10, 20, 30, 40};
    add_arrays(totals, extra, 4);
    for (int i = 0; i < 4; i++) printf("%d ", totals[i]);
    printf("\n");
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -O2 -o restrict restrict.c
$ ./restrict
11 22 33 44 

The output is the same with or without restrict. The keyword changes what the optimizer is allowed to assume, not what correct code computes. The compiler cannot check the promise. Calling add_arrays(totals, totals + 1, 3) breaks it, and the result is undefined behavior. memcpy is declared with restrict for this reason, and overlapping copies must use memmove.

Practice

  1. A uint8_t counter starts at 250 and is incremented 10 times. Predict its final value, then check.
Solution
#include <stdio.h>
#include <stdint.h>
#include <inttypes.h>

int main(void) {
    uint8_t counter = 250;
    for (int i = 0; i < 10; i++) {
        counter++;
    }
    printf("counter = %" PRIu8 "\n", counter);
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -o p1 p1.c
$ ./p1
counter = 4

250 + 10 = 260, and a uint8_t wraps at 256, so 260 − 256 = 4. Unsigned wraparound is well-defined, so this is guaranteed on every platform.

  1. This average prints 4.00 for 17 / 4. Fix it so it prints 4.25.
int total = 17, count = 4;
double wrong = total / count;
Solution
#include <stdio.h>

int main(void) {
    int total = 17, count = 4;
    double wrong = total / count;
    double right = (double)total / count;
    printf("wrong average = %.2f\n", wrong);
    printf("right average = %.2f\n", right);
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -o p2 p2.c
$ ./p2
wrong average = 4.00
right average = 4.25

Storing into a double doesn’t help. total / count is fully evaluated as integer division first, giving 4, and only that result is converted. Casting one operand before the division makes it floating-point division.

  1. Add 0.1f to a float ten times. Is the sum exactly 1.0f?
Solution
#include <stdio.h>

int main(void) {
    float sum = 0.0f;
    for (int i = 0; i < 10; i++) {
        sum += 0.1f;
    }
    printf("sum = %.10f\n", sum);
    printf("sum == 1.0f: %s\n", sum == 1.0f ? "yes" : "no");
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -o p3 p3.c
$ ./p3
sum = 1.0000001192
sum == 1.0f: no

Each addition rounds to the nearest float, and the ten small errors add up to 0.0000001192. That is visible, even though %.1f would still print 1.0.

  1. Print every character of "Hello" next to its integer code, e.g. H=72.
Solution
#include <stdio.h>

int main(void) {
    const char *word = "Hello";
    for (int i = 0; word[i] != '\0'; i++) {
        printf("%c=%d ", word[i], word[i]);
    }
    printf("\n");
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -o p4 p4.c
$ ./p4
H=72 e=101 l=108 l=108 o=111 

The same char value prints as a letter with %c and as a number with %d. The type is identical; only the format changes. The loop stops at '\0', the terminator every C string ends with.

Frequently Asked Questions

Why does int have different sizes on different platforms?
The C standard only guarantees minimum sizes (int is at least 16 bits). In practice, int is 32 bits on all mainstream 32- and 64-bit platforms, while long varies: 8 bytes on 64-bit Linux and macOS, 4 bytes on 64-bit Windows. When exact size matters, use stdint.h types like int32_t.
When should I use float vs double?
Use double by default: it carries about 15 significant decimal digits against float's 6, and modern CPUs handle it just as fast. Use float only when memory or bandwidth matters, such as large arrays of numbers or GPU code.
What is the difference between signed and unsigned?
A signed type can represent negative values; an unsigned type cannot, but has twice the positive range. A signed 8-bit type ranges from -128 to 127, an unsigned one from 0 to 255. Unsigned arithmetic wraps around on overflow, and mixing signed and unsigned in a comparison converts the signed value to unsigned, which can make -1 compare greater than 3.
Why is 0.1 + 0.2 not equal to 0.3 in C?
Binary floating-point cannot represent 0.1, 0.2 or 0.3 exactly, so each is stored as the nearest representable double. The rounding errors don't cancel: 0.1 + 0.2 comes out as 0.30000000000000004 while 0.3 is stored as 0.29999999999999999. Compare floating-point values within a tolerance instead of with ==.
What does volatile do in C?
volatile tells the compiler that a variable can change in ways it cannot see, such as from a signal handler or a hardware register, so every read must actually load it from memory. Without it, an optimizing compiler may read the value once and loop forever. It is not a thread-synchronization tool; use atomics or mutexes for that.