Getting started¶
This page takes you from an empty environment to a working graph query. You'll install lance-csr, describe a small graph, and query it with Cypher.
Install¶
lance-csr is published on PyPI as lance-csr. Use uv:
Prebuilt wheels cover Linux x86-64 and aarch64 (glibc 2.28+: RHEL 8, Ubuntu 20.04 and later), Apple Silicon
(macOS 11+) and Windows x86-64, for CPython 3.12, 3.13 and 3.14. Installing a wheel needs no Rust
toolchain. On Windows you can also use lance-csr inside WSL2, where it installs the Linux wheel. Other platforms
build from the source distribution, which needs Rust and protoc (see Contributing).
The package depends on pyarrow>=14 and pylance 13.x, which uv installs for you.
Using Rust?
The same engine is on crates.io: run cargo add lance-csr and see Using from Rust.
Docs track the main branch
These pages describe the code on main. The node_table and rel_table builder methods arrived in
0.2.0; on 0.1.0 use with_node_label and with_relationship, which were removed in 0.5.0 (see
Graph schema).
Check the install:
Your first graph¶
A graph in lance-csr is a set of ordinary tables plus a GraphConfig that says which tables are nodes,
which are relationships, and which columns link them. Here are people, the cities they live in, and who
knows whom:
import pyarrow as pa
datasets = {
"Person": pa.table({
"id": [1, 2, 3, 4],
"name": ["Alice", "Bob", "Carol", "David"],
"age": [28, 34, 29, 42],
}),
"City": pa.table({"id": [10, 20], "name": ["London", "Sydney"]}),
"KNOWS": pa.table({"src": [1, 1, 2, 3], "dst": [2, 3, 4, 4]}),
"LIVES_IN": pa.table({"src": [1, 2, 3, 4], "dst": [10, 10, 20, 20]}),
}
let int = |values: Vec<i64>| Arc::new(Int64Array::from(values)) as ArrayRef;
let text = |values: Vec<&str>| Arc::new(StringArray::from(values)) as ArrayRef;
let datasets = HashMap::from([
(
"Person".to_string(),
RecordBatch::try_from_iter([
("id", int(vec![1, 2, 3, 4])),
("name", text(vec!["Alice", "Bob", "Carol", "David"])),
("age", int(vec![28, 34, 29, 42])),
])?,
),
(
"City".to_string(),
RecordBatch::try_from_iter([
("id", int(vec![10, 20])),
("name", text(vec!["London", "Sydney"])),
])?,
),
(
"KNOWS".to_string(),
RecordBatch::try_from_iter([
("src", int(vec![1, 1, 2, 3])),
("dst", int(vec![2, 3, 4, 4])),
])?,
),
(
"LIVES_IN".to_string(),
RecordBatch::try_from_iter([
("src", int(vec![1, 2, 3, 4])),
("dst", int(vec![10, 10, 20, 20])),
])?,
),
]);
Each key of datasets is a node label or relationship type. Now describe how they connect:
from lance_csr import GraphConfig
config = (
GraphConfig.builder()
.node_table("Person", "id") # (1)!
.node_table("City", "id")
.rel_table("KNOWS", "Person", "Person", "src", "dst") # (2)!
.rel_table("LIVES_IN", "Person", "City", "src", "dst")
.build()
)
- A node table: the label
Person, read from the dataset namedPerson, identified by itsidcolumn. - A relationship table:
KNOWSedges go fromPersontoPerson; thesrccolumn holds the source node'sidanddstthe target's.
Use integer keys
We recommend integer node keys and integer src/dst columns, as in this example. String keys,
including UUIDs, give the same results but slow down every query that traverses relationships.
With integer keys, the CSR index jumps straight from a node to its
neighbours. It can't store string keys, so their queries scan whole tables at every hop instead, without
a warning. Path variables (MATCH p = ...) need integer keys too.
If your source data uses UUIDs, add an integer id column to use as the key and keep the UUID as an ordinary property. See Key types and traversal performance.
Run a query¶
CypherEngine registers the datasets once and runs as many queries as you like against them:
from lance_csr import CypherEngine
engine = CypherEngine(config, datasets)
result = engine.execute("""
MATCH (p:Person)-[:LIVES_IN]->(c:City {name: 'London'})
RETURN p.name AS name, p.age AS age
ORDER BY age
""")
print(result.to_pylist())
# [{'name': 'Alice', 'age': 28}, {'name': 'Bob', 'age': 34}]
// The engine reads each dataset as an `EngineSource`; `false` leaves the CSR off.
let sources: HashMap<_, _> = datasets
.iter()
.map(|(name, batch)| (name.clone(), EngineSource::Arrow(batch.clone())))
.collect();
let engine = CypherEngine::new(config.clone(), sources.clone(), false).await?;
let result = engine
.execute(
"MATCH (p:Person)-[:LIVES_IN]->(c:City {name: 'London'})
RETURN p.name AS name, p.age AS age
ORDER BY age",
HashMap::new(),
)
.await?;
print_batches(&[result])?;
// +-------+-----+
// | name | age |
// +-------+-----+
// | Alice | 28 |
// | Bob | 34 |
// +-------+-----+
Results come back as a pyarrow.Table,
so to_pylist(), to_pydict(), to_pandas() and Polars' pl.from_arrow() all work. In Rust, execute
returns an Arrow RecordBatch.
Pass values with $parameters instead of formatting them into the query string:
Go multi-hop with the CSR¶
Pass use_csr=True and the engine builds a CSR adjacency index for each relationship type. Multi-hop
patterns then become array lookups instead of joins:
The third argument of CypherEngine::new is use_csr.
let engine = CypherEngine::new(config.clone(), sources, true).await?;
let friends_of_friends = engine
.execute(
"MATCH (a:Person {name: 'Alice'})-[:KNOWS]->()-[:KNOWS]->(c:Person)
RETURN DISTINCT c.name AS friend_of_friend",
HashMap::new(),
)
.await?;
print_batches(&[friends_of_friends])?;
// +------------------+
// | friend_of_friend |
// +------------------+
// | David |
// +------------------+
When should I turn the CSR on?
Turn it on when your queries traverse relationships, and especially for two or more hops. Node keys and edge endpoints must be integer columns to benefit; anything else quietly uses joins. Read The CSR index for the full picture.
One-off queries¶
For a single query you don't plan to repeat, CypherQuery skips the engine:
CypherQuery rebuilds its context on every execute() call, so prefer CypherEngine whenever you run more
than one query against the same data.
Next steps¶
- Graph schema: every option of
GraphConfig. - Querying with Cypher: parameters, streaming,
explain()and the supported Cypher. - Lance datasets: query data on disk without loading it, and pick up new writes with
checkout_latest(). - Using from Rust: the same engine as a Rust library.
- The
examples/directory has runnable scripts.