The Arrow columnar format

PyArrow · Expert · 15 min

The physical layout is what makes Arrow zero-copy across languages, and understanding it explains memory use and interop behaviour.

Every Arrow array is a few flat buffers plus metadata (type, length, null count, offset), defined by a language-independent specification.

  • Validity bitmap: one bit per slot, 1 = valid, 0 = null. It may be omitted entirely when there are no nulls.
  • Fixed-width types (ints, floats, timestamps): one values buffer, length * width bytes. Null slots still occupy space with unspecified contents.
  • Variable-size binary and strings: an offsets buffer of length + 1 int32 values plus a data buffer of concatenated bytes; value i is data[offsets[i]:offsets[i+1]]. large_string uses int64 offsets for more than 2 GB of data. Newer string view types store 16-byte views with short strings inline.
  • Lists: offsets into a child array. Structs: one child array per field. Dictionary arrays: indices plus a dictionary array.
  • Booleans are bit-packed values.

Buffers are aligned (64-byte alignment is recommended) for SIMD. A slice doesn't copy; it records an offset and length into the same buffers, which is why arr.offset can be non-zero and why serialising a small slice of a huge array can still carry the big buffers unless you compact it.

arr.buffers() returns the raw buffers so you can see this layout directly. Same bytes, same meaning in C++, Rust, Java, Go and JavaScript: that's what lets a Polars, DuckDB or Spark process hand data to Python without conversion.

Example

import pyarrow as pa

s = pa.array(["hi", None, "arrow"])
validity, offsets, data = s.buffers()
print(bin(validity.to_pybytes()[0]))                  # 0b101  (slot 1 is null)
print(pa.Array.from_buffers(pa.int32(), 4, [None, offsets]).to_pylist())
# [0, 2, 2, 7]   value i = data[offsets[i]:offsets[i+1]]
print(data.to_pybytes()[:7])                          # b'hiarrow'

ints = pa.array([1, 2, 3], type=pa.int32())
print(ints.buffers()[0], ints.buffers()[1].size)      # None 12  (no nulls: no bitmap)

sl = ints.slice(1, 2)
print(sl.offset, sl.buffers()[1].address == ints.buffers()[1].address)   # 1 True

Remember

  • Arrays = validity bitmap + values buffer (+ offsets / children)
  • Strings and lists use offsets into a shared data buffer
  • Slices share buffers and record an offset

Watch out for

  • Serialising a tiny slice of a huge array and shipping the whole underlying buffers.

Check yourself

  1. For a string array of length n, how many entries does its offsets buffer hold?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: arrow.apache.org/docs/format/Columnar.html