Arrays

Arrays are the core data structure in marrow. They are immutable and columnar — values sit contiguously in memory with a separate validity bitmap tracking null positions. Immutability is what makes a copy O(1): every array holds its data behind ref-counted buffers, so sharing one costs a refcount bump, never a memcpy.

ma.array() turns a Python list into a columnar array, inferring the type in one pass. None marks a null.

a = ma.array([1, 2, 3, None, 5])
b = ma.array([1, 2, 3], type=ma.int32())

array() takes the dtype as an argument, not a parameter, and lives in marrow.builders:

from marrow.builders import array, arange, nulls
from marrow.dtypes import int32, int64, Int32Type

var a = array([1, 2, 3, None, 5], int64)   # nulls at index 3
var b = array([True, False, True])          # BoolArray — no dtype needed
var c = array(["hello", None, "world"])     # StringArray
var d = arange[Int32Type](0, 10)

The rest of this page is the Python API; Building arrays in Mojo at the end covers the native builder API.

Primitive arrays

Primitive arrays hold fixed-size scalar values: booleans, integers, and floats.

# Integer arrays
i64 = ma.array([1, 2, 3, 4, 5])
i32 = ma.array([1, 2, 3, 4, 5], type=ma.int32())
u8  = ma.array([0, 127, 255],   type=ma.uint8())

print(i64)
print(i32)
print(u8)
PrimitiveArray[int64]([1, 2, 3, 4, 5])
PrimitiveArray[int32]([1, 2, 3, 4, 5])
PrimitiveArray[uint8]([0, 127, 255])
# Floating point
f32 = ma.array([1.0, 2.5, 3.14], type=ma.float32())
f64 = ma.array([1.0, 2.5, 3.14])   # float64 inferred
print(f32)
print(f64)
PrimitiveArray[float32]([1.0, 2.5, 3.14])
PrimitiveArray[float64]([1.0, 2.5, 3.14])
# Boolean
b = ma.array([True, False, True, False])
print(b)
BoolArray([True, False, True, False])

Null values

Pass None to mark a position as null. The validity bitmap tracks which positions are valid.

arr = ma.array([1, None, 3, None, 5])
print(arr)
print("null count:", arr.null_count)
print("is_valid:  ", arr.is_valid())     # elementwise BoolArray
print("is_valid(0):", arr[0].is_valid())   # True
print("is_valid(1):", arr[1].is_valid())   # False — null
PrimitiveArray[int64]([1, NULL, 3, NULL, 5])
null count: 2
is_valid:   BoolArray([True, False, True, False, True])
is_valid(0): True
is_valid(1): False
Note

Null is not zero. is_valid() reports False at index 1 — the value there is absent entirely, not 0 or empty. Array.is_valid() returns an elementwise BoolArray (like pyarrow.compute.is_valid); index into the array first (arr[1]) to check a single position.

Inspecting arrays

arr = ma.array([10, 20, 30, None, 50], type=ma.int32())

print("length:    ", len(arr))
print("null count:", arr.null_count)
print("type:      ", arr.type)
length:     5
null count: 1
type:       int32

String arrays

String arrays store variable-length UTF-8 strings.

s = ma.array(["hello", None, "world", "mañana"])
print(s)
print("null count:", s.null_count)
print("is_valid(1):", s[1].is_valid())
StringArray([hello, NULL, world, mañana])
null count: 1
is_valid(1): False

List arrays

List arrays store variable-length sequences. Each element is itself a list.

nested = ma.array([[1, 2], [3, 4, 5], [6]])
print(nested)
print("type:", nested.type)
ListArray([PrimitiveArray[int64]([1, 2]), PrimitiveArray[int64]([3, 4, 5]), PrimitiveArray[int64]([6])])
type: list<int64>

Nulls in list arrays

with_null = ma.array([[1, 2], None, [3, 4, 5]])
print(with_null)
print("null count:", with_null.null_count)
ListArray([PrimitiveArray[int64]([1, 2]), NULL, PrimitiveArray[int64]([3, 4, 5])])
null count: 1

Struct arrays

Struct arrays store rows of named fields — like a table with one row per element.

