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

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()?;

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:

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