Powered by AppSignal & Oban Pro

ExArrow — Parquet power-read / write (v0.8)

livebook/05_parquet.livemd

ExArrow — Parquet power-read / write (v0.8)

deps = [
  {:pythonx, "~> 0.4.2"},
  {:kino_pythonx, "~> 0.1.0"},
  {:kino, "~> 0.19.0"}
]

# Opened from livebook/ in the repo → local source; otherwise Hex (precompiled NIF).
local? = File.exists?(Path.join(__DIR__, "../native/ex_arrow_native/Cargo.toml"))

{ex_arrow_dep, extra_deps, config} =
  if local? do
    System.put_env("EX_ARROW_BUILD", "1")

    # Force recompile ex_arrow so a cached Mix.install build is not reused.
    ex_arrow_beam = Path.join(__DIR__, "../_build/dev/lib/ex_arrow/ebin")

    if File.dir?(ex_arrow_beam) do
      File.rm_rf!(ex_arrow_beam)
    end

    {
      {:ex_arrow, path: Path.expand("..", __DIR__)},
      [{:rustler, "~> 0.36", optional: true}],
      [rustler_precompiled: [force_build: [ex_arrow: true]]]
    }
  else
    {
      {:ex_arrow, "~> 0.8.0"},
      [],
      []
    }
  end

Mix.install(deps ++ [ex_arrow_dep] ++ extra_deps, config: config)
[project]
name = "project"
version = "0.0.0"
requires-python = "==3.13.*"
dependencies = ["pyarrow"]

Overview

This notebook mirrors common PyArrow Parquet patterns with ExArrow v0.8:

  • column projection + predicate pushdown
  • compressed writes and footer metadata
  • multi-file / directory streams
  • IPC file-format write for symmetry

Run the cells top to bottom — later cells reuse path, schema, and batch.


Setup: write a small demo Parquet file

{:ok, batch} =
  ExArrow.RecordBatch.from_columns(
    ["id", "score"],
    [
      <<1::little-signed-64, 2::little-signed-64, 3::little-signed-64>>,
      <<0.5::little-float-64, 0.95::little-float-64, 1.2::little-float-64>>
    ],
    ["s64", "f64"],
    3
  )

schema = ExArrow.RecordBatch.schema(batch)
path = Path.join(System.tmp_dir!(), "ex_arrow_parquet_demo.parquet")
:ok = ExArrow.Parquet.Writer.to_file(path, schema, [batch], compression: :zstd)

{path, ExArrow.RecordBatch.num_rows(batch), ExArrow.Schema.field_names(schema)}

Read a subset (pushdown)

PyArrow equivalent:

import pyarrow.parquet as pq

# Elixir binaries arrive in Pythonx as `bytes` — decode before treating as a path.
parquet_path = path.decode("utf-8") if isinstance(path, (bytes, bytearray)) else path

table = pq.read_table(
    parquet_path,
    columns=["id", "score"],
    filters=[("score", ">", 0.9)],
)
table.num_rows

ExArrow:

{:ok, stream} =
  ExArrow.Stream.from_parquet(path,
    columns: ["id", "score"],
    filters: {:gt, "score", 0.9}
  )

stats = ExArrow.Parquet.Reader.read_stats(stream)
rows =
  stream
  |> ExArrow.Stream.to_list()
  |> Enum.map(&ExArrow.RecordBatch.num_rows/1)
  |> Enum.sum()

%{stats: stats, filtered_rows: rows}

Both cells return 2 — the rows where score > 0.9 (0.95 and 1.2). Livebook shares the Elixir path binding into the Python cell automatically; Pythonx encodes Elixir binaries as Python bytes, so decode to str before passing to PyArrow.


Compressed write + footer metadata

:ok =
  ExArrow.Parquet.Writer.to_file(path, schema, [batch],
    compression: {:zstd, 3},
    row_group_size: 1024
  )

{:ok, meta} = ExArrow.Parquet.Metadata.from_file(path)

%{
  num_rows: meta.num_rows,
  num_row_groups: meta.num_row_groups,
  columns: hd(meta.row_groups).columns
}

Column maps include path, compression, encodings, min, and max.


Partitioned directory (multi-file)

dir = Path.join(System.tmp_dir!(), "ex_arrow_parquet_parts")
File.rm_rf!(dir)
File.mkdir_p!(dir)

for {name, id} <- [{"part-0.parquet", 10}, {"part-1.parquet", 20}] do
  {:ok, b} =
    ExArrow.RecordBatch.from_columns(
      ["id"],
      [<<id::little-signed-64>>],
      ["s64"],
      1
    )

  :ok =
    ExArrow.Parquet.Writer.to_file(
      Path.join(dir, name),
      ExArrow.RecordBatch.schema(b),
      [b]
    )
end

{:ok, multi} = ExArrow.Stream.from_parquet_dir(dir, filters: {:gte, "id", 15})
row_counts = Enum.map(ExArrow.Stream.to_list(multi), &ExArrow.RecordBatch.num_rows/1)

# Early abandon releases the multi-file Agent.
:ok = ExArrow.Stream.close(multi)

%{opened_dir: dir, filtered_batch_rows: row_counts}

Expect one batch with 1 row (id == 20).


IPC file writer symmetry

ipc_path = Path.join(System.tmp_dir!(), "ex_arrow_parquet_demo.arrow")
:ok = ExArrow.IPC.File.write(ipc_path, schema, [batch])
{:ok, file} = ExArrow.IPC.File.from_file(ipc_path)

%{
  path: ipc_path,
  batch_count: ExArrow.IPC.File.batch_count(file),
  fields:
    case ExArrow.IPC.File.schema(file) do
      {:ok, sch} -> ExArrow.Schema.field_names(sch)
    end
}