people = ma.array([
    {"name": "Alice", "age": 30},
    {"name": "Bob",   "age": 25},
    {"name": None,    "age": 40},   # null name
])
print(people)
print("type:", people.type)
StructArray({'name': StringArray([Alice, Bob, NULL]), 'age': PrimitiveArray[int64]([30, 25, 40])})
type: struct<name: string, age: int64>

Slicing

slice(offset, length) returns a zero-copy view of a sub-range. No data is copied.

arr = ma.array([10, 20, 30, None, 50, 60])
print("original: ", arr)
print("slice(2, 3):", arr.slice(2, 3))   # [30, NULL, 50]
print("slice(0, 4):", arr.slice(0, 4))   # [10, 20, 30, NULL]
original:  PrimitiveArray[int64]([10, 20, 30, NULL, 50, 60])
slice(2, 3): PrimitiveArray[int64]([30, NULL, 50])
slice(0, 4): PrimitiveArray[int64]([10, 20, 30, NULL])

Slicing works on all array types:

s = ma.array(["a", "b", "c", "d", "e"])
print(s.slice(1, 3))   # ["b", "c", "d"]
StringArray([b, c, d])

FixedSizeList arrays

FixedSizeList arrays store sequences of a fixed length per element — the natural layout for embedding vectors or coordinates, where every row has the same width.

Type inference always produces a variable-length list, even when every row happens to be the same length:

# inference sees lists — the result is list<float64>, not a fixed-size list
coords = ma.array([[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]])
print(coords.type)   # list<float64>
list<float64>

To get a genuine fixed_size_list, pass the type explicitly with fixed_size_list_(value_type, size). The fixed width is part of the type, which lets kernels skip per-row offset lookups:

coords = ma.array(
    [[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]],
    type=ma.fixed_size_list_(ma.float64(), 2),
)
print(coords)
print("type:", coords.type)   # fixed_size_list<item: float64>
FixedSizeListArray([PrimitiveArray[float64]([1.0, 2.0]), PrimitiveArray[float64]([3.0, 4.0]), PrimitiveArray[float64]([5.0, 6.0])])
type: fixed_size_list<item: float64>

Building arrays in Mojo

ma.array() is a convenience over the native builder API — the mutable staging area you accumulate values in before freezing them with finish(). Reach for it in Mojo when values arrive one at a time.

from marrow.builders import Int64Builder
from marrow.arrays import Int64Array

var b = Int64Builder(capacity=4)
b.append(10)
b.append(20)
b.append_null()
b.append(40)

var arr: Int64Array = b.finish()
print(arr)               # PrimitiveArray[int64]([10, 20, NULL, 40])

capacity pre-allocates the backing buffer; it is optional but avoids reallocation when the size is known.

StringBuilder tracks offsets for you:

from marrow.builders import StringBuilder
from marrow.arrays import StringArray

var sb = StringBuilder()
sb.append("hello")
sb.append_null()
sb.append("world")
var strs: StringArray = sb.finish()   # StringArray([hello, NULL, world])

Nested builders

A ListBuilder wraps a child builder. Append child values, then commit each list element with append_valid() — or append_null() for a null list:

from marrow.builders import ListBuilder, Int64Builder

var lists = ListBuilder(Int64Builder())
var child_any = lists.values()      # a DynBuilder over the child
ref child = child_any.as_int64()    # borrow it at its real type

child.append(1)
child.append(2)
lists.append_valid()                # [1, 2]

child.append(3)
lists.append_valid()                # [3]

lists.append_null()                 # a null list
var arr = lists.finish()

values() hands back a DynBuilder, which has no append of its own — narrow it with .as_int64() / .as_string() / … first. StructBuilder and FixedSizeListBuilder follow the same build-children-then-commit shape.

Type-erased builders

DynBuilder is the runtime-typed builder. Construct one from a DataType when the type is only known at run time; typed builders convert to it implicitly:

from marrow.builders import DynBuilder, Int64Builder
from marrow.dtypes import int64

var dyn = DynBuilder(int64)      # int64 is already an instance — not int64()

var typed = Int64Builder()
var erased: DynBuilder = typed^  # move it in -- no explicit wrapper needed

The conversion is implicit, so no DynBuilder(...) wrapper is needed, but the typed builder is moved (typed^) rather than copied: builders are not ImplicitlyCopyable, which keeps ownership visible at the call site.

finish() on a DynBuilder returns a DynArray, the type-erased array container.

Back to top