Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Cookbook: From Zero to Graph Analytics

This chapter is a single end-to-end walk-through: load → enforce → query → analyze → export → integrate, ending with the versioning and provenance features that make patinaDB distinct. Every command here is real and runnable against the embedded CLI; the outputs shown were captured from a throwaway database.

Throughout we use the CLI binary as patinadb <db> <subcommand> (see Installation for how to get it — the Docker image, or a standalone binary). The first argument is always the database directory — it is created on first use.

We’ll build a tiny social graph: Person nodes connected by FOLLOWS relationships.

1. Load data

For a one-off graph you can just CREATE nodes with a query (see Quick Start). For anything larger, use the offline bulk importer — neo4j-admin-style node and relationship files.

Two CSV files. Node files carry :ID, :LABEL, and typed property columns (age:int, since:int, or a bare name for strings). Relationship files carry :START_ID / :END_ID / :TYPE:

# people.csv
:ID,name,age:int,city,:LABEL
u1,Ada,36,London,Person
u2,Grace,40,New York,Person
u3,Linus,29,Helsinki,Person
u4,Margaret,33,Boston,Person
# follows.csv
:START_ID,:END_ID,:TYPE,since:int
u1,u2,FOLLOWS,2019
u2,u3,FOLLOWS,2020
u3,u1,FOLLOWS,2021
u4,u1,FOLLOWS,2022
u1,u3,FOLLOWS,2023

Import them (all node files load first, then relationships — so :START_ID references resolve):

patinadb demo.db import --nodes people.csv --rels follows.csv
imported 4 nodes (12 props), 5 edges (5 props) in 0.00s (1093 nodes/sec)

The importer also reads Parquet and Arrow IPC (.parquet / .arrow / .feather / .ipc) — dispatched per file by extension, with property types taken from the columnar schema — and streams in bounded --batch-size batches, so it scales to graphs far larger than RAM. Import maps each :ID string to a deterministic UUID (--id-type hash, the default); pass --id-type uuid (or use an :ID(uuid) header) to treat the id cell as a literal UUID, which is what makes an exportimport round-trip reproduce the same graph. See the CLI chapter for every flag.

Import bypasses versioning. The bulk loader writes straight into storage, skipping the engram/history layer for speed — the imported data is visible at HEAD but not in --at time-travel history. Load through import for the initial data set; use queries (below) for changes you want in the audit trail.

Loading online, in batches

Inside a running query (embedded or on the server) you can stream a CSV and commit it in chunks, so a large load never buffers the whole file into one transaction:

LOAD CSV WITH HEADERS FROM 'file:///data/accounts.csv' AS row
CALL {
  WITH row
  CREATE (:Account {id: toInteger(row.id), name: row.name})
} IN TRANSACTIONS OF 1000 ROWS

Each batch of 1000 rows commits as its own engram (memory stays O(batch), not O(file)), and on the server each batch is one Raft entry. ON ERROR {CONTINUE|BREAK|FAIL} controls what happens to a failing batch.

2. Enforce schema

Add constraints so bad data is rejected at write time. patinaDB supports IS UNIQUE, IS NOT NULL, and IS NODE KEY:

patinadb demo.db query \
  "CREATE CONSTRAINT person_name FOR (p:Person) REQUIRE p.name IS UNIQUE"
status: 'Constraint created on :Person(name) IS UNIQUE'

Creating a constraint over already-populated data fails loudly if the data already violates it. Once in place, a violating write is rejected:

patinadb demo.db query "CREATE (p:Person {name: 'Ada'})"
error: Unique constraint violation on :Person(name): value already exists

SHOW CONSTRAINTS lists them. On the server, constraint DDL replicates to every node and rides Raft snapshots. See the Cypher chapter for the full DDL grammar.

3. Query

Ordinary Cypher — MATCH, WHERE, ORDER BY, aggregation. List people by age:

patinadb demo.db query \
  "MATCH (p:Person) RETURN p.name AS name, p.age AS age ORDER BY p.age DESC"

Who has the most followers? A traversal plus a count(*) group-by:

patinadb demo.db query \
  "MATCH (:Person)-[:FOLLOWS]->(t:Person)
   RETURN t.name AS name, count(*) AS followers
   ORDER BY followers DESC, name"

The --json flag emits a machine-readable form whose scalars block is the clean column view:

"scalars": {
  "name": ["Ada", "Linus", "Grace"],
  "followers": [2, 2, 1]
}

patinaDB passes ~95% of the openCypher TCK; see Cypher Support for the exact coverage and the Limitations chapter for the gaps.

4. Analyze

Built-in, read-only graph algorithms run over an in-memory snapshot of the current graph and yield real nodes plus a per-node result. Rank the influential accounts with PageRank:

patinadb demo.db query \
  "CALL patinadb.algo.pageRank('Person','FOLLOWS') YIELD node, score
   RETURN node.name AS name, round(score*1000)/1000 AS score
   ORDER BY score DESC"
"scalars": {
  "name":  ["Ada", "Linus", "Grace", "Margaret"],
  "score": [0.387, 0.374, 0.202, 0.038]
}

patinadb.algo.wcc (weakly-connected components) and patinadb.algo.degree (degree centrality) round out the set, with gds.*.stream aliases for tooling. See Procedures.

