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 runs against a live
patinaDB server (Installation, Quick Start)
over its REST endpoint (curl) or Bolt endpoint (a Neo4j driver /
cypher-shell) — pick whichever fits your workflow; both run the identical
Cypher.
We’ll build a tiny social graph: Person nodes connected by FOLLOWS
relationships. REST examples assume a node reachable at localhost:21001 with
Basic auth neo4j:secret (see Quick Start).
1. Load data
For a one-off graph, just CREATE nodes with a query:
curl -s -u neo4j:secret -X POST localhost:21001/cypher \
-H 'content-type: application/json' \
-d '{"query": "CREATE (u1:Person {name:\"Ada\", age:36, city:\"London\"})"}'
For a real dataset, use LOAD CSV — a streaming CSV row source in the reading
pipeline, just like UNWIND but from a file. Point it at a file the server can
read (see file-I/O sandboxing below):
LOAD CSV WITH HEADERS FROM 'file:///data/people.csv' AS row
CREATE (:Person {
name: row.name, age: toInteger(row.age), city: row.city
})
# people.csv
name,age,city
Ada,36,London
Grace,40,New York
Linus,29,Helsinki
Margaret,33,Boston
LOAD CSV WITH HEADERS FROM 'file:///data/follows.csv' AS row
MATCH (a:Person {name: row.from}), (b:Person {name: row.to})
CREATE (a)-[:FOLLOWS {since: toInteger(row.since)}]->(b)
WITH HEADERSmakes each row a map keyed by header (row.name); without it each row is a list of string cells (row[0]).- Cells are strings — coerce with
toInteger/toFloat/toBoolean. A coercion may sit directly in aCREATE/MERGEpattern property, as above. - Only
file://and bare/relative paths are read;http(s)://is rejected.
Loading online, in batches — a lot of rows at once
A bare LOAD CSV … CREATE buffers all resolved writes into one transaction
(one Raft entry) before committing — fine for a small file, but an O(rows)
memory cliff for a large one. Wrap the write in
CALL { … } IN TRANSACTIONS OF n ROWS to chunk it into many small,
independently-committed transactions instead:
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 a cluster, as its own Raft entry. ON ERROR {CONTINUE|BREAK|FAIL} controls what happens to a failing batch. See
Bulk Loading & Import.
Loading a lot of rows — bulk upsert
For loading (or reconciling) a large dataset from a file already sitting on the
server’s disk, POST /mgmt/upsert (admin-only) resolves and applies a whole
columnar file as one match-or-create changeset, index-accelerated and
chunkable — see Bulk Upsert.
2. Enforce schema
Add constraints so bad data is rejected at write time. patinaDB supports
IS UNIQUE, IS NOT NULL, IS NODE KEY, and IS :: <TYPE> on nodes, plus the
same set on relationships — see Constraints for the full
list:
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:
CREATE (p:Person {name: 'Ada'})
error: Unique constraint violation on :Person(name): value already exists
SHOW CONSTRAINTS lists them. 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:
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:
MATCH (:Person)-[:FOLLOWS]->(t:Person)
RETURN t.name AS name, count(*) AS followers
ORDER BY followers DESC, name
Over REST, the response’s scalars block is the clean column view:
"scalars": {
"name": ["Ada", "Linus", "Grace"],
"followers": [2, 2, 1]
}
patinaDB passes ~97% 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:
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), patinadb.algo.degree
(degree centrality), patinadb.algo.betweenness, patinadb.algo.closeness,
patinadb.algo.triangleCount, and patinadb.algo.labelPropagation round out
the set (seven algorithms in all), 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:
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
Read the whole graph back out, read-only, into import-compatible files — one
node file per label, one relationship file per type. GET /mgmt/export
streams a tar archive:
curl -s -u neo4j:secret 'localhost:21001/mgmt/export?db=default&format=csv' \
-o dump.tar
tar xf dump.tar
$ ls
nodes_Person.csv rels_FOLLOWS.csv _schema.json
$ cat nodes_Person.csv
:ID(uuid),age:int,city,name,:LABEL
1ac09593-6a05-5203-b12a-91294189c587,40,New York,Grace,Person
...
?format=parquet / ?format=arrow write the columnar equivalents. Selective
exports are also available from within a query:
CALL patinadb.export.csv(label, path) or
CALL patinadb.export.query(query, file) write a file on the server —
see Procedures → CSV export and the
file-I/O sandbox note below.
Server file-I/O sandbox
LOAD CSV FROM 'file://…' and the export procedures both touch the
server’s own filesystem — they read/write a file on the machine running
the query, not on your client. Both are deny-by-default: an operator must
explicitly whitelist directories with --allow-csv-dir/--allow-export-dir,
and any query doing file I/O requires the global Admin role. See
Authentication & TLS → Cypher-driven file I/O.
6. Integrate
- The server speaks Bolt, so the official Neo4j drivers and the Neo4j Browser point straight at patinaDB.
- Change Streams (CDC) (
GET /changes) let an external system react to graph changes as they happen — cache invalidation, search-index sync, ETL. - The full REST API and management endpoints
(
/mgmt/*) cover backup/restore, cluster administration, and metrics.
7. Time-travel & provenance — the differentiators
Every write is recorded as an engram — a git-like commit. Build up some history:
CREATE (a:Person {name:'Ada', team:'core'})
CREATE (g:Person {name:'Grace', team:'core'})
MATCH (a:Person {name:'Ada'}) SET a.team = 'infra'
List the history (oldest first) with CALL patinadb.engrams(), then view a
git-style diff of any single change:
CALL patinadb.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.
Prefix the read with USE <db> AS OF '<engram-id>' (or pass an at field over
REST):
USE default AS OF '<prev-engram-id>'
MATCH (a:Person {name:'Ada'}) RETURN a.team AS team
-- team: 'core' (it is 'infra' at HEAD)
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()). Where other databases leave you to
assemble provenance from triggers or CDC, here it ships in the engine.
Where to go next
- Cypher Support — the full query language and its coverage.
- The Server (Raft) — run patinaDB as a replicated service.
- Full-Text & Vector Search — search in the engine.
- Limitations — read this before a production workload.