JetGraph uses Cypher, the standard graph query language originally developed for Neo4j and now governed by the openCypher specification. If you know SQL, Cypher will feel natural — it uses a similar declarative style but describes patterns in the graph rather than joins between tables.
📖
This section covers the essentials with fraud-detection examples. For the complete language reference — every clause, function, procedure, engine limit, and JetGraph-specific behaviour — see the JetGraph Cypher Manual.
Basic Patterns
Cypher uses ASCII-art notation to express graph patterns:
(n:LABEL) — a node with a label
-[:EDGE_TYPE]-> — a directed relationship
(a)-[:EDGE]->(b) — node a connected to node b
CREATE — Insert a Node
cypher
// Create a CARD node with propertiesCREATE (c:CARD {external_id: "card-001", country: "US"})
RETURN c.external_id AS id
MATCH — Query Nodes
cypher
// Find a specific cardMATCH (c:CARD {external_id: "card-001"})
RETURN c.external_id AS id, c.country AS country
// Find all cards (with limit — always paginate large result sets)MATCH (c:CARD)
RETURN c.external_id AS id
LIMIT 100
CREATE — Insert a Relationship
cypher
// Connect a CARD to a MERCHANTMATCH (c:CARD {external_id: "card-001"}),
(m:MERCHANT {external_id: "merchant-42"})
CREATE (c)-[:TRANSACTS_AT {amount: 49.99, ts: 1712345678}]->(m)
RETURNtrueAS created
Traversal — Multi-hop Queries
cypher
// Find all merchants this card has visitedMATCH (c:CARD {external_id: "card-001"})-[:TRANSACTS_AT]->(m:MERCHANT)
RETURN m.external_id AS merchant
// Two-hop: other cards that share a device with this cardMATCH (c:CARD {external_id: "card-001"})-[:USES_DEVICE]->(d:DEVICE)
<-[:USES_DEVICE]-(other:CARD)
WHERE other.external_id <> "card-001"RETURN other.external_id AS related_card, d.fingerprint AS shared_device
Filtering with WHERE
cypher
MATCH (c:CARD)-[r:TRANSACTS_AT]->(m:MERCHANT)
WHERE r.amount > 500AND m.country = "US"RETURN c.external_id AS card, m.external_id AS merchant, r.amount
ORDER BY r.amount DESCLIMIT 20
Parameterized Queries
Always use parameters (prefixed with $) instead of string interpolation to avoid injection and improve query plan reuse:
// Count transactions per merchantMATCH (c:CARD)-[:TRANSACTS_AT]->(m:MERCHANT)
RETURN m.external_id AS merchant, COUNT(c) AS card_count
ORDER BY card_count DESCLIMIT 10
// Sum transaction amounts per cardMATCH (c:CARD)-[r:TRANSACTS_AT]->(m:MERCHANT)
RETURN c.external_id AS card, SUM(r.amount) AS total_spend
MERGE — Upsert Nodes and Relationships
MERGE matches an existing pattern or creates it if it does not exist. Use ON CREATE SET and ON MATCH SET to set properties conditionally:
cypher
// Upsert a node — create if absent, update timestamp if it existsMERGE (c:CARD {external_id: $card_id})
ON CREATE SET c.created_at = $ts, c.country = $country
ON MATCH SET c.last_seen = $ts
RETURN c.external_id, c.created_at
// Upsert a relationship between two existing nodesMATCH (c:CARD {external_id: $card_id}), (d:DEVICE {external_id: $device_id})
MERGE (c)-[:USES_DEVICE]->(d)
RETURNtrueAS linked
OPTIONAL MATCH
OPTIONAL MATCH works like a left outer join — if the pattern does not exist, the variables are bound to null rather than excluding the row:
cypher
// Return card with its device fingerprint, even if no device is linkedMATCH (c:CARD {external_id: $card_id})
OPTIONAL MATCH (c)-[:USES_DEVICE]->(d:DEVICE)
RETURN c.external_id AS card, d.external_id AS device
WITH — Pipeline and Filter Mid-Query
WITH passes results from one query stage to the next, allowing intermediate filtering, aggregation, and variable re-binding:
cypher
// Find cards with more than 5 distinct merchants, then get their devicesMATCH (c:CARD)-[:TRANSACTS_AT]->(m:MERCHANT)
WITH c, COUNT(DISTINCT m) AS merchant_count
WHERE merchant_count > 5MATCH (c)-[:USES_DEVICE]->(d:DEVICE)
RETURN c.external_id AS card, merchant_count, d.external_id AS device
LIMIT 50
UNWIND — Expand a List
UNWIND turns a list into individual rows, which is useful for batch operations driven by a parameter array:
cypher
// Create multiple nodes from a list parameter in one round-tripUNWIND $card_ids AS cid
CREATE (c:CARD {external_id: cid})
RETURN c.external_id AS created
// Parameters: {"card_ids": ["card-001", "card-002", "card-003"]}
Data Manipulation
Insert Data
Use CREATE to insert new nodes and relationships. Both the source and destination nodes must already exist before creating a relationship.
Use SET to update or add properties on an existing node:
cypher
MATCH (c:CARD {external_id: "card-001"})
SET c.risk_score = 0.85, c.flagged = trueRETURN c.external_id, c.risk_score
Delete a Node
A node must have no relationships before it can be deleted. Use DETACH DELETE to remove both the node and all its relationships in one step:
cypher
// Delete node and all its relationshipsMATCH (c:CARD {external_id: "card-001"})
DETACH DELETE c
Delete a Relationship
cypher
MATCH (c:CARD {external_id: "card-001"})-[r:TRANSACTS_AT]->(m:MERCHANT)
DELETE r
Bulk Inserts
For bulk data loading, fire multiple POST /cypher requests in parallel. Each request is independent and thread-safe. For maximum throughput from Rust, use the jetgraph-client crate which batches requests over a persistent gRPC connection.
ℹ️
JetGraph can ingest up to 35,000 events per second via the streaming ingestion pipeline. For high-volume onboarding, use the bulk import tooling rather than individual /cypher POST requests.
Cypher Best Practices
These rules are derived from direct analysis of the JetGraph query planner logs. Each one maps to a specific engine optimisation — or the absence of one — that has measurable impact at scale. Follow them to keep every query O(1) or close to it.
1. Always label every node in MATCH
The pushdown optimizer converts a WHERE node.external_id = $value filter into an O(1) IndexLookup only when it knows the node type. Without a label the engine produces NodeScan { node_type: None } — a full scan across every node in the graph, repeated once per edge type registered in the schema.
cypher — ✗ Avoid
// No label on (s) → full graph scan × number of edge typesMATCH (s)-[r]->(d)
WHERE s.external_id = $id
RETURN s, r, d
cypher — ✓ Prefer
// Typed nodes → pushdown rewrites NodeScan to O(1) IndexLookupMATCH (s:CARD)-[r:TRANSACTS_AT]->(d:MERCHANT)
WHERE s.external_id = $id
RETURN s.external_id, d.external_id
2. Always use parameters — never embed literal values
Literal values baked into the query string each create a unique plan cache key. Every new literal triggers a full parse + plan cycle regardless of how many times a similar query has been run before. Parameters collapse every variation of a query into a single cached plan that is reused for every card ID, merchant ID, device fingerprint, or IP address.
cypher — ✗ Avoid
// Each card number is a separate cache key — replanning on every callMATCH (c:CARD {external_id: "9792487647826207"})-[:TRANSACTS_AT]->(m:MERCHANT)
RETURN m.external_id AS merchant
cypher — ✓ Prefer
// One plan, cached forever — value supplied at runtime via parametersMATCH (c:CARD {external_id: $card_id})-[:TRANSACTS_AT]->(m:MERCHANT)
RETURN m.external_id AS merchant
💡
This applies equally to device fingerprints, IP addresses, customer IDs, and every other lookup value. Any hardcoded string in the query string is a separate cache entry.
3. Filter on external_id equality on the source node
The pushdown optimizer walks the plan tree and converts Filter(src.external_id = value) → NodeScan into an IndexLookup. This rewrite only fires when the filter is an equality on external_id and applies to the source variable of the expand. Filters on destination properties or on any other property remain as post-expansion filters (slower).
cypher — ✗ Avoid
// No anchor on the source — engine scans all CARDs then filters merchantsMATCH (c:CARD)-[:TRANSACTS_AT]->(m:MERCHANT)
WHERE m.name CONTAINS'Airline'RETURN c.external_id, m.name
cypher — ✓ Prefer
// Anchor on source external_id → pushdown fires → IndexLookup for the cardMATCH (c:CARD)-[:TRANSACTS_AT]->(m:MERCHANT)
WHERE c.external_id = $card_id
AND m.name CONTAINS'Airline'RETURN c.external_id, m.name
// If you need to start from the merchant side, flip the traversal directionMATCH (m:MERCHANT)<-[:TRANSACTS_AT]-(c:CARD)
WHERE m.external_id = $merchant_id
RETURN c.external_id
4. Always type every relationship
An untyped relationship -[r]-> causes the engine to fan out the expand across every edge type registered in the schema — one full expand per type. With six edge types registered, a single untyped MATCH compiles into six separate query plans. Always name the relationship type explicitly.
cypher — ✗ Avoid
// Untyped [r] → engine fans out over EdgeTypeId(0), (1), (2) … for every typeMATCH (c:CARD)-[r]->(n)
WHERE c.external_id = $card_id
RETURNtype(r), n.external_id
cypher — ✓ Prefer
// Single explicit edge type → one targeted expand, one IndexLookupMATCH (c:CARD)-[:TRANSACTS_AT]->(m:MERCHANT)
WHERE c.external_id = $card_id
RETURN m.external_id
// Need multiple edge types? Use UNION ALL — each branch is individually optimizedMATCH (c:CARD)-[:TRANSACTS_AT]->(m:MERCHANT) WHERE c.external_id = $card_id
RETURN'merchant'AS kind, m.external_id AS neighbor
UNION ALLMATCH (c:CARD)-[:USES_DEVICE]->(d:DEVICE) WHERE c.external_id = $card_id
RETURN'device'AS kind, d.external_id AS neighbor
UNION ALLMATCH (c:CARD)-[:USES_IP]->(ip:IP) WHERE c.external_id = $card_id
RETURN'ip'AS kind, ip.external_id AS neighbor
5. Canonical fraud context — the recommended multi-hop template
This is the recommended pattern for pulling the full risk context of a card in a single round-trip: typed nodes, typed relationships, parameterized, one plan cached for every card. Each UNION ALL branch is compiled and optimized independently.
cypher — ✓ Canonical fraud context query
MATCH (c:CARD)-[:TRANSACTS_AT]->(m:MERCHANT)
WHERE c.external_id = $card_id
RETURN'merchant'AS kind, m.external_id AS id, m.name AS label
UNION ALLMATCH (c:CARD)-[:USES_DEVICE]->(d:DEVICE)
WHERE c.external_id = $card_id
RETURN'device'AS kind, d.external_id AS id, ''AS label
UNION ALLMATCH (c:CARD)-[:USES_IP]->(ip:IP)
WHERE c.external_id = $card_id
RETURN'ip'AS kind, ip.external_id AS id, ''AS label
6. Anchor ring / co-occurrence queries on the known node
Shared-entity ring queries (cards sharing a device or IP) must start from a known, typed anchor. An unanchored ring causes a full scan of all devices before expanding to cards. Start from the entity you know.
cypher — ✗ Avoid
// No anchor → scans all DEVICE nodes, expands to all cards, then filtersMATCH (d:DEVICE)<-[:USES_DEVICE]-(c:CARD)
WHERE c.country = 'US'RETURN d.external_id, c.external_id
cypher — ✓ Prefer
// Start from a known card → IndexLookup → expand to its devices → expand to sibling cardsMATCH (c1:CARD)-[:USES_DEVICE]->(d:DEVICE)<-[:USES_DEVICE]-(c2:CARD)
WHERE c1.external_id = $card_id
AND c2.external_id <> $card_id
RETURNDISTINCT c2.external_id AS linked_card, d.external_id AS shared_device
LIMIT 50
// Or start from a known deviceMATCH (d:DEVICE)<-[:USES_DEVICE]-(c:CARD)
WHERE d.external_id = $device_id
RETURN c.external_id AS linked_card
LIMIT 50
7. Always add LIMIT to traversals and scans
The engine propagates LIMIT hints down into NodeScan and VariableExpand nodes so BFS stops as soon as enough rows are found. Without a limit, traversals materialise the full result set before returning anything. This is especially important for variable-length paths.
cypher — ✓ Prefer
// LIMIT is pushed into the expand — BFS stops after finding 50 merchantsMATCH (c:CARD)-[:TRANSACTS_AT]->(m:MERCHANT)
WHERE c.external_id = $card_id
RETURN m.external_id, m.name
LIMIT 50
// Variable-length traversal: cap both hop count and result rowsMATCH (c:CARD)-[:TRANSACTS_AT*1..3]->(m:MERCHANT)
WHERE c.external_id = $card_id
RETURNDISTINCT m.external_id
LIMIT 100
8. Use db.nodeStats() for counts, not MATCH (n:TYPE)
A bare MATCH (n:CARD) RETURN count(n) materialises every node in that type before counting. db.nodeStats() reads a pre-maintained O(1) counter with no scan.
cypher — ✗ Avoid
// Materialises all nodes in the type before countingMATCH (n:CARD) RETURNcount(n) AS total
cypher — ✓ Prefer
// O(1) pre-computed counter — no scanCALL db.nodeStats() YIELD type, count
WHERE type = 'CARD'RETURN count
// If you must scan for exploration, always cap with LIMITMATCH (n:CARD) RETURN n.external_id LIMIT 25
9. Batch writes with graph.ingest()
Individual CREATE statements are planned and executed one at a time. For loading multiple nodes and edges in a single operation, use graph.ingest() — one round-trip, one plan, one atomic batch write regardless of how many entities are included.
10. Record transaction events with graph.upsertEdge()
For high-throughput edge upserts — recording payment events — use graph.upsertEdge() rather than MERGE-based patterns. It is a single O(1) procedure call that increments counters and updates the activity bitmap atomically, with no planner involved.