Array We There Yet?

Source on GitHub

Picture a table where each row is one sample, such as a well of cells or a document, and each column is one number that describes it. Image-based profiling and embedding models make tables like this, with hundreds or thousands of columns. These are commonly called features.

When you save a table like this, you have a choice. You can keep one column for each feature, like a spreadsheet. This is often called a wide layout. Or you can pack all the features of a row into one field, like a short list. This is often called an array-like layout.

A wide layout compared with an array-like layout Wide layout sample f1 f2 f3 … A B one column per feature vs Array-like layout sample features A [f1, f2, f3, …] B [f1, f2, f3, …] all features in one field
A wide layout gives each feature its own column. An array-like layout packs every feature of a row into one field.

Does the choice matter? No single layout serves every workload, so this page tests seven common storage formats: CSV, Parquet, DuckDB, Zarr, TileDB, Vortex, and Lance. It measures how long it takes to save, load, and slice the data, how big the files are, and what it costs to move them around. If you load features into NumPy or a model, or you share data with other people, the results can help you pick a layout.

The fairest test compares each array-like layout with the wide layout in the same format. Every layout is also shown against CSV wide, the most common way to share this kind of data. CSV is plain text, so those gains look bigger than they would against another binary format.

Summary

Main finding. Array-like layouts read whole feature matrices 4.4x to 250x faster than wide layouts in 6 of 7 backends. Reading only 8 of 8,192 features: faster in 4, slower in 3 backends. The next bullets give the per-backend numbers - it is faster in Vortex (25x), Lance (14x), DuckDB (2.1x), and Parquet (1.1x); it is slower in CSV (5.7x), Zarr (3.4x), and TileDB (1.1x).

Matrix materialization loads every value into one in-memory table of numbers, ready for analysis or model training. Feature projection loads only a few chosen features, and skips the rest.

Figures

The benchmark used synthetic data with 2,000 rows and 256 to 8,192 features. Each timing is the median of 6 repetitions.

How to read the figures

  • Each panel shows one operation or one summary measure.
  • Lower is better in every panel.
  • Ratio panels divide one result by a reference result. A value of 1.0 means the same as the reference.
  • Geometric Mean Time is the geometric mean of the time ratios across all operations. The geometric mean is the standard way to average ratios (see References).
  • Error bars show the q25-to-q75 range across repetitions.
  • The y-axis uses a log scale by default to show small and large changes. Use the Log scale control to switch the overview and explorer plots to a linear scale.
  • Dashed lines are wide layouts. Solid lines are array-like layouts.
  • Click a layout in the key to hide or show it in every figure. Click a backend name to hide or show all of its layouts. Hover over a point to read its value.

Figure 1. Every layout against CSV wide

Every layout divided by CSV wide, which is the flat line at 1.0. Parquet wide is the only other wide layout shown here. Figure 3 shows all of them.

CSV wide is the reference because it is the most common way to share this kind of data. It is a text format, so ratios against it exaggerate the gain of any binary format. The next figure is the fairer comparison: each array-like layout against a wide layout in the same format.

Figure 2. Array-like layouts against their own wide layout

Each array-like layout divided by the wide layout of the same backend. Values below 1.0 favor the array-like layout. This is the fairest comparison, because both layouts use the same format.

Figure 3. Wide layouts

Wide layouts only, divided by CSV wide, which is the flat line at 1.0.

Figure 4. Absolute times and sizes

The figures above show ratios. Pick an operation and a profile to see the absolute times and sizes. The key applies to this plot too.

Figure 5. Time and egress for the real-world example

Left: time for one use, split into download and read. Right: egress cost over the chosen number of uses. Lower is better in both panels. Values use a 1.5 GB file, 1,000 uses, $0.09 per GB, and 100 MB/s.

Figure 6. Time against row count

Median time against row count. Both axes use a log scale.

Figure 7. Storage size against time