To understand the shape of your data before writing queries, inspect the planner’s statistics catalog — per-property count, distinct values (NDV), and min/max:

patinadb demo.db query \
  "CALL patinadb.stats('Person') YIELD label, property, count, ndv, min, max
   RETURN property, count, ndv, min, max ORDER BY ndv"
"scalars": {
  "property": ["age", "city", "name", null],
  "count":    [4, 4, 4, 4],
  "ndv":      [4, 4, 4, null],
  "min":      [29, "Boston", "Ada", null],
  "max":      [40, "New York", "Margaret", null]
}

The same catalog feeds cost-based entry-point selection and join ordering — it only ever changes which plan runs, never the result. See Procedures → Statistics catalog.

5. Export

The complement of import: scan the whole graph read-only into import-compatible files — one node file per label, one relationship file per type:

patinadb demo.db export --out dump
exported 4 nodes (1 files), 5 edges (1 files) to dump in 0.00s
$ ls dump
nodes_Person.csv  rels_FOLLOWS.csv

$ cat dump/nodes_Person.csv
:ID(uuid),age:int,city,name,:LABEL
1ac09593-6a05-5203-b12a-91294189c587,40,New York,Grace,Person
1be3441a-1076-5b1a-97f5-da854d48b8f3,33,Boston,Margaret,Person
263ca574-2e85-505f-bb21-e4c490b055da,29,Helsinki,Linus,Person
440d7304-d691-50fa-806a-888ead31a245,36,London,Ada,Person

Because the id column is written under an :ID(uuid) header, re-importing reproduces the same graph, same UUIDs — a lossless round-trip:

patinadb fresh.db import --nodes dump/nodes_Person.csv \
                         --rels  dump/rels_FOLLOWS.csv --id-type uuid
# imported 4 nodes (12 props), 5 edges (5 props)

--format parquet / --format arrow write the columnar equivalents. From within a query you can also export selectively with CALL patinadb.export.csv(label, path) or CALL patinadb.export.query(query, file) — see Procedures → CSV export. On the server, GET /mgmt/export?db=…&format=… streams a portable tar archive of a whole database.

6. From code

Python

The Python bindings (PyO3 + maturin) give you a data-science-friendly, row-oriented API on top of the same embedded engine:

import patinadb

db = patinadb.Database.open("./demo.db")

# Row-oriented results: list[dict] keyed by RETURN column — pandas-ready.
rows = db.query_rows(
    "MATCH (p:Person) RETURN p.name AS name, p.age AS age ORDER BY p.age DESC")
import pandas as pd
df = pd.DataFrame(rows)          # columns: name, age

# Bulk-load DataFrame records straight into the graph (deterministic UUIDs
# from the id key, so re-runs are idempotent and edges reference by key):
db.bulk_load_nodes("Person", people_records, id_key="id")
db.bulk_load_edges("FOLLOWS", follows_records)

# Thin wrappers over the graph-algorithm procedures:
ranked = db.page_rank(label="Person", rel_type="FOLLOWS")

Install the patinadb Python wheel to use this (pip install patinadb-<version>-<platform>.whl). See Language Bindings.

AI agents (MCP) and the browser

  • The MCP server (patinadb-mcp) exposes a database to an AI agent as structured tools — cypher / write / schema, analytics (statistics, graph_algorithm, import_csv), and the versioning differentiators (engrams / diff / time_travel_query / provenance). See Language Bindings → MCP.
  • The Graph Browser & Admin UI opens a database directly (or connects to a server over REST/Bolt), visualizes the graph, paints PageRank / WCC / degree results onto it, and surfaces the cluster/metrics admin dashboard.
  • The server speaks Bolt, so the official Neo4j drivers and the Neo4j Browser point straight at patinaDB.

7. Time-travel & provenance — the differentiators

Every write that goes through a query is recorded as an engram — a git-like commit. Build up some history:

patinadb social.db query "CREATE (a:Person {name:'Ada', team:'core'})"
patinadb social.db query "CREATE (g:Person {name:'Grace', team:'core'})"
patinadb social.db query "MATCH (a:Person {name:'Ada'}) SET a.team = 'infra'"

List the history (oldest first) with patinadb social.db log, then view a git-style diff of any single change:

patinadb social.db diff <set-engram-id>
engram ae2eae1a-3fcc-5c26-aad9-b11c4949b5c3
parent:    54f17ad5-bc1d-5686-867a-9891bc906d92
timestamp: 1783448468

~ (7530d903:Person).team: 'core' -> 'infra'

Query the graph as it was at any past engram — no audit table required:

# Ada's team at the engram just before the SET:
patinadb social.db query \
  "MATCH (a:Person {name:'Ada'}) RETURN a.team AS team" --at <prev-engram-id>
# team: 'core'   (it is 'infra' at HEAD)

On the server the same reads use the USE db AS OF '<engram>' selector (or a named TAG). See Time Travel, Diffs, and Engrams.

Finally, Anamnesis auto-projects that change stream into a queryable W3C-PROV graph in a companion <db>__anamnesis database — who, what, when, and optional source / confidence / derived_from — enriched straight from Neo4j transaction metadata. It is opt-in per database (CALL patinadb.anamnesis.enable()) on the server, where writes flow through Raft. Where other databases leave you to assemble provenance from triggers or CDC, here it ships in the engine.

Where to go next