Skip to content

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:

Bash
uv add lance-csr
Bash
uv run --with lance-csr python your_script.py
Bash
uv add lance-csr --no-build-package lance-csr

Prebuilt wheels cover Linux x86-64 (glibc 2.28+: RHEL 8, Ubuntu 20.04 and later) and Apple Silicon (macOS 11+), for CPython 3.12, 3.13 and 3.14. Installing a wheel needs no Rust toolchain. 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:

Python
import lance_csr

print(lance_csr.__version__)

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:

Python
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]}),
}
Rust
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:

Python
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()
)
  1. A node table: the label Person, read from the dataset named Person, identified by its id column.
  2. A relationship table: KNOWS edges go from Person to Person; the src column holds the source node's id and dst the target's.
Rust
let config = GraphConfig::builder()
    .node_table("Person", "id")
    .node_table("City", "id")
    .rel_table("KNOWS", "Person", "Person", "src", "dst")
    .rel_table("LIVES_IN", "Person", "City", "src", "dst")
    .build()?;

Run a query

CypherEngine registers the datasets once and runs as many queries as you like against them:

Python
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}]
Rust
// 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:

Python
older = engine.execute(
    "MATCH (p:Person) WHERE p.age >= $min_age RETURN p.name AS name ORDER BY name",
    {"min_age": 30},
)
print(older.to_pydict())  # {'name': ['Bob', 'David']}
Rust
let older = engine
    .execute(
        "MATCH (p:Person) WHERE p.age >= $min_age RETURN p.name AS name ORDER BY name",
        HashMap::from([("min_age".to_string(), serde_json::json!(30))]),
    )
    .await?;
print_batches(&[older])?;
// +-------+
// | name  |
// +-------+
// | Bob   |
// | David |
// +-------+

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:

Python
engine = CypherEngine(config, datasets, use_csr=True)

friends_of_friends = engine.execute("""
    MATCH (a:Person {name: 'Alice'})-[:KNOWS]->()-[:KNOWS]->(c:Person)
    RETURN DISTINCT c.name AS friend_of_friend
""")
print(friends_of_friends.to_pylist())  # [{'friend_of_friend': 'David'}]

The third argument of CypherEngine::new is use_csr.

Rust
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:

Python
from lance_csr import CypherQuery

result = (
    CypherQuery("MATCH (p:Person) WHERE p.age > 30 RETURN p.name AS name ORDER BY name")
    .with_config(config)
    .execute(datasets)
)
print(result.to_pydict())  # {'name': ['Bob', 'David']}
Rust
let result =
    CypherQuery::new("MATCH (p:Person) WHERE p.age > 30 RETURN p.name AS name ORDER BY name")?
        .with_config(config)
        .execute(datasets, None)
        .await?;
print_batches(&[result])?;
// +-------+
// | name  |
// +-------+
// | Bob   |
// | David |
// +-------+

CypherQuery rebuilds its context on every execute() call, so prefer CypherEngine whenever you run more than one query against the same data.

Next steps