Storage size against time at the largest feature count. Each line joins the default profile (filled) to the compact profile (open). Points without a line have no compact profile. Squares are wide layouts and circles are array-like layouts. Lower and further left is better.

Real-world example

What egress is. Cloud providers charge for storing a file and, separately, for data that leaves their network. The second charge is called egress. It applies each time someone downloads a file from cloud storage to a computer outside the provider's network, and it is billed per gigabyte.

Why it matters for hosting a dataset. A team that shares a dataset pays egress each time someone downloads it. The bill grows with the number of users and with the file size, and it keeps growing after the dataset stops changing. A smaller file lowers the bill in direct proportion. Some providers offer a requester-pays option, where the person who downloads pays instead of the host.

Why it matters for using a dataset. A researcher who works on a laptop or in another cloud waits for every download, and pays the fee when the host does not. The wait starts again each time the data is read into memory. File size sets the download time. The format sets the read time.

The egress costs here are an estimate. They use one list price to show the size of the effect on a single example. Prices differ by provider, region, storage service, and volume, and they change over time.

This example asks what it costs to move and read a 1.5 GB CSV wide file, and how much the other layouts save. The file holds about 16,800 rows of 8,192 features. A use is one download followed by one read into memory. Figure 5 shows the time and egress cost.

Takeaway. With Parquet fixed_array, one use takes 5.8 s instead of 26 s and costs $0.050 instead of $0.135 in egress. Over 1,000 uses that saves 5.5 h and $85.

Size as Parquet. The same data takes 0.55 GB as a Parquet file with the array layout, 63% smaller than the 1.5 GB CSV wide file. With compact settings it takes 0.46 GB, 69% smaller. As a Parquet wide file it takes 0.81 GB, 46% smaller.

One use

Layout Size Download Read into memory Total time Egress cost
CSV wide 1.5 GB 15 s 11 s 26 s $0.135
CSV wide (compact) 0.64 GB 6.4 s 13 s 20 s $0.058
Parquet wide 0.81 GB 8.1 s 0.54 s 8.7 s $0.073
Parquet fixed_array 0.55 GB 5.5 s 0.33 s 5.8 s $0.050
Parquet fixed_array (compact) 0.46 GB 4.6 s 0.5 s 5.1 s $0.042
DuckDB duckdb_array 0.88 GB 8.8 s 0.34 s 9.1 s $0.079
Zarr zarr_matrix 0.51 GB 5.1 s 0.47 s 5.6 s $0.046
TileDB tiledb_dense 0.57 GB 5.7 s 0.12 s 5.8 s $0.051
Vortex fixed_array 0.48 GB 4.8 s 0.069 s 4.9 s $0.044
Lance fixed_array 0.55 GB 5.5 s 0.082 s 5.6 s $0.049

Compact rows use the compact write profile, which writes smaller files and can cost time. See Write settings.

Savings over 1,000 uses

Time spent and egress spent are the totals over 1,000 uses. Time saved and egress cost saved compare a layout with CSV wide. Time is the download plus the read.

Layout Time spent Egress spent Time saved Egress cost saved
CSV wide 7.1 h $135 baseline baseline
CSV wide (compact) 5.5 h $58 1.7 h $77
Parquet wide 2.4 h $73 4.7 h $62
Parquet fixed_array 1.6 h $50 5.5 h $85
Parquet fixed_array (compact) 1.4 h $42 5.7 h $93
DuckDB duckdb_array 2.5 h $79 4.6 h $56
Zarr zarr_matrix 1.5 h $46 5.6 h $89
TileDB tiledb_dense 1.6 h $51 5.5 h $84
Vortex fixed_array 1.4 h $44 5.8 h $91
Lance fixed_array 1.6 h $49 5.6 h $85

Streaming a Parquet file

Parquet stores a footer that lists where every column and row group is. A client can read the footer and then request only the byte ranges it needs from cloud storage. Egress is billed for the bytes that are sent, so a partial read costs less than a full download. A CSV file cannot be read in part by column, because every row holds every column.

