A struct gives every member its own separate storage. A union does the opposite.
Every member of a union shares the exact same storage. Only one interpretation of that storage is meaningful at a time. Writing through one member and then reading back through a different member reinterprets those same underlying bits as a different type.
11 min read | Intermediate Level
What You'll Learn
- How a union's members share the same storage, unlike a struct's separate storage per member
- Why a union's size equals its largest member, not the sum of all of them
- How a packed union lets you reinterpret the same bits as different types
- Why every member of a packed union must be exactly the same size
Union vs Struct: Shared Storage
Take a struct with a 16-bit member and an 8-bit member. It occupies at least 24 bits, because each member gets its own space.
Now take a union with those exact same two members. It occupies only 16 bits — the size of its largest member. Every member of a union is really just a different way of looking at the same storage, not separate storage of its own.
The diagram below shows the difference using these same numbers:
Design use: a union is a compact way to model hardware where the same bits genuinely mean different things depending on context, like a shared register field. Verification use: a union is handy in a testbench for reinterpreting a packet's raw bytes as a structured header without writing manual bit-slicing code.
Struct vs Union: Same Fields, Two Behaviors
You already know how a struct behaves, so start there as a quick recap. Here's a struct with a 32-bit raw field alongside a 16-bit high and a 16-bit low:
typedef struct packed {
bit [31:0] raw;
bit [15:0] high;
bit [15:0] low;
} data_struct_t;
module tb;
initial begin
data_struct_t d;
d.raw = 32'hDEAD_BEEF;
$display ("raw = 0x%0h", d.raw);
$display ("high = 0x%0h", d.high);
$display ("low = 0x%0h", d.low);
end
endmodule
raw = 0xdeadbeef high = 0x0 low = 0x0 - S i m u l a t i o n R e p o r t: Verilator 5.052 2026-09-05
The struct is 64 bits wide — 32 for raw, plus 16 for high, plus 16 for low — because each member gets its own separate space. Assigning d.raw only touches raw's own bits. high and low are untouched, so they still show their default value of 0.
Now here's the same idea, but as a union. A packed union is the most commonly used form in practice. It lets you view one fixed-width value either as a flat vector or as named sub-fields:
typedef union packed {
bit [31:0] raw;
struct packed {
bit [15:0] high; // declared first - occupies the upper (most significant) half
bit [15:0] low; // declared second - occupies the lower (least significant) half
} halves;
} data_t;
module tb;
initial begin
data_t d;
d.raw = 32'hDEAD_BEEF;
$display ("raw = 0x%0h", d.raw);
$display ("high = 0x%0h", d.halves.high);
$display ("low = 0x%0h", d.halves.low);
end
endmodule
raw = 0xdeadbeef high = 0xdead low = 0xbeef - S i m u l a t i o n R e p o r t: Verilator 5.052 2026-09-05
This prints raw = 0xdeadbeef, high = 0xdead, low = 0xbeef — a very different result from the struct version above, even though the assignment looks the same.
Nothing was actually copied or split apart here. raw and halves occupy the exact same 32 bits of storage. Assigning through raw and reading back through halves.high and halves.low is just viewing those same bits through a different, more structured lens.
As with a packed struct, the first-declared member of the inner struct — high — occupies the most significant bits. The diagram below shows exactly how the 32 bits of 0xDEADBEEF break down into high and low:
Compare the two results: in the struct, assigning raw touched nothing else, so high and low stayed at 0. In the union, that same assignment instantly changed what high and low read back, because they were never separate storage to begin with. One assignment either touching everything or touching nothing is the entire practical difference between a union and a struct.
Quick Check
Q: If d.halves.low were assigned 16'h1234 directly, what would d.raw show afterward?
Show Answer
0xdead1234. Since raw and halves share the same storage, changing the lower 16 bits through halves.low is visible immediately through raw too — the upper 16 bits (0xdead, still held by halves.high) are untouched.
Why All Packed Untagged Union Members Must Be the Same Size
Every member of a packed, untagged union has to occupy exactly the same number of bits — a packed tagged union is explicitly exempt from this. In the example above, raw (a plain 32-bit vector) and halves (a 16+16-bit packed struct) are both exactly 32 bits wide.
This isn't a coincidence — it's a requirement for the untagged form specifically. A packed, untagged union has no way to represent "this member is only valid for part of the storage." Every member is understood to span the union's entire width, so they all have to actually be that width. A tagged union stores an explicit tag alongside the value, so it doesn't have this restriction — see "A Note on Tagged Unions" below.
A Note on Tagged Unions
The union shown above is untagged. Nothing stops you from writing through one member and reading back through a completely different one, as the example deliberately does.
A union tagged {...} is the type-checked alternative. It tracks which member was actually written, and only allows reading that same member back safely. If accidentally reading the wrong interpretation of a union's bits would be a real risk in a given piece of code, a tagged union removes that risk entirely — at the cost of losing the free-form reinterpretation this article's main example relies on.
Where Are Unions Used?
A packed union is a natural fit for a register model. It represents the same 32-bit register value as both a flat vector (for simple reads and writes) and a named-field breakdown (for readable access to individual control bits), without duplicating any storage or manually shifting and masking bits every time either view is needed.
On the verification side, a testbench can use the same trick to reinterpret a raw byte stream captured off a bus as a structured transaction, without writing a separate parser.
Common Beginner Mistakes
Mistake #1: Assuming a union's size is the sum of its members
That's how a struct works — a union is the opposite. Why? A union's size is always just the size of its single largest member, since every member shares that same storage rather than getting its own.
Mistake #2: Mixing different-sized members in a packed, untagged union
Every member of a packed, untagged union has to be exactly the same width — a packed tagged union is the one place this restriction doesn't apply. Why? An untagged packed union has no mechanism for a member that only covers part of the total storage, since there's no tag to say which member is actually valid — if the members genuinely need to be different sizes, a tagged union is the right tool instead.
Mistake #3: Forgetting that all members share the same bits
As the Quick Check shows, changing one member is immediately visible through every other member, since there's only one underlying storage location. Why? This is the entire point of a union, but it's easy to forget mid-code and accidentally assume two members hold independent values.
Mistake #4: Assuming an untagged union offers any type safety
Writing through one member and reading through a completely unrelated one always succeeds, with no checking at all. Why? If that flexibility is actually a risk in a specific context, a tagged union is the safer choice — an untagged union simply doesn't protect against it.
Practice Exercise
Challenge: Split a 16-bit Value Into Bytes
Define a packed union with a 16-bit word member and a packed struct member holding two 8-bit fields, msb and lsb. Assign word = 16'hA5C3; and predict what msb and lsb would each show.
Hint
Remember that the first-declared member of a packed struct occupies the most significant bits — decide which field needs to come first to match its name.
Solution
typedef union packed {
bit [15:0] word;
struct packed {
bit [7:0] msb;
bit [7:0] lsb;
} bytes;
} word_t;
module tb;
initial begin
word_t w;
w.word = 16'hA5C3;
$display ("msb=0x%0h lsb=0x%0h", w.bytes.msb, w.bytes.lsb);
end
endmodule
This prints msb=0xa5 lsb=0xc3. 16'hA5C3 splits into an upper byte 0xA5 and a lower byte 0xC3 — and since msb is declared first in the packed struct, it correctly lands on the upper (most significant) byte, matching its name.
Summary
In this tutorial, you learned:
- A union's members all share the same storage; a struct gives each member its own
- A union's size equals its largest member, not the sum of all its members
- A packed, untagged union requires every member to be exactly the same size, since none of them represent only part of the storage — a packed
taggedunion is exempt from this, since it tracks an explicit tag - Writing through one member and reading through another reinterprets the same bits — a technique packed unions are commonly used for, like viewing a register as both a flat vector and a named-field breakdown
- An untagged union provides no type safety at all; a
taggedunion exists specifically for when that safety matters