Beginner

Variables and Types in C

Learn about variables, data types, type conversions, and memory layout in C with practical Docker-ready examples

C is a statically typed, weakly typed language — every variable has a fixed type declared at compile time, but the compiler allows many implicit conversions between types. This combination gives C its characteristic power and its notorious pitfalls. Understanding C’s type system means understanding how data sits in memory, which is the foundation of systems programming.

C’s type system is deliberately minimal. Rather than hiding memory layout behind abstractions, C exposes it directly. An int is a machine word. A char is a byte. A float is an IEEE 754 single-precision value. What you declare is what you get — no boxing, no object wrappers, no runtime type information.

In this tutorial you’ll learn to declare variables of C’s fundamental types, understand their sizes and ranges, work with constants, and perform type conversions — both implicit and explicit.

Fundamental Types

C provides a small set of built-in types that map directly to hardware representations. The exact size of some types is platform-dependent, but the standard guarantees minimums.

Create a file named variables.c:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#include <stdio.h>

int main(void) {
    /* Integer types */
    char   c  = 'A';       /* 1 byte: -128 to 127 (or 0 to 255 unsigned) */
    short  s  = 1000;      /* at least 2 bytes */
    int    i  = 42;        /* at least 2 bytes, usually 4 */
    long   l  = 100000L;   /* at least 4 bytes */

    /* Floating-point types */
    float  f  = 3.14f;     /* single precision, ~7 decimal digits */
    double d  = 3.141592653589793; /* double precision, ~15 decimal digits */

    /* Boolean (C99 and later via stdbool.h) */
    _Bool flag = 1;        /* 0 = false, non-zero = true */

    /* Print each variable with its size in bytes */
    printf("char   c  = '%c'  (size: %zu bytes)\n", c,  sizeof(c));
    printf("short  s  = %d    (size: %zu bytes)\n",  s,  sizeof(s));
    printf("int    i  = %d    (size: %zu bytes)\n",  i,  sizeof(i));
    printf("long   l  = %ld   (size: %zu bytes)\n",  l,  sizeof(l));
    printf("float  f  = %.2f  (size: %zu bytes)\n",  f,  sizeof(f));
    printf("double d  = %.15f (size: %zu bytes)\n",  d,  sizeof(d));
    printf("_Bool  flag = %d  (size: %zu bytes)\n",  flag, sizeof(flag));

    return 0;
}

Fixed-Width Integer Types

Because int and long sizes vary by platform, C99 introduced <stdint.h> with fixed-width types. These are essential for portable code, network protocols, file formats, and embedded work where exact bit widths matter.

Create a file named variables_fixed.c:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <stdio.h>
#include <stdint.h>   /* fixed-width integer types */
#include <inttypes.h> /* PRId8, PRId32, etc. format macros */

int main(void) {
    int8_t   a = -100;          /* exactly 8 bits, signed */
    uint8_t  b = 255;           /* exactly 8 bits, unsigned */
    int16_t  c = -32000;        /* exactly 16 bits, signed */
    uint16_t d = 65535;         /* exactly 16 bits, unsigned */
    int32_t  e = 2147483647;    /* exactly 32 bits, signed (INT32_MAX) */
    uint32_t f = 4294967295U;   /* exactly 32 bits, unsigned (UINT32_MAX) */
    int64_t  g = -9223372036854775807LL - 1; /* INT64_MIN */
    uint64_t h = 18446744073709551615ULL;    /* UINT64_MAX */

    printf("int8_t   a = %" PRId8  "\n", a);
    printf("uint8_t  b = %" PRIu8  "\n", b);
    printf("int16_t  c = %" PRId16 "\n", c);
    printf("uint16_t d = %" PRIu16 "\n", d);
    printf("int32_t  e = %" PRId32 "\n", e);
    printf("uint32_t f = %" PRIu32 "\n", f);
    printf("int64_t  g = %" PRId64 "\n", g);
    printf("uint64_t h = %" PRIu64 "\n", h);

    return 0;
}

Constants and the const Qualifier

C provides two ways to define constants: the const qualifier and preprocessor #define macros. They serve different purposes and behave differently.

Create a file named variables_const.c:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <stdio.h>

/* Preprocessor macro: replaced by the preprocessor before compilation.
   No type, no memory address, cannot be passed by pointer. */
#define MAX_BUFFER_SIZE 1024
#define PI_APPROX 3.14159

int main(void) {
    /* const-qualified variable: has a type, lives in memory,
       but the compiler prevents assignment after initialization. */
    const int    MAX_RETRIES = 3;
    const double GRAVITY     = 9.80665;  /* m/s^2, standard gravity */
    const char   NEWLINE     = '\n';

    printf("MAX_BUFFER_SIZE (macro) = %d\n", MAX_BUFFER_SIZE);
    printf("PI_APPROX (macro)       = %.5f\n", PI_APPROX);
    printf("MAX_RETRIES (const int) = %d\n", MAX_RETRIES);
    printf("GRAVITY (const double)  = %.5f m/s^2\n", GRAVITY);
    printf("NEWLINE (const char)    = '\\n' (ASCII %d)%c", NEWLINE, NEWLINE);

    /* Attempting to modify a const variable is a compile-time error:
       MAX_RETRIES = 5;  // error: assignment of read-only variable */

    return 0;
}

