Skip to content

Lance datasets

Lance is a columnar format for ML and multimodal data, with versioning, fast random access and vector indexes. lance-csr can query Lance datasets on disk without loading them into memory first.

Lazy, version-pinned reads

Pass lance.dataset(...) handles to CypherEngine and the engine reads them lazily: nothing is scanned at construction, and each query reads only the columns and rows it needs, with projections and filters pushed into the Lance scan.

Python
import lance
import pyarrow as pa
from lance_csr import CypherEngine, GraphConfig

lance.write_dataset(
    pa.table({"id": [1, 2, 3], "name": ["Alice", "Bob", "Carol"]}), "person.lance"
)
lance.write_dataset(pa.table({"src": [1, 2], "dst": [2, 3]}), "knows.lance")

config = (
    GraphConfig.builder()
    .node_table("Person", "id")
    .rel_table("KNOWS", "Person", "Person", "src", "dst")
    .build()
)
engine = CypherEngine(
    config,
    {"Person": lance.dataset("person.lance"), "KNOWS": lance.dataset("knows.lance")},
    use_csr=True,
)
print(engine.execute("MATCH (p:Person) RETURN count(*) AS n").to_pylist())  # [{'n': 3}]
print(engine.dataset_versions())  # {'Person': 1, 'KNOWS': 1}

Each dataset is pinned to the version you passed in. Writes made later, by your process or another one, stay invisible until you call refresh(). That gives every query a stable snapshot.

Snapshots are per dataset

Each dataset is pinned separately. The versions across tables are not a transactional multi-table snapshot; if a writer updates Person and KNOWS one after the other, refresh after both commits.

Use pylance 13.x to write

The engine is built against Lance 13. Datasets written by an older or newer major version of pylance may use a file format it can't read. Installing lance-csr pins a compatible pylance for you.

refresh()

refresh() moves every Lance dataset to its latest version:

Python
lance.write_dataset(
    pa.table({"id": [4], "name": ["David"]}), "person.lance", mode="append"
)
lance.write_dataset(pa.table({"src": [3], "dst": [4]}), "knows.lance", mode="append")

print(engine.execute("MATCH (p:Person) RETURN count(*) AS n").to_pylist())  # [{'n': 3}]
engine.refresh()
print(engine.execute("MATCH (p:Person) RETURN count(*) AS n").to_pylist())  # [{'n': 4}]
print(engine.dataset_versions())  # {'Person': 2, 'KNOWS': 2}

What refresh() guarantees:

  • Unchanged datasets keep their handle. A dataset whose version and commit time are unchanged is reused, along with its CSR, node index and statistics, so a no-op refresh is cheap.
  • Schema changes are picked up. New columns are queryable after the refresh.
  • All or nothing. The replacement is validated in full before it's swapped in. On error, nothing changes and the previous snapshot keeps working.
  • Running queries are unaffected. Queries and execute_batches() readers that started before the refresh finish on their original snapshot.
  • The CSR updates incrementally, as described in Keeping the CSR current.
  • PyArrow inputs don't change. To replace in-memory data, build a new engine.

A refresh reopens each dataset by its URI with the same storage_options it was opened with, so object-store credentials carry over.

Streaming large results

execute() returns one table, held in memory. For big outputs, stream:

Python
total = 0
for batch in engine.execute_batches("MATCH (p:Person) RETURN p.id AS id, p.name AS name"):
    total += batch.num_rows  # a pyarrow.RecordBatch at a time
print(total)  # 4

Combined with lazy Lance reads, a scan-filter-project query streams from disk to your code with bounded memory. Joins, sorts and aggregations may still buffer their inputs.

Memory: what's held where

Data Where it lives
Lance dataset rows On disk; read per query, only the needed columns and rows
PyArrow inputs In memory for the engine's lifetime, sliced across cores without copying
CSR adjacency (use_csr=True) In memory, about 70 bytes per edge for both directions
Node index (use_csr=True) In memory, one entry per node row
Join-key statistics In memory, a few numbers per key column
Query results In memory with execute(); streamed with execute_batches()

The CSR and node index are built from the Lance data once and updated on refresh(); queries don't rescan the edge table to traverse it.

Tables created with LanceDB

A LanceDB table is a Lance dataset on disk. Pass the dataset behind it with table.to_lance():

Python
import lancedb
from lance_csr import CypherEngine, GraphConfig

db = lancedb.connect("./mydb")
people, knows = db.open_table("Person"), db.open_table("KNOWS")

config = (
    GraphConfig.builder()
    .node_table("Person", "id")
    .rel_table("KNOWS", "Person", "Person", "src", "dst")
    .build()
)
engine = CypherEngine(config, {"Person": people.to_lance(), "KNOWS": knows.to_lance()})

knows.add(new_edges)  # keep writing with the LanceDB API
engine.refresh()      # the engine sees the new version
  • Reads are lazy and version-pinned exactly as with lance.dataset(...).
  • Passing the LanceDB Table object itself raises a TypeError. table.to_arrow() works, but loads the whole table into memory and never refreshes.
  • Tested with LanceDB 0.40.0 on local tables (Lance file format 2.2). A LanceDB release bundling a newer Lance major may write files this engine can't read.

Not supported

  • LanceDB Cloud: remote tables have no to_lance().
  • Tables opened through a namespace connection, whose credentials are vended at runtime. The engine can only reopen a dataset by URI and storage options, so it raises ValueError rather than read with the wrong identity.
  • Object-store tables (S3 and similar) have not been tested.

Directory namespaces

DirNamespace resolves table names to Lance datasets in one directory, at <base_uri>/<name>.lance. Use it with CypherQuery instead of building a datasets dict:

Python
from lance_csr import CypherQuery, DirNamespace

namespace = DirNamespace("graph")
lance.write_dataset(pa.table({"id": [1, 2], "name": ["Ann", "Ben"]}), "graph/Person.lance")
lance.write_dataset(pa.table({"src": [1], "dst": [2]}), "graph/KNOWS.lance")

result = (
    CypherQuery("MATCH (a:Person)-[:KNOWS]->(b:Person) RETURN a.name AS a, b.name AS b")
    .with_config(config)
    .execute_with_namespace(namespace)
)
print(result.to_pylist())  # [{'a': 'Ann', 'b': 'Ben'}]
print(namespace.base_uri)  # graph

CypherQuery (with a namespace or a dict) reads the data for each execute(); use CypherEngine for lazy reads, refresh and the CSR.