Skip to main content
C · lesson 14 of 25
C intermediate Lesson 14 of 25

Unions and Enums in C

C unions and enums in practice: shared memory layout, tagged unions, reading float bits, enum values, and how -Wswitch catches missing cases.

By SkillByExample · Updated

Union Basics and Memory Layout

A union is big enough for its largest member, and every member starts at the same address. Writing one member overwrites the others:

#include <stdio.h>

union Data {
    int    i;
    float  f;
    double d;
    unsigned char bytes[8];
};

int main(void) {
    union Data u;
    printf("sizeof(union Data) = %zu\n", sizeof u);
    printf("&u.i = %p\n&u.d = %p\n", (void *)&u.i, (void *)&u.d);

    u.i = 42;
    printf("wrote i = 42   -> i = %d\n", u.i);
    u.f = 1.0f;                           /* overwrites the same bytes */
    printf("wrote f = 1.0f -> f = %.1f, i = %d\n", u.f, u.i);

    printf("bytes of 1.0f: ");
    for (size_t k = 0; k < sizeof(float); k++) printf("%02X ", u.bytes[k]);
    printf("\n");
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -o union union.c
$ ./union
sizeof(union Data) = 8
&u.i = 0x7ffcf6ce4860
&u.d = 0x7ffcf6ce4860
wrote i = 42   -> i = 42
wrote f = 1.0f -> f = 1.0, i = 1065353216
bytes of 1.0f: 00 00 80 3F 

i and d have the same address, so there’s one set of 8 bytes shared four ways. After u.f = 1.0f, reading u.i doesn’t give 42 or 1. It gives 1065353216, which is 0x3F800000, the IEEE 754 bit pattern of 1.0f read as an integer. The bytes print “backwards” (00 00 80 3F) because x86 is little-endian, so the lowest-order byte comes first in memory.

Tagged Unions

On its own, a union doesn’t know which member is live. The standard pattern pairs it with an enum tag that records it:

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

typedef enum { VAL_INT, VAL_FLOAT, VAL_STRING, VAL_BOOL } ValueType;

typedef struct {
    ValueType type;           /* the tag: which member is live */
    union {                   /* anonymous union (C11) */
        int    i;
        double f;
        char  *s;
        int    b;
    };
} Value;

Value make_int(int i)      { return (Value){.type = VAL_INT,   .i = i}; }
Value make_float(double f) { return (Value){.type = VAL_FLOAT, .f = f}; }
Value make_bool(int b)     { return (Value){.type = VAL_BOOL,  .b = !!b}; }
Value make_string(const char *s) {
    Value v = {.type = VAL_STRING};
    v.s = malloc(strlen(s) + 1);
    if (v.s) strcpy(v.s, s);
    return v;
}

void print_value(const Value *v) {
    switch (v->type) {
        case VAL_INT:    printf("int(%d)\n", v->i); break;
        case VAL_FLOAT:  printf("float(%.4g)\n", v->f); break;
        case VAL_STRING: printf("string(\"%s\")\n", v->s); break;
        case VAL_BOOL:   printf("bool(%s)\n", v->b ? "true" : "false"); break;
    }
}

int main(void) {
    Value vals[] = { make_int(42), make_float(3.14159), make_string("hello"), make_bool(7) };
    printf("sizeof(Value) = %zu\n", sizeof(Value));
    for (size_t k = 0; k < sizeof vals / sizeof vals[0]; k++) {
        print_value(&vals[k]);
        if (vals[k].type == VAL_STRING) free(vals[k].s);
    }
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -o tagged tagged.c
$ ./tagged
sizeof(Value) = 16
int(42)
float(3.142)
string("hello")
bool(true)

Four kinds of value fit in 16 bytes: a 4-byte tag, 4 bytes of padding, and an 8-byte union. As a struct holding all four members, it would take 32. Every read goes through switch (v->type), so no code reads a member that isn’t live. The anonymous union lets you write v->i instead of v->u.i. make_bool(7) stores !!7, which is 1. The string copy uses malloc + strcpy rather than strdup, because strdup is POSIX, and only standard C from C23. Under strict -std=c17 it isn’t even declared:

$ gcc -Wall -Wextra -std=c17 -o tagged_strdup tagged_strdup.c
tagged_strdup.c: In function 'main':
tagged_strdup.c:6:15: error: implicit declaration of function 'strdup'; did you mean 'strcmp'? [-Wimplicit-function-declaration]
    6 |     char *s = strdup("hello");
      |               ^~~~~~
      |               strcmp
tagged_strdup.c:6:15: error: initialization of 'char *' from 'int' makes pointer from integer without a cast [-Wint-conversion]

Compiling with -std=gnu17 (GCC’s default) or -D_POSIX_C_SOURCE=200809L makes strdup available.

Looking at the Bits of a Float

Writing a float and reading a uint32_t from the same union shows the raw IEEE 754 encoding. C allows this “type punning” through a union:

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

union FloatBits {
    float    f;
    uint32_t bits;
};

void print_float_bits(float f) {
    union FloatBits u = {.f = f};
    /* IEEE 754 single: 1 sign bit, 8 exponent bits, 23 mantissa bits */
    printf("%-6g = 0x%08X  sign=%u exp=%3u mant=0x%06X\n", f, u.bits,
           (unsigned)(u.bits >> 31), (unsigned)((u.bits >> 23) & 0xFF),
           (unsigned)(u.bits & 0x7FFFFF));
}

int main(void) {
    print_float_bits(1.0f);
    print_float_bits(-1.0f);
    print_float_bits(0.5f);
    print_float_bits(3.14f);
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -o floatbits floatbits.c
$ ./floatbits
1      = 0x3F800000  sign=0 exp=127 mant=0x000000
-1     = 0xBF800000  sign=1 exp=127 mant=0x000000
0.5    = 0x3F000000  sign=0 exp=126 mant=0x000000
3.14   = 0x4048F5C3  sign=0 exp=128 mant=0x48F5C3

1.0 and -1.0 differ in exactly one bit, the sign. The exponent is stored with a bias of 127: 127 means 2⁰, so 0.5 = 2⁻¹ is stored as 126. 3.14 is 1.57 × 2¹, so its exponent is 128, and its mantissa is a long non-repeating pattern because 3.14 has no exact binary form. In C++, this union trick is undefined behavior and you’d use memcpy or std::bit_cast. In C it’s allowed.

Enums

An enum defines named integer constants. By default they count up from 0, and any value can be set explicitly:

#include <stdio.h>

enum Direction { NORTH, EAST, SOUTH, WEST };
enum HttpStatus { HTTP_OK = 200, HTTP_CREATED, HTTP_NOT_FOUND = 404, HTTP_GONE = 410 };
enum Permission {
    PERM_READ    = 1 << 0,
    PERM_WRITE   = 1 << 1,
    PERM_EXECUTE = 1 << 2,
};

int main(void) {
    printf("NORTH=%d EAST=%d SOUTH=%d WEST=%d\n", NORTH, EAST, SOUTH, WEST);
    printf("HTTP_OK=%d HTTP_CREATED=%d HTTP_GONE=%d\n", HTTP_OK, HTTP_CREATED, HTTP_GONE);

    unsigned perms = PERM_READ | PERM_WRITE;
    printf("perms=%u write? %s execute? %s\n", perms,
           (perms & PERM_WRITE) ? "yes" : "no", (perms & PERM_EXECUTE) ? "yes" : "no");
    perms |= PERM_EXECUTE;       /* grant */
    perms &= ~PERM_WRITE;        /* revoke */
    printf("perms=%u after grant/revoke\n", perms);

    enum Direction d = 42;       /* no error: enums are just ints */
    printf("d = %d, sizeof(enum Direction) = %zu\n", d, sizeof d);
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -o enums enums.c
$ ./enums
NORTH=0 EAST=1 SOUTH=2 WEST=3
HTTP_OK=200 HTTP_CREATED=201 HTTP_GONE=410
perms=3 write? yes execute? no
perms=5 after grant/revoke
d = 42, sizeof(enum Direction) = 4

HTTP_CREATED was never given a value and became 201: an unset constant is always one more than the one before it. Power-of-two constants can be combined as flags. 5 is READ | EXECUTE after write was revoked. The last line shows C’s weak spot: enum Direction d = 42 compiled without a warning, because an enum variable is just an int and C doesn’t check that the value matches a named constant.

Letting the Compiler Check Your switch

C doesn’t restrict enum values, but GCC does check a switch over an enum for missing cases:

#include <stdio.h>

enum Direction { NORTH, EAST, SOUTH, WEST };

const char *direction_name(enum Direction d) {
    switch (d) {
        case NORTH: return "North";
        case EAST:  return "East";
        case SOUTH: return "South";
    }
    return "Unknown";
}

int main(void) {
    for (int d = NORTH; d <= WEST; d++) printf("%d: %s\n", d, direction_name(d));
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -o wswitch wswitch.c
wswitch.c: In function 'direction_name':
wswitch.c:6:5: warning: enumeration value 'WEST' not handled in switch [-Wswitch]
    6 |     switch (d) {
      |     ^~~~~~
$ ./wswitch
0: North
1: East
2: South
3: Unknown

The warning names the exact missing value. This check is the main reason to leave the default: label out of a switch over an enum. A default quietly swallows any value you forgot, and the warning goes away. With no default, adding a new enumerator later makes the compiler point at every switch that needs updating.

typedef enum and the Count Sentinel

A final _COUNT member equals the number of real values. That makes it useful for sizing lookup tables and validating input:

#include <stdio.h>

typedef enum {
    LOG_DEBUG,
    LOG_INFO,
    LOG_WARNING,
    LOG_ERROR,
    LOG_LEVEL_COUNT          /* sentinel: equals the number of real levels */
} LogLevel;

static const char *const LEVEL_NAMES[LOG_LEVEL_COUNT] = {
    [LOG_DEBUG] = "DEBUG", [LOG_INFO] = "INFO",
    [LOG_WARNING] = "WARNING", [LOG_ERROR] = "ERROR",
};

void log_message(LogLevel level, const char *msg) {
    if ((unsigned)level >= LOG_LEVEL_COUNT) {
        printf("[?] (bad level %d) %s\n", (int)level, msg);
        return;
    }
    printf("[%s] %s\n", LEVEL_NAMES[level], msg);
}

int main(void) {
    log_message(LOG_INFO, "server started");
    log_message(LOG_WARNING, "high memory usage");
    log_message(LOG_ERROR, "connection refused");
    log_message((LogLevel)9, "corrupted level");
    printf("LOG_LEVEL_COUNT = %d\n", LOG_LEVEL_COUNT);
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -o loglevel loglevel.c
$ ./loglevel
[INFO] server started
[WARNING] high memory usage
[ERROR] connection refused
[?] (bad level 9) corrupted level
LOG_LEVEL_COUNT = 4

Since enums don’t restrict values, the bounds check is what stops (LogLevel)9 from reading past the end of LEVEL_NAMES. Casting to unsigned makes a negative level into a huge number, so one comparison rejects both too-small and too-large values. Designated initializers ([LOG_INFO] = "INFO") tie each name to its constant, so reordering the enum can’t silently shift the names out of step.

Practice

  1. Build a tagged union Shape that holds either a circle (radius) or a rectangle (width, height), and write area() for it.
Solution
#include <stdio.h>

typedef enum { SHAPE_CIRCLE, SHAPE_RECT } ShapeKind;

typedef struct {
    ShapeKind kind;
    union {
        struct { double r; } circle;
        struct { double w, h; } rect;
    };
} Shape;

double area(const Shape *s) {
    switch (s->kind) {
        case SHAPE_CIRCLE: return 3.14159265 * s->circle.r * s->circle.r;
        case SHAPE_RECT:   return s->rect.w * s->rect.h;
    }
    return 0.0;
}

int main(void) {
    Shape shapes[] = {
        {.kind = SHAPE_CIRCLE, .circle = {2.0}},
        {.kind = SHAPE_RECT,   .rect = {3.0, 4.5}},
    };
    for (int i = 0; i < 2; i++) printf("area = %.2f\n", area(&shapes[i]));
    printf("sizeof(Shape) = %zu\n", sizeof(Shape));
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -o p1 p1.c
$ ./p1
area = 12.57
area = 13.50
sizeof(Shape) = 24

Each variant is its own small struct inside the union, so their field names can’t clash. Shape is 24 bytes: the tag plus padding (8), plus the larger variant, rect (16). The circle’s radius reuses the first 8 of those bytes.

  1. Use a union of uint32_t and uint8_t[4] to detect whether your machine is little- or big-endian.
Solution
#include <stdio.h>
#include <stdint.h>

int main(void) {
    union { uint32_t word; uint8_t bytes[4]; } u = {.word = 0x01020304};
    printf("first byte in memory: 0x%02X -> %s-endian\n",
           u.bytes[0], u.bytes[0] == 0x04 ? "little" : "big");
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -o p2 p2.c
$ ./p2
first byte in memory: 0x04 -> little-endian

The least significant byte, 0x04, is stored first, which is little-endian (x86, and ARM as normally configured). Network protocols use big-endian, which is why htonl/ntohl exist to convert between the two.

  1. Model a traffic light as an enum with a LIGHT_COUNT sentinel, and write next() so it cycles RED → YELLOW → GREEN → RED.
Solution
#include <stdio.h>

typedef enum { RED, YELLOW, GREEN, LIGHT_COUNT } Light;

Light next(Light l) {
    return (Light)((l + 1) % LIGHT_COUNT);
}

int main(void) {
    static const char *names[LIGHT_COUNT] = {"RED", "YELLOW", "GREEN"};
    Light l = GREEN;
    for (int i = 0; i < 4; i++) {
        printf("%s -> ", names[l]);
        l = next(l);
    }
    printf("%s\n", names[l]);
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -o p3 p3.c
$ ./p3
GREEN -> RED -> YELLOW -> GREEN -> RED

% LIGHT_COUNT wraps GREEN + 1 (3) back to RED (0). Because the sentinel updates automatically, adding a fourth light between YELLOW and GREEN needs no change to next().

  1. Add WEST to direction_name in the -Wswitch example so it compiles with no warnings.
Solution

Add case WEST: return "West"; to the switch, then rebuild:

$ gcc -Wall -Wextra -std=c17 -o wswitch wswitch.c
$ ./wswitch
0: North
1: East
2: South
3: West

The warning is gone and all four directions have names. The trailing return "Unknown"; is still needed, since d could hold a value like 42 that matches no case.

Frequently Asked Questions

What is the difference between a union and a struct?
In a struct, every member has its own storage, so the size is at least the sum of the members. In a union, all members share the same storage starting at the same address, so the size is that of the largest member and only the most recently written member holds a meaningful value.
Is type punning through a union allowed in C?
Yes, in C. Writing one member and reading another reinterprets the stored bytes as the other type; the C standard describes this explicitly (C99 TC3 and later). It is the rules of C++ that make it undefined behavior, which is where the common warning comes from. The result still depends on the platform's representation, such as endianness and IEEE 754 floats.
When would I actually use a union?
The main use is a tagged union: an enum recording which member is live, paired with a union holding the value, so one type can hold an int, a double or a string. Unions are also used to view the raw bytes of a value and, in embedded code, to map one hardware register in several ways.
Can enum values be negative, and does C check enum assignments?
Enum constants can be any int value, including negative ones. C does not check assignments: an enum variable can be given any integer, even one matching no named constant, without an error. GCC's -Wswitch (in -Wall) does warn when a switch over an enum misses a named value, which catches the most common mistake.