Type Conversions

C performs many conversions automatically (implicit conversion, also called “coercion”). Knowing when and how these happen prevents subtle bugs — especially integer overflow and floating-point precision loss.

Create a file named variables_convert.c:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
#include <stdio.h>

int main(void) {
    /* Implicit widening: smaller type promotes to larger */
    int   i = 42;
    long  l = i;    /* int promoted to long automatically */
    double d = i;   /* int promoted to double automatically */
    printf("int %d -> long %ld -> double %f\n", i, l, d);

    /* Implicit narrowing (truncation): larger to smaller loses data */
    double pi = 3.14159;
    int truncated = pi;  /* fractional part discarded, no rounding */
    printf("double %.5f -> int %d (truncated)\n", pi, truncated);

    /* Explicit cast: programmer acknowledges the conversion */
    int dividend = 7;
    int divisor  = 2;
    double result = (double)dividend / divisor;  /* cast before division */
    int integer_div = dividend / divisor;         /* integer division */
    printf("%d / %d = %f (float div), %d (integer div)\n",
           dividend, divisor, result, integer_div);

    /* Signed/unsigned mixing — a common source of bugs */
    unsigned int u = 10;
    int negative = -1;
    /* When mixing signed and unsigned, the signed value is converted
       to unsigned, turning -1 into a very large number. */
    if ((unsigned int)negative > u) {
        printf("-1 as unsigned (%u) is greater than 10\n",
               (unsigned int)negative);
    }

    /* char arithmetic: char is just a small integer */
    char ch = 'a';
    printf("'a' = %c, 'a' + 1 = %c, 'a' + 25 = %c\n",
           ch, ch + 1, ch + 25);

    return 0;
}

Running with Docker

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
# Pull the official GCC image
docker pull gcc:14

# Compile and run the basic types example
docker run --rm -v $(pwd):/app -w /app gcc:14 \
    sh -c 'gcc -o variables variables.c && ./variables'

# Compile and run the fixed-width types example
docker run --rm -v $(pwd):/app -w /app gcc:14 \
    sh -c 'gcc -o variables_fixed variables_fixed.c && ./variables_fixed'

# Compile and run the constants example
docker run --rm -v $(pwd):/app -w /app gcc:14 \
    sh -c 'gcc -o variables_const variables_const.c && ./variables_const'

# Compile and run the type conversions example
docker run --rm -v $(pwd):/app -w /app gcc:14 \
    sh -c 'gcc -o variables_convert variables_convert.c && ./variables_convert'

Expected Output

Output for variables.c:

char   c  = 'A'  (size: 1 bytes)
short  s  = 1000    (size: 2 bytes)
int    i  = 42    (size: 4 bytes)
long   l  = 100000   (size: 8 bytes)
float  f  = 3.14  (size: 4 bytes)
double d  = 3.141592653589793 (size: 8 bytes)
_Bool  flag = 1  (size: 1 bytes)

Output for variables_fixed.c:

int8_t   a = -100
uint8_t  b = 255
int16_t  c = -32000
uint16_t d = 65535
int32_t  e = 2147483647
uint32_t f = 4294967295
int64_t  g = -9223372036854775808
uint64_t h = 18446744073709551615

Output for variables_const.c:

MAX_BUFFER_SIZE (macro) = 1024
PI_APPROX (macro)       = 3.14159
MAX_RETRIES (const int) = 3
GRAVITY (const double)  = 9.80665 m/s^2
NEWLINE (const char)    = '\n' (ASCII 10)

Output for variables_convert.c:

int 42 -> long 42 -> double 42.000000
double 3.14159 -> int 3 (truncated)
7 / 2 = 3.500000 (float div), 3 (integer div)
-1 as unsigned (4294967295) is greater than 10
'a' = a, 'a' + 1 = b, 'a' + 25 = z

Key Concepts

  • Static typing: Every variable’s type is fixed at compile time; the compiler rejects type mismatches it cannot implicitly resolve.
  • Weak typing: C allows many implicit conversions (int to double, char to int) without complaint, including lossy ones — the programmer is responsible for correctness.
  • Size is platform-dependent for basic types: int, long, and pointer sizes vary by architecture; use <stdint.h> fixed-width types (int32_t, uint64_t) when exact sizes matter.
  • sizeof is your friend: Use sizeof(type) or sizeof(variable) to get the actual byte size on your platform; it’s evaluated at compile time.
  • const vs #define: const variables have types and addresses and are preferred in modern C; #define macros are textual substitution with no type safety.
  • Integer division truncates toward zero: 7 / 2 is 3, not 3.5; cast at least one operand to double if you need a fractional result.
  • Signed/unsigned mixing is dangerous: When a signed negative value mixes with an unsigned type, the signed value converts to unsigned, producing unexpectedly large numbers.
  • char is an integer: Character variables participate in arithmetic; 'a' + 1 equals 'b' because character encoding (ASCII/UTF-8) assigns contiguous numeric values to letters.

Running Today

All examples can be run using Docker:

docker pull gcc:14
Last updated:

Comments

Loading comments...

Leave a Comment

2000 characters remaining