The wide layout can skip features. The array layout stores all features of a row in one column, so it cannot. Row selection saves egress only when the file has several row groups and the rows you need are together. These files use one row group, so the row numbers below assume row groups of about 1,000 rows.

What you read CSV wide Parquet wide Parquet fixed_array
Everything 1.5 GB 0.81 GB 0.55 GB
8 features of 8,192 1.5 GB 4.9 MB (measured) 0.55 GB
1,000 rows 1.5 GB 48 MB (estimate) 33 MB (estimate)

Reading 8 features from Parquet wide sends 4.9 MB, which costs $0.0004 in egress, against $0.135 for the CSV file. The footer is 4.1 MB (measured).

Scaling check

The benchmark data is small, so the scaling check wrote and read files of about 16,800 rows to check the scaled numbers. The tables above use the measured size and read time for these layouts.

Layout Scaled size Measured size Scaled read Measured read
CSV wide 1.5 GB 1.5 GB 11 s 11 s
CSV wide (compact) 0.64 GB 0.64 GB 14 s 13 s
Parquet wide 0.78 GB 0.81 GB 1.3 s 0.54 s
Parquet fixed_array 0.56 GB 0.55 GB 0.3 s 0.33 s
Parquet fixed_array (compact) 0.46 GB 0.46 GB 0.4 s 0.5 s
DuckDB duckdb_array 0.49 GB 0.88 GB 0.28 s 0.34 s
Zarr zarr_matrix 0.51 GB 0.51 GB 0.49 s 0.47 s
TileDB tiledb_dense 0.56 GB 0.57 GB 0.094 s 0.12 s
Vortex fixed_array 0.49 GB 0.48 GB 0.043 s 0.069 s
Lance fixed_array 0.55 GB 0.55 GB 0.052 s 0.082 s

Measured sizes are within 10% of the scaled sizes, except DuckDB duckdb_array (1.8x larger). Measured read times are within 1.5x of the scaled times, except Parquet wide (2.4x faster), Vortex fixed_array (1.6x slower), and Lance fixed_array (1.6x slower).

Egress pricing pages:

Check the current page before you plan a budget.

Assumptions:

Detailed results

The figures above show every measurement at once. The rest of this page slows down and checks the numbers behind them: the per-backend table below, then how far each layout sits from a plain NumPy file, how the results scale with row count, and how sensitive they are to compression settings. If you only load whole matrices, the summary above already has your answer. If you slice by rows or features, the next sections matter.

Key findings

Each cell compares the array-like layout with the wide layout of the same backend at 8,192 features. A negative percentage means faster or smaller. A positive percentage means slower or larger. For example, +100% means twice as slow or twice as large, and -50% means half the time or size. Feature projection reads 8 of 8,192 features, which is the best case for a wide layout. Figure 2 shows these ratios at every feature count.

Backend Layout Matrix materialization Feature projection Write Storage size
CSV delimited_array +27% +450% +38% +11%
CSV json_array +40% +490% +42% +11%
Parquet fixed_array -77% -13% -88% -29%
DuckDB duckdb_array -86% -53% -38% -57%
Zarr zarr_matrix -99% +240% -99.7% -9%
TileDB tiledb_dense -99.6% +10% -99.96% -11%
Vortex fixed_array -97% -96% -98% -14%
Lance fixed_array -99% -93% -95% -8%

These layouts used more than one thread in at least one operation: Lance fixed_array (median 0.92, up to 3.6), Lance wide (median 2, up to 3.9), Vortex fixed_array (median 2.6, up to 7.6), Vortex wide (median 1.1, up to 3.4). Parallelism is CPU time divided by wall time, so 1.0 means one busy thread. The benchmark cannot limit the native thread pools of Lance and Vortex.

Measured in CPU time, which counts every thread, matrix materialization with the array-like layout is 7x faster in Vortex (33x faster in wall time).

