Guide

Records can be stored two ways. An array of structs (AoS) keeps each record's fields together. A struct of arrays (SoA) keeps each field in its own array. The arithmetic is the same either way. The difference is how many of the bytes you load are bytes you actually use.

Concept

Before you start: a struct is a record of named fields. Cache lines are 64-byte blocks (see cache lines and locality). Every field you do not read still rides along inside any line you load.

The big idea

Think of a warehouse. In AoS, each box holds a whole product, its label and its packing slip. If you only need the labels, you still haul every box. In SoA, all the labels sit on one shelf. You walk the shelf and read only labels.

The analogy breaks down a little. Caches load lines, not boxes, so the win comes from packing the fields you read next to each other.

Walking through it: summing one field

Suppose a simulation keeps a million particles. Each frame it needs the total mass. Here is the AoS version. It is a sketch, with the struct trimmed to the fields that matter:

Go
// Array of structs: each particle is 64 bytes
type Particle struct {
	X, Y, Z    float64
	VX, VY, VZ float64
	Mass       float64
	ID         int64
}
particles := make([]Particle, 1_000_000)

total := 0.0
for i := range particles {
	total += particles[i].Mass // uses 8 of every 64 bytes loaded
}

Each particle is exactly one 64-byte cache line, and the loop uses 8 bytes of it. You pull 1,000,000 × 64 B = 64 MB through the memory system to use 8 MB of data. That is 12.5% utilization.

The SoA version stores each field in its own slice:

Go
// Struct of arrays: each field is its own contiguous slice
type Particles struct {
	X, Y, Z    []float64
	VX, VY, VZ []float64
	Mass       []float64
	ID         []int64
}

total := 0.0
for _, m := range ps.Mass {
	total += m // every byte loaded is used
}

The loop now reads exactly 8 MB, all of it useful, in sequence. The compiler can also use SIMD to add several values per instruction. For loops that touch one or two fields of many records, SoA is often several times faster.

When AoS wins

AoS is better when you usually need all the fields of one record together. Rendering one particle, or loading one user to show a profile page, reads a whole record once. There, keeping the fields together means one cache line serves the whole job.

This is the reason analytical databases such as ClickHouse, DuckDB and BigQuery store data by column, because analytics scans a few columns over many rows. Transactional databases such as Postgres store data by row, because a transaction usually reads or writes whole rows.

Tip

Choose the layout from the access pattern, not from habit. Ask which fields a hot loop reads together. If it reads one or two fields across many records, lean toward SoA. If it reads whole records, keep AoS.

Common mistakes

  • Storing a large struct in a slice and scanning one field. Every scan drags the other fields through the cache.
  • Switching to SoA everywhere. It makes single-record access, such as GetUser(id), touch many arrays.
  • Trusting the layout without measuring. Compilers and allocators can change what really sits next to what.

Check yourself

  • Why does AoS waste 87.5% of its bandwidth in the particle loop? Each 64-byte line holds one particle, and the loop uses only the 8-byte mass field.
  • Which layout suits a GetUser(id) call that reads every field? AoS, because one record's fields sit together in one place.

Key takeaways

  • A cache line carries whatever sits next to the field you read. Layout decides how much of it is useful.
  • SoA wins for scans over one or two fields across many records. AoS wins when you read whole records.
  • Analytical engines are column-oriented for this reason. Transactional engines are row-oriented.

Further learning

Next, see how threads sharing one cache line slow each other down, in false sharing and NUMA.

For how this fits into the bigger picture of data storage, see data structures in practice.