How to Use Union Types in Rust

Define a union with the `union` keyword and access its fields inside an `unsafe` block to manage overlapping memory safely.

When memory must be shared, not owned

You're writing a low-level driver for a sensor that reports data in two formats. The hardware writes exactly 4 bytes to a register. Sometimes those bytes represent a temperature reading as an integer. Sometimes they represent a calibration factor as a float. You know the data is one or the other, but you don't want the runtime cost of checking a tag every time. You want the bytes to be exactly what the hardware puts there, with zero overhead.

In Python, you'd just reassign the variable. In Rust, you reach for an enum. But enum adds a discriminant tag to track which variant is active. That tag takes space and requires a check. You need the raw layout. You need a union.

Concept: overlapping memory with no tag

A union is a type where all fields share the same memory address. The size of the union is the size of its largest field. The alignment is the alignment of its most strictly aligned field. Fields overlap completely. Writing to one field overwrites the others.

Think of a union like a single slot in a vending machine. The slot can hold a can of soda or a bag of chips. It cannot hold both. If you put a bag of chips in and then try to grab a can, you're grabbing a bag of chips and pretending it's a can. The machine doesn't stop you. It just dispenses whatever is in the slot.

Rust treats unions as inherently unsafe. The compiler cannot track which field is active. There is no hidden tag. There is no runtime check. If you read the wrong field, you get undefined behavior. The compiler forces you to wrap access in unsafe to acknowledge this risk.

Minimal example

/// A union holding either an integer or a float.
/// Both fields occupy the same 4 bytes of memory.
union Number {
    int_val: i32,
    float_val: f32,
}

fn main() {
    // Initialize the union by setting one field.
    // The other field contains uninitialized memory.
    let n = Number { int_val: 42 };

    // Accessing union fields is unsafe.
    // The compiler cannot verify which field is valid.
    unsafe {
        println!("Integer: {}", n.int_val);
    }
}

You initialize a union by specifying one field. The other fields are uninitialized. Accessing any field requires an unsafe block. The compiler rejects direct access with E0512 (cannot access field of union ... outside of an unsafe block). This is a hard gate. You cannot bypass it.

Layout and memory behavior

When you define Number, Rust allocates 4 bytes on the stack. i32 takes 4 bytes. f32 takes 4 bytes. The union is 4 bytes. If you defined a union with u8 and i32, the union would still be 4 bytes. The u8 field would share the first byte of the i32 field, and the remaining 3 bytes would be padding. The layout is determined by the largest and most aligned field.

This overlap is the source of power and danger. Writing to int_val changes the bits that float_val sees. If you write 42 to int_val, reading float_val interprets those bits as a float. You get 42.0 only if the bit pattern happens to match. For 42, the integer pattern and float pattern differ. You'll get garbage.

Unions are raw memory with a type system overlay. The overlay only exists to help you write the code, not to protect you at runtime.

Realistic example: safe bit-casting

In practice, you rarely write raw unions. You use them to build safe abstractions. The standard library uses unions internally for methods like f32::from_bits. Here is how you might implement a safe bit-cast function.

/// A union to reinterpret bits between i32 and f32.
union Bits {
    i: i32,
    f: f32,
}

/// Safely converts an i32 bit pattern to an f32.
fn i32_to_f32(bits: i32) -> f32 {
    // Create union with integer bits.
    let u = Bits { i: bits };

    // Read float interpretation.
    // SAFETY: We just initialized `i`, so reading `f` is valid bit-casting.
    // Both types have the same size and alignment.
    // Neither type implements Drop, so no destructor ambiguity exists.
    unsafe { u.f }
}

fn main() {
    let bits = 0x40490FDB; // Bit pattern for 3.14159...
    let pi = i32_to_f32(bits);
    println!("Pi approx: {}", pi);
}

The // SAFETY: comment lists the invariants. We initialized i. We read f. Both have the same size. No drop. This is valid. The public function i32_to_f32 is safe because it enforces the invariants internally.

Convention aside: The community prefers checking for existing from_bits or to_bits methods before writing custom unions. The standard library provides these for floats and integers. If you are doing bit-casting, use the standard methods. They are optimized and audited. Write your own union only when the standard library doesn't cover your case, such as custom binary formats or FFI structures.

When calling C code, unions often need #[repr(C)]. This tells Rust to lay out the union exactly like C does. Without it, Rust might reorder fields or add padding differently. For FFI, always use #[repr(C)] on unions to match the ABI.

Pitfalls and compiler errors

Destructors are forbidden. You cannot put a String in a union. The compiler rejects this. String implements Drop. When a union goes out of scope, Rust must drop its contents. But Rust doesn't know which field is active. If you drop both, you double-free. If you drop neither, you leak. The language forbids union fields with destructors to prevent this ambiguity.

Initialization tracking is manual. You can only initialize one field. The others are uninitialized. Reading an uninitialized field is undefined behavior. Even if the field is a u8, reading it before writing is UB. The compiler doesn't track initialization. You must track it yourself.

If you find yourself writing a struct containing a union and a boolean flag to track which field is active, you are reinventing enum. Stop and use enum instead. The enum provides the tag and the safety guarantees automatically.

If you can drop it, don't put it in a union. Use an enum for owned data.

Decision: when to use union vs alternatives

Use union when you need zero-cost bit reinterpretation between primitive types of the same size, such as converting integer bits to a float for a custom binary format.

Use union when you are interfacing with C code that defines a union, and you need the exact memory layout to match the ABI for #[repr(C)] compatibility.

Use enum when you need to store values of different types that might have different sizes, or when you need Rust to track which variant is active at runtime.

Use enum when your data involves types that implement Drop, like String, Vec, or Box.

Use std::mem::transmute when you want a concise bit cast and are willing to accept the compiler warning that this is a last-resort tool with no layout guarantees.

Reach for enum first. union is a scalpel for memory layout, not a general-purpose type.

Where to go next