Access path check: for every layout, matrix materialization takes at most 3 times as long as a full read, and random rows take at most 3 times as long as matrix materialization.

Distance from a plain NumPy file

A NumPy .npy file loads into memory in 3.9 ms at 8,192 features. For matrix materialization this is a floor, because no format can be faster than a memory copy. For random rows and feature projection it is a baseline, not a floor. A row-major file is a poor layout for reading a few columns, so a value below 1x is possible. Each cell divides the time of a layout by the time of the plain file, so 1x is as fast as the plain file.

Layout Matrix materialization Random rows Feature projection
CSV wide 340x 2,900x 180x
CSV delimited_array 430x 3,600x 970x
CSV json_array 470x 4,000x 1,000x
Parquet wide 40x 340x 22x
Parquet fixed_array 9.1x 71x 19x
DuckDB wide 63x 560x 30x
DuckDB duckdb_array 8.6x 55x 14x
Zarr wide 2,800x 24,000x 4.6x
Zarr zarr_matrix 15x 120x 15x
TileDB wide 710x 5,600x 5.9x
TileDB tiledb_dense 2.9x 15x 6.5x
Vortex wide 44x 380x 72x
Vortex fixed_array 1.3x 12x 2.9x
Lance wide 160x 1,100x 50x
Lance fixed_array 1.6x 3.3x 3.6x

Scaling with row count

The row count grows 100x, from 2,000 to 200,000 rows, at 1,024 features. Each cell divides the time at 200,000 rows by the time at 2,000 rows. Time that grows 100x is linear in the row count. A smaller value means the time grew more slowly than the rows.

Figure 6 plots these times against the row count.

Layout Matrix materialization Random rows Feature projection
Parquet wide 34x 18x 2x
Parquet fixed_array 100x 96x 99x
DuckDB wide 20x 14x 1.4x
DuckDB duckdb_array 77x 62x 67x
Zarr wide 390x 190x 94x
Zarr zarr_matrix 92x 42x 92x
TileDB wide 16x 4.2x 20x
TileDB tiledb_dense 130x 54x 92x
Vortex wide 22x 4.2x 5.2x
Vortex fixed_array 110x 6.7x 65x
Lance wide 13x 11x 1.7x
Lance fixed_array 130x 1x 92x

Matrix materialization grew the most in Zarr wide (390x) and the least in Lance wide (13x). Random rows grew the most in Zarr wide (190x) and the least in Lance fixed_array (1x).

CSV was measured only up to 20,000 rows.

Encoding sensitivity

Encoding and compression choices change file size and speed. This table checks whether the array-versus-wide results hold under both write profiles. Each cell shows the array-like layout relative to the wide layout of the same backend, first with the default profile and then with the compact profile. The last column says whether every measure stays on the same side of wide, better or worse, in both profiles. This does not mean that the size of the effect stays the same.

Backend Layout Storage size Matrix materialization Feature projection Write Conclusion
Parquet fixed_array -29% → -15% -77% → -77% -13% → +26% -88% → -72% Reverses
Zarr zarr_matrix -9% → -10% -99% → -99% +240% → +260% -99.7% → -99.7% Same side of wide
TileDB tiledb_dense -11% → -15% -99.6% → -98% +10% → +340% -99.96% → -99.9% Same side of wide

Encodings observed

The table lists the compression and encodings that each format wrote at the largest feature count. Bytes per value is the artifact size divided by the number of stored values. A raw float32 value takes 4 bytes. Most size differences between the binary formats come from default compression and encoding choices, not from the layout. Random values compress poorly, so the sizes describe this data set and not real profile data. Sizes are for 2,000 rows and can change with more rows.

Parquet turns on dictionary encoding by default. Every value in this data is unique, so the dictionary adds overhead. Parquet wide takes 5.67 bytes per value by default and 3.97 with the compact profile. DuckDB wide takes 8.26 bytes per value, about 2.1x the raw size. The benchmark did not investigate why. It stores 8,192 columns of 2,000 values each.

