Powered by AppSignal & Oban Pro

ExArrow — Datasets and Scanners (v0.9)

livebook/06_datasets.livemd

ExArrow — Datasets and Scanners (v0.9)

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")

    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.9.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 PyArrow dataset discovery + scan with ExArrow:

  • Hive-partitioned directory open
  • Expression filters with partition prune + Parquet pushdown
  • Scanner stats (exact fragment / row-group counts)
  • Projection via :columns

Run cells top to bottom. Prefer the checked-in fixture under test/fixtures/hive_events when working from a git clone.


Locate the fixture (or write a tiny Hive tree)

repo_fixture =
  Path.expand("../test/fixtures/hive_events", __DIR__)

root =
  if File.dir?(repo_fixture) do
    repo_fixture
  else
    dir = Path.join(System.tmp_dir!(), "ex_arrow_hive_demo")
    File.rm_rf!(dir)

    {:ok, batch} =
      ExArrow.RecordBatch.from_lists([
        {"id", :s64, [1, 2]},
        {"amount", :f64, [10.0, 100.0]},
        {"account_id", :utf8, ["a", "b"]}
      ])

    schema = ExArrow.RecordBatch.schema(batch)

    for {rel, ids, amounts} <- [
          {"year=2025/month=12/part-0.parquet", [1], [10.0]},
          {"year=2026/month=01/part-0.parquet", [2], [100.0]}
        ] do
      {:ok, b} =
        ExArrow.RecordBatch.from_lists([
          {"id", :s64, ids},
          {"amount", :f64, amounts},
          {"account_id", :utf8, List.duplicate("a", length(ids))}
        ])

      path = Path.join(dir, rel)
      File.mkdir_p!(Path.dirname(path))
      :ok = ExArrow.Parquet.Writer.to_file(path, schema, [b])
    end

    dir
  end

{root, File.ls!(root)}

PyArrow: open + filter

import pyarrow.dataset as ds

hive_root = root.decode("utf-8") if isinstance(root, (bytes, bytearray)) else root

dataset = ds.dataset(hive_root, format="parquet", partitioning="hive")
table = dataset.to_table(filter=(ds.field("year") >= 2026) & (ds.field("amount") > 50), columns=["id", "amount"])
{"rows": table.num_rows, "ids": table.column("id").to_pylist()}

ExArrow: Dataset + Scanner

alias ExArrow.Compute.Expression, as: E

{:ok, dataset} =
  ExArrow.Dataset.open(root,
    partitioning: {:hive, schema: [{"year", :int32}, {"month", :int32}]}
  )

filter =
  E.and_(
    E.gte(E.field("year"), E.scalar(2026)),
    E.gt(E.field("amount"), E.scalar(50.0))
  )

{:ok, scanner} =
  ExArrow.Dataset.scanner(dataset, columns: ["id", "amount"], filter: filter)

{:ok, stream} = ExArrow.Scanner.to_stream(scanner)
batches = Enum.to_list(stream)
stats = ExArrow.Scanner.stats(stream)
:ok = ExArrow.Stream.close(stream)

%{
  fragments: length(ExArrow.Dataset.fragments(dataset)),
  batches: length(batches),
  rows: Enum.map(batches, &ExArrow.RecordBatch.num_rows/1) |> Enum.sum(),
  stats: stats
}

On the checked-in fixture, expect one emitted row (id = 5), one fragment pruned by year, and exact row-group skip counts in stats.


Expression vs legacy tuple

# Fully pushable data predicate as a legacy tuple (still supported):
{:ok, scanner2} =
  ExArrow.Dataset.scanner(dataset, filter: {:gt, "amount", 0.0})

{:ok, stream2} = ExArrow.Scanner.to_stream(scanner2)
rows2 =
  stream2
  |> Enum.to_list()
  |> Enum.map(&ExArrow.RecordBatch.num_rows/1)
  |> Enum.sum()

:ok = ExArrow.Stream.close(stream2)
%{rows_amount_gt_0: rows2}

Prefer ExArrow.Compute.Expression when the filter mixes partition keys, not_/1, or anything that needs a residual after Parquet pushdown.

See also

  • Guide: guides/11_datasets.md
  • Parquet pushdown notebook: livebook/05_parquet.livemd