Backend Layout Profile Compression and encodings Bytes per value
CSV wide default uncompressed decimal text 10.92
CSV wide compact gzip-compressed decimal text 4.68
CSV delimited_array default uncompressed decimal text 12.17
CSV json_array default uncompressed decimal text 12.17
Parquet wide default snappy; PLAIN, RLE, RLE_DICTIONARY; 1 row group 5.67
Parquet wide compact zstd; BYTE_STREAM_SPLIT, RLE; 1 row group 3.97
Parquet fixed_array default snappy; PLAIN, RLE, RLE_DICTIONARY; 1 row group 4.05
Parquet fixed_array compact zstd; BYTE_STREAM_SPLIT, RLE; 1 row group 3.38
DuckDB wide default FLOAT; ALP, ALPRD, Constant 8.26
DuckDB duckdb_array default FLOAT[8192]; ALPRD, Constant 3.55
Zarr wide default zstd level 0 4.07
Zarr wide compact Blosc zstd level 5, byte shuffle 4.13
Zarr zarr_matrix default zstd level 0 3.71
Zarr zarr_matrix compact Blosc zstd level 5, byte shuffle 3.71
TileDB wide default attributes: none; dimensions: Zstd 4.59
TileDB wide compact attributes: ByteShuffle + Zstd; dimensions: Zstd 4.23
TileDB tiledb_dense default attributes: none; dimensions: Zstd 4.10
TileDB tiledb_dense compact attributes: ByteShuffle + Zstd; dimensions: Zstd 3.59
Vortex wide default encodings chosen by Vortex 4.12
Vortex fixed_array default encodings chosen by Vortex 3.54
Lance wide default encodings chosen by Lance (data storage version 2.2) 4.35
Lance fixed_array default encodings chosen by Lance (data storage version 2.2) 4.01

Appendix: storage size against time

This appendix uses synthetic random data. It shows how the compact profile moves the size and the time of each format. It is not a ranking of formats for real data.

Figure 7 plots storage size against time at the largest feature count.

Changes from the default profile to the compact profile:

Layout Storage size Matrix materialization Write
CSV wide -57% +23% +340%
Parquet wide -30% +34% -33%
Parquet fixed_array -17% +34% +58%
Zarr wide +1% +1% +4%
Zarr zarr_matrix 0% +3% +3%
TileDB wide -8% +2% +2%
TileDB tiledb_dense -12% +370% +120%

The compact profile changes only the write settings in Write settings. DuckDB, Lance, and Vortex have no compact profile.

Layouts

The benchmark writes these layouts:

Backend Wide layout Array-like layout
CSV One column per feature Delimited string and JSON string
Parquet One column per feature FixedSizeList<float32>
DuckDB One column per feature FLOAT[N] fixed-size array
Zarr One array per feature 2D chunked feature array
TileDB One attribute per feature Dense 2D array
Vortex One column per feature FixedSizeList<float32>
Lance One column per feature FixedSizeList<float32>

For packed and fixed-array layouts, the size of feature_names.json is included in the artifact size. This accounts for the feature names that a wide schema stores in its columns.

Parquet has no fixed-size list type. PyArrow writes the FixedSizeList layout as an ordinary variable-length list and restores the fixed size from the Arrow schema stored in the file metadata. Readers that do not use that schema see a variable-length list.

Storing several values in one cell is not a shortcut around the relational model. It is a documented alternative within it. Codd's First Normal Form (1970) required every value to be atomic, one number or string per cell. The NF² (non-first-normal-form) relations of Jaeschke and Schek (1982) relaxed that on purpose, allowing an attribute's value to be a list, and SQL standardized array-typed columns not long after. See References for the lineage, including the paper behind Parquet's own nested encoding.

Operations

Each run measures these operations:

Operation Description
Write Write the full dataset.
Full read Read the full table.
Matrix materialization Read every value into one N x D NumPy array, a single in-memory table of numbers.
Random rows Read 128 random rows, using each format's own row-selection call. CSV has none, so it reads the whole file.
Feature projection Read only a few chosen features and skip the rest, when the format allows it. Here it is 8 features.
Mixed retrieval Read metadata and selected features together, using each format's row-selection call. CSV reads the whole file.
Vector norm Read the matrix as in matrix materialization, then compute the L2 norm of each row in NumPy.

Each result is stored as an absolute time. The report also stores ratios to CSV wide and ratios to the wide layout of the same backend.

Methodology

Environment

Item Value
CPU Apple M4 Pro (12 logical cores)
Memory 48 GiB
Platform macOS-26.7.1-arm64-arm-64bit
Python 3.11.13
Code version 33e33fd
Runs 2 independent runs of the same code, pooled
duckdb 1.5.5
lance 12.0.0
numpy 2.4.6
pandas 3.0.5
pyarrow 25.0.1
tiledb 0.36.1
vortex 0.86.1
zarr 3.1.6
Threads Arrow CPU 1, Arrow I/O 1, DuckDB 1, TileDB 1, Zarr chunks 1, Zarr Blosc 1; Lance and Vortex use their library defaults

Backends and access paths

Backend Package or binding Layouts measured
CSV pandas CSV I/O wide, delimited_array, json_array
Parquet pyarrow.parquet wide, fixed_array
DuckDB duckdb Python package wide, duckdb_array
Zarr zarr Python package wide, zarr_matrix
TileDB tiledb Python package wide, tiledb_dense
Vortex vortex-data (vortex import) wide, fixed_array
Lance lance Python package wide, fixed_array

The timings include each package and binding, not only the storage layout. Different language layers can change the result.

Write settings

The benchmark writes every layout with a default profile. It uses only the settings listed here. All other settings are the defaults of the package version in the Environment table.

Formats with a compression setting that the benchmark verified also run a compact profile. It writes smaller files and can cost time. The plots use only the default profile. The Encoding sensitivity table and the appendix compare the two.

Backend Default profile Compact profile
CSV No compression. Packed arrays use 9 significant digits per value. gzip on the wide layout only.
Parquet snappy compression through pyarrow.parquet.write_table. Dictionary encoding is on. zstd, dictionary encoding off, and byte-stream split on float data.
DuckDB One CREATE TABLE followed by CHECKPOINT. Arrays are FLOAT[N]. None. DuckDB chooses its own compression.
Zarr Chunks of 1,024 rows. The matrix layout also uses at most 1,024 features. No codec is requested, so Zarr writes its own default: zstd. Blosc with zstd, level 5, byte shuffle.
TileDB Dense array. Tile extents of at most 1,024 rows and 1,024 features. No attribute filters. Byte shuffle followed by zstd at level 5 on the attributes.
Vortex None. The benchmark calls vortex.io.write with defaults. None. The benchmark found no setting to change.
Lance None. The benchmark calls lance.write_dataset with defaults. None. The compression settings that the benchmark tried had no effect.

Limitations

References

Terminology

Term Meaning
Array-like layout A layout that stores all features of a row in one field.
Artifact A file or directory that a benchmark run writes.
Backend A storage format and the library that reads it. DuckDB is a database engine with its own file format.
Compact profile Write settings that make smaller files and can cost time. See Write settings.
Egress The fee that a cloud provider charges when data leaves its network, billed per gigabyte.
Feature One numeric measurement for a row.
Feature projection Reading only a few chosen features and skipping the rest.
Matrix materialization Reading every value into one in-memory table of numbers (an N x D NumPy array).
Metadata Descriptive columns, such as sample ID or plate ID.
Packed array An array stored as text inside one CSV field.
Ratio One result divided by a reference result.
Row group A block of rows in a Parquet file. A reader can skip whole row groups.
Streaming Reading part of a file with byte-range requests, so that only those bytes are sent.
Wide layout A layout that stores each feature in its own column.