Traditional Cassandra warns against join-heavy workloads. Ferrosa doesn’t — it has a built-in graph engine with bidirectional traversals and secondary indexes. This tutorial shows relationship queries that are impossible in vanilla Cassandra.

Note
Complete the 3-Node Cluster Setup tutorial first. Your cluster must have FERROSA_GRAPH_ENABLED=true set (which our cluster-setup tutorial includes).

Why Graph + CQL?

Apache Cassandra is a proven choice for time-series data, IoT ingestion, and write-heavy workloads. Its partition-key design delivers single-digit-millisecond reads at any scale. But there is one thing the Cassandra community has always warned against: relationship queries.

Need "find friends of friends"? "Which customers bought products that this customer also bought"? "What is the shortest path between two accounts"? In traditional Cassandra, these questions require multiple round trips from your application, client-side joins, and increasingly complex denormalized tables. Most teams end up running a second database — Neo4j, JanusGraph, or Amazon Neptune — just for graph queries. That means data duplication, ETL pipelines, and consistency headaches.

Ferrosa eliminates that trade-off. Your data lives in ordinary CQL tables annotated with graph.* extensions. An async adjacency index maintains graph structure automatically in the background. You get the best of both worlds:

  • CQL for fast key-value operations, time-range scans, and high-throughput writes

  • Cypher (via Ferrosa’s graph HTTP endpoint) for relationship traversals, pattern matching, and path queries

Same data. Two query languages. No duplication, no ETL, no second database.

The Social Network Schema

We will model a small social network with six entity and relationship types. The graph data model maps naturally to CQL tables.

Vertex tables (entities)

  • Person — a user with a name, age, city, and join date

  • Company — an organization with an industry and founding year

  • Post — a piece of content authored by a Person

Edge tables (relationships)

  • FOLLOWS — Person follows Person (directed)

  • LIKES — Person likes Post

  • WORKS_AT — Person works at Company

Vertex tables use a single UUID primary key. Edge tables use a composite primary key of (source_id, target_id), which lets CQL answer "who does X follow?" efficiently. Ferrosa’s graph engine also maintains a reverse index so "who follows X?" is equally fast — something vanilla Cassandra cannot do without a second denormalized table.

Create the Graph Tables

Connect to your cluster with cqlsh and run the following DDL. Pay attention to the WITH extensions clauses — these are what tell Ferrosa to treat ordinary CQL tables as graph vertices and edges.

Understanding the extensions

Extension key Purpose

graph.type

Marks a table as vertex (entity) or edge (relationship). The graph engine ignores tables without this annotation.

graph.label

The name used in Cypher queries. MATCH (p:Person) references the table with graph.label = 'Person'.

graph.source

Column name holding the source vertex UUID. Only for edge tables.

graph.target

Column name holding the target vertex UUID. Only for edge tables.

graph.source_label

Which vertex label the source column references. Lets the engine resolve cross-table joins.

graph.target_label

Which vertex label the target column references.

Secondary indexes for reverse lookups

Create secondary indexes on columns you will filter by. Ferrosa’s secondary indexes are backed by local token-range scans and the graph adjacency index, making them far more efficient than Cassandra’s legacy secondary indexes.

The full schema (keyspace, graph tables, and indexes):

CREATE KEYSPACE IF NOT EXISTS social WITH replication = {
    'class': 'SimpleStrategy', 'replication_factor': 3
};

USE social;

-- Person vertex table
CREATE TABLE person (
    person_id uuid PRIMARY KEY,
    name text,
    age int,
    city text,
    joined_at timestamp
) WITH extensions = {
    'graph.type': 'vertex',
    'graph.label': 'Person'
};

-- Company vertex table
CREATE TABLE company (
    company_id uuid PRIMARY KEY,
    name text,
    industry text,
    city text,
    founded_year int
) WITH extensions = {
    'graph.type': 'vertex',
    'graph.label': 'Company'
};

-- Post vertex table
CREATE TABLE post (
    post_id uuid PRIMARY KEY,
    author_id uuid,
    content text,
    created_at timestamp,
    tags set<text>
) WITH extensions = {
    'graph.type': 'vertex',
    'graph.label': 'Post'
};

-- Person follows Person
CREATE TABLE follows (
    source_id uuid,
    target_id uuid,
    since timestamp,
    PRIMARY KEY (source_id, target_id)
) WITH extensions = {
    'graph.type': 'edge',
    'graph.label': 'FOLLOWS',
    'graph.source': 'source_id',
    'graph.target': 'target_id',
    'graph.source_label': 'Person',
    'graph.target_label': 'Person'
};

-- Person likes Post
CREATE TABLE likes (
    source_id uuid,
    target_id uuid,
    liked_at timestamp,
    PRIMARY KEY (source_id, target_id)
) WITH extensions = {
    'graph.type': 'edge',
    'graph.label': 'LIKES',
    'graph.source': 'source_id',
    'graph.target': 'target_id',
    'graph.source_label': 'Person',
    'graph.target_label': 'Post'
};

-- Person works at Company
CREATE TABLE works_at (
    source_id uuid,
    target_id uuid,
    role text,
    started_at timestamp,
    PRIMARY KEY (source_id, target_id)
) WITH extensions = {
    'graph.type': 'edge',
    'graph.label': 'WORKS_AT',
    'graph.source': 'source_id',
    'graph.target': 'target_id',
    'graph.source_label': 'Person',
    'graph.target_label': 'Company'
};

-- Person transfers money to Person (for fraud detection)
CREATE TABLE transfers (
    source_id uuid,
    target_id uuid,
    amount decimal,
    transferred_at timestamp,
    reference text,
    PRIMARY KEY (source_id, target_id, transferred_at)
) WITH extensions = {
    'graph.type': 'edge',
    'graph.label': 'TRANSFERS',
    'graph.source': 'source_id',
    'graph.target': 'target_id',
    'graph.source_label': 'Person',
    'graph.target_label': 'Person'
};

-- Secondary indexes for reverse lookups
CREATE INDEX ON person (city);
CREATE INDEX ON person (name);
CREATE INDEX ON company (industry);
CREATE INDEX ON post (author_id);

Load the Social Network

Insert sample data. We use short, readable UUIDs so you can follow along easily. In production you would use uuid() to generate them.

The data includes 8 people, 3 companies, 6 posts, follow relationships, likes, employment records, and transfer records for fraud detection.

USE social;

-- People
INSERT INTO person (person_id, name, age, city, joined_at) VALUES
  (aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa, 'Alice',   32, 'San Francisco', '2024-01-15T08:00:00Z');
INSERT INTO person (person_id, name, age, city, joined_at) VALUES
  (bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb, 'Bob',     28, 'San Francisco', '2024-02-20T10:30:00Z');
INSERT INTO person (person_id, name, age, city, joined_at) VALUES
  (cccccccc-cccc-cccc-cccc-cccccccccccc, 'Carol',   35, 'New York',      '2024-03-10T14:00:00Z');
INSERT INTO person (person_id, name, age, city, joined_at) VALUES
  (dddddddd-dddd-dddd-dddd-dddddddddddd, 'David',   41, 'New York',      '2024-03-22T09:15:00Z');
INSERT INTO person (person_id, name, age, city, joined_at) VALUES
  (eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee, 'Eve',     29, 'Austin',        '2024-04-05T16:45:00Z');
INSERT INTO person (person_id, name, age, city, joined_at) VALUES
  (ffffffff-ffff-ffff-ffff-ffffffffffff, 'Frank',   38, 'Austin',        '2024-05-12T11:00:00Z');
INSERT INTO person (person_id, name, age, city, joined_at) VALUES
  (11111111-1111-1111-1111-111111111111, 'Grace',   26, 'San Francisco', '2024-06-18T07:30:00Z');
INSERT INTO person (person_id, name, age, city, joined_at) VALUES
  (22222222-2222-2222-2222-222222222222, 'Hank',    45, 'Chicago',       '2024-07-01T13:20:00Z');

-- Companies
INSERT INTO company (company_id, name, industry, city, founded_year) VALUES
  (c0c0c0c0-c0c0-c0c0-c0c0-c0c0c0c0c0c0, 'Acme Corp',    'Technology', 'San Francisco', 2015);
INSERT INTO company (company_id, name, industry, city, founded_year) VALUES
  (d0d0d0d0-d0d0-d0d0-d0d0-d0d0d0d0d0d0, 'Globex Inc',   'Finance',    'New York',      2008);
INSERT INTO company (company_id, name, industry, city, founded_year) VALUES
  (e0e0e0e0-e0e0-e0e0-e0e0-e0e0e0e0e0e0, 'StartupXYZ',   'Technology', 'Austin',        2022);

-- Posts
INSERT INTO post (post_id, author_id, content, created_at, tags) VALUES
  (p0000001-0000-0000-0000-000000000001,
   aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa,
   'Just deployed our new graph engine. The future is traversals!',
   '2025-01-10T09:00:00Z', {'ferrosa', 'graph'});

INSERT INTO post (post_id, author_id, content, created_at, tags) VALUES
  (p0000002-0000-0000-0000-000000000002,
   bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb,
   'Hot take: denormalization is a workaround, not a strategy.',
   '2025-01-12T14:30:00Z', {'databases', 'opinion'});

INSERT INTO post (post_id, author_id, content, created_at, tags) VALUES
  (p0000003-0000-0000-0000-000000000003,
   cccccccc-cccc-cccc-cccc-cccccccccccc,
   'Wrote a fraud detection pipeline in 20 lines of Cypher. Mind blown.',
   '2025-01-15T11:00:00Z', {'fraud', 'cypher'});

INSERT INTO post (post_id, author_id, content, created_at, tags) VALUES
  (p0000004-0000-0000-0000-000000000004,
   eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee,
   'Exploring lock-free data structures for concurrent graph indexing.',
   '2025-01-18T16:00:00Z', {'concurrency', 'research'});

INSERT INTO post (post_id, author_id, content, created_at, tags) VALUES
  (p0000005-0000-0000-0000-000000000005,
   dddddddd-dddd-dddd-dddd-dddddddddddd,
   'Benchmarked Ferrosa against Cassandra + Neo4j. Single system wins.',
   '2025-01-20T08:45:00Z', {'benchmark', 'ferrosa'});

INSERT INTO post (post_id, author_id, content, created_at, tags) VALUES
  (p0000006-0000-0000-0000-000000000006,
   11111111-1111-1111-1111-111111111111,
   'Graph indexes + S3 write-behind = best of all worlds.',
   '2025-01-22T10:30:00Z', {'ferrosa', 's3'});

-- Follow relationships
-- Alice's network: follows Bob, Carol, and Eve
INSERT INTO follows (source_id, target_id, since) VALUES
  (aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa, bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb, '2024-02-21T00:00:00Z');
INSERT INTO follows (source_id, target_id, since) VALUES
  (aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa, cccccccc-cccc-cccc-cccc-cccccccccccc, '2024-03-11T00:00:00Z');
INSERT INTO follows (source_id, target_id, since) VALUES
  (aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa, eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee, '2024-04-10T00:00:00Z');

-- Bob follows Carol and David
INSERT INTO follows (source_id, target_id, since) VALUES
  (bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb, cccccccc-cccc-cccc-cccc-cccccccccccc, '2024-03-15T00:00:00Z');
INSERT INTO follows (source_id, target_id, since) VALUES
  (bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb, dddddddd-dddd-dddd-dddd-dddddddddddd, '2024-04-01T00:00:00Z');

-- Carol follows Alice and Frank (creates a cycle: Alice -> Carol -> Alice)
INSERT INTO follows (source_id, target_id, since) VALUES
  (cccccccc-cccc-cccc-cccc-cccccccccccc, aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa, '2024-03-12T00:00:00Z');
INSERT INTO follows (source_id, target_id, since) VALUES
  (cccccccc-cccc-cccc-cccc-cccccccccccc, ffffffff-ffff-ffff-ffff-ffffffffffff, '2024-06-01T00:00:00Z');

-- Eve follows Frank and Grace
INSERT INTO follows (source_id, target_id, since) VALUES
  (eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee, ffffffff-ffff-ffff-ffff-ffffffffffff, '2024-05-15T00:00:00Z');
INSERT INTO follows (source_id, target_id, since) VALUES
  (eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee, 11111111-1111-1111-1111-111111111111, '2024-06-20T00:00:00Z');

-- David follows Hank
INSERT INTO follows (source_id, target_id, since) VALUES
  (dddddddd-dddd-dddd-dddd-dddddddddddd, 22222222-2222-2222-2222-222222222222, '2024-07-05T00:00:00Z');

-- Grace follows Alice
INSERT INTO follows (source_id, target_id, since) VALUES
  (11111111-1111-1111-1111-111111111111, aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa, '2024-06-19T00:00:00Z');

-- Likes
-- Alice likes Bob's post and Carol's post
INSERT INTO likes (source_id, target_id, liked_at) VALUES
  (aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa, p0000002-0000-0000-0000-000000000002, '2025-01-12T15:00:00Z');
INSERT INTO likes (source_id, target_id, liked_at) VALUES
  (aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa, p0000003-0000-0000-0000-000000000003, '2025-01-15T12:00:00Z');

-- Bob likes Alice's post and Eve's post
INSERT INTO likes (source_id, target_id, liked_at) VALUES
  (bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb, p0000001-0000-0000-0000-000000000001, '2025-01-10T10:00:00Z');
INSERT INTO likes (source_id, target_id, liked_at) VALUES
  (bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb, p0000004-0000-0000-0000-000000000004, '2025-01-19T08:00:00Z');

-- Carol likes Alice's post and David's post
INSERT INTO likes (source_id, target_id, liked_at) VALUES
  (cccccccc-cccc-cccc-cccc-cccccccccccc, p0000001-0000-0000-0000-000000000001, '2025-01-11T09:00:00Z');
INSERT INTO likes (source_id, target_id, liked_at) VALUES
  (cccccccc-cccc-cccc-cccc-cccccccccccc, p0000005-0000-0000-0000-000000000005, '2025-01-20T10:00:00Z');

-- Eve likes Bob's post and Carol's post
INSERT INTO likes (source_id, target_id, liked_at) VALUES
  (eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee, p0000002-0000-0000-0000-000000000002, '2025-01-13T07:30:00Z');
INSERT INTO likes (source_id, target_id, liked_at) VALUES
  (eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee, p0000003-0000-0000-0000-000000000003, '2025-01-16T11:00:00Z');

-- David likes Grace's post
INSERT INTO likes (source_id, target_id, liked_at) VALUES
  (dddddddd-dddd-dddd-dddd-dddddddddddd, p0000006-0000-0000-0000-000000000006, '2025-01-22T14:00:00Z');

-- Employment
-- Alice and Bob work at Acme Corp
INSERT INTO works_at (source_id, target_id, role, started_at) VALUES
  (aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa, c0c0c0c0-c0c0-c0c0-c0c0-c0c0c0c0c0c0,
   'Staff Engineer', '2020-06-01T00:00:00Z');
INSERT INTO works_at (source_id, target_id, role, started_at) VALUES
  (bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb, c0c0c0c0-c0c0-c0c0-c0c0-c0c0c0c0c0c0,
   'Senior Engineer', '2022-01-15T00:00:00Z');

-- Carol and David work at Globex Inc
INSERT INTO works_at (source_id, target_id, role, started_at) VALUES
  (cccccccc-cccc-cccc-cccc-cccccccccccc, d0d0d0d0-d0d0-d0d0-d0d0-d0d0d0d0d0d0,
   'VP of Engineering', '2018-03-01T00:00:00Z');
INSERT INTO works_at (source_id, target_id, role, started_at) VALUES
  (dddddddd-dddd-dddd-dddd-dddddddddddd, d0d0d0d0-d0d0-d0d0-d0d0-d0d0d0d0d0d0,
   'Data Scientist', '2021-09-01T00:00:00Z');

-- Eve and Frank work at StartupXYZ
INSERT INTO works_at (source_id, target_id, role, started_at) VALUES
  (eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee, e0e0e0e0-e0e0-e0e0-e0e0-e0e0e0e0e0e0,
   'Co-Founder', '2022-01-01T00:00:00Z');
INSERT INTO works_at (source_id, target_id, role, started_at) VALUES
  (ffffffff-ffff-ffff-ffff-ffffffffffff, e0e0e0e0-e0e0-e0e0-e0e0-e0e0e0e0e0e0,
   'CTO', '2022-01-01T00:00:00Z');

-- Grace works at Acme Corp
INSERT INTO works_at (source_id, target_id, role, started_at) VALUES
  (11111111-1111-1111-1111-111111111111, c0c0c0c0-c0c0-c0c0-c0c0-c0c0c0c0c0c0,
   'Junior Engineer', '2024-06-20T00:00:00Z');

-- Transfers (for fraud ring detection)
-- Alice -> David: $5,000
INSERT INTO transfers (source_id, target_id, amount, transferred_at, reference) VALUES
  (aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa, dddddddd-dddd-dddd-dddd-dddddddddddd,
   5000.00, '2025-02-01T10:00:00Z', 'consulting');

-- David -> Frank: $4,800
INSERT INTO transfers (source_id, target_id, amount, transferred_at, reference) VALUES
  (dddddddd-dddd-dddd-dddd-dddddddddddd, ffffffff-ffff-ffff-ffff-ffffffffffff,
   4800.00, '2025-02-02T14:30:00Z', 'equipment');

-- Frank -> Alice: $4,500 (completes the ring)
INSERT INTO transfers (source_id, target_id, amount, transferred_at, reference) VALUES
  (ffffffff-ffff-ffff-ffff-ffffffffffff, aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa,
   4500.00, '2025-02-03T09:15:00Z', 'refund');

-- Legitimate (non-circular) transfers
INSERT INTO transfers (source_id, target_id, amount, transferred_at, reference) VALUES
  (bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb, cccccccc-cccc-cccc-cccc-cccccccccccc,
   250.00, '2025-02-01T11:00:00Z', 'lunch');
INSERT INTO transfers (source_id, target_id, amount, transferred_at, reference) VALUES
  (eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee, 11111111-1111-1111-1111-111111111111,
   75.00, '2025-02-04T16:00:00Z', 'book');

CQL Queries with Secondary Indexes

With secondary indexes in place, CQL can handle direct lookups efficiently. Let’s see what it can do — and where it hits its limits.

What CQL handles well

Find people in a city (secondary index scan):

Find who Alice follows (partition key lookup — fastest possible query):

Find posts by a specific author (secondary index on author_id):

Find technology companies (secondary index on industry):

Find who follows Alice (reverse lookup — uses ALLOW FILTERING in CQL, but Ferrosa’s graph engine makes this a fast indexed lookup):

USE social;

-- Find people in a city (secondary index scan)
SELECT name, age FROM person WHERE city = 'San Francisco';

-- Find who Alice follows (partition key lookup)
SELECT target_id, since FROM follows
WHERE source_id = aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa;

-- Find posts by a specific author (secondary index on author_id)
SELECT content, created_at FROM post
WHERE author_id = aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa;

-- Find technology companies (secondary index on industry)
SELECT name, city, founded_year FROM company
WHERE industry = 'Technology';

-- Find who follows Alice (reverse lookup)
SELECT source_id, since FROM follows
WHERE target_id = aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa
ALLOW FILTERING;

Where CQL hits a wall

These queries are impossible to express in a single CQL statement. Each one would require multiple round trips and application-side join logic:

  • Friends of friends: "Find people followed by people that Alice follows" — requires fetching Alice’s follows, then fetching each of their follows, then deduplicating

  • Mutual followers: "Who follows people that Alice follows?" — requires reverse lookups per target, then intersection

  • Shortest path: "What is the shortest follow chain from Alice to Hank?" — not expressible at all; requires BFS in application code

  • Pattern matching: "Find triangles: A follows B, B follows C, C follows A" — combinatorial explosion in application code

This is exactly where Ferrosa’s graph engine takes over.

Graph Queries with Cypher

Ferrosa exposes a graph HTTP endpoint on port 7474. The same data you just inserted via CQL is immediately available for Cypher queries. No ETL. No sync delay. The graph index is updated asynchronously as CQL writes land — typically within milliseconds.

Check graph health

curl http://localhost:7474/graph/health

View the graph schema

curl http://localhost:7474/graph/schema | python3 -m json.tool

Direct follows

Find everyone Alice follows. This is equivalent to the CQL partition-key lookup, but expressed in graph terms:

curl -X POST http://localhost:7474/graph/query \
  -H "Content-Type: application/json" \
  -d '{"query": "MATCH (a:Person {name: \"Alice\"})-[:FOLLOWS]->(b:Person) RETURN b.name, b.city"}'

Friends of friends (2-hop traversal)

Find people at distance 2 from Alice in the follow graph. This is the query that CQL cannot express in a single statement:

curl -X POST http://localhost:7474/graph/query \
  -H "Content-Type: application/json" \
  -d '{"query": "MATCH (a:Person {name: \"Alice\"})-[:FOLLOWS]->()-[:FOLLOWS]->(friend2:Person) WHERE friend2.name <> \"Alice\" RETURN DISTINCT friend2.name"}'

Reverse traversal: who follows Alice?

Bidirectional traversal is where the graph engine truly shines. In vanilla Cassandra, this requires ALLOW FILTERING (full-table scan). In Ferrosa, the graph engine’s reverse adjacency index makes it instant:

curl -X POST http://localhost:7474/graph/query \
  -H "Content-Type: application/json" \
  -d '{"query": "MATCH (follower:Person)-[:FOLLOWS]->(a:Person {name: \"Alice\"}) RETURN follower.name"}'

Coworkers: people at the same company

Traverse through a Company vertex to find colleagues. This crosses two edge types and would require three CQL queries plus application logic:

curl -X POST http://localhost:7474/graph/query \
  -H "Content-Type: application/json" \
  -d '{"query": "MATCH (a:Person {name: \"Alice\"})-[:WORKS_AT]->(c:Company)<-[:WORKS_AT]-(coworker:Person) WHERE coworker.name <> \"Alice\" RETURN coworker.name, coworker.age, c.name AS company"}'

Posts liked by people Alice follows

A 3-hop traversal across two different edge types. This combines social graph data with content interaction data in one query:

curl -X POST http://localhost:7474/graph/query \
  -H "Content-Type: application/json" \
  -d '{"query": "MATCH (a:Person {name: \"Alice\"})-[:FOLLOWS]->(friend:Person)-[:LIKES]->(p:Post) RETURN friend.name, p.content"}'

Shortest path between two people

Find the shortest follow chain between Alice and Hank. This is entirely impossible in CQL — it requires a breadth-first search that Cypher handles natively:

curl -X POST http://localhost:7474/graph/query \
  -H "Content-Type: application/json" \
  -d '{"query": "MATCH path = shortestPath((a:Person {name: \"Alice\"})-[:FOLLOWS*..6]->(h:Person {name: \"Hank\"})) RETURN [n IN nodes(path) | n.name] AS chain"}'
Important
Every Cypher query above reads from the same CQL tables you created earlier. There is no data duplication. A CQL INSERT into the follows table is immediately visible to Cypher. One database, two query paradigms.

Fraud Ring Detection

Graph databases excel at detecting fraud rings — circular patterns of transactions that hide money laundering or collusion. The transfers edge table in our schema lets us detect suspicious cycles.

Detect circular money flows

Alice sends money to David, David sends to Frank, Frank sends back to Alice. This forms a 3-node cycle — a classic money laundering pattern.

Find all cycles of length 2 to 4 in the transfer graph:

curl -X POST http://localhost:7474/graph/query \
  -H "Content-Type: application/json" \
  -d '{"query": "MATCH path = (a:Person)-[:TRANSFERS*2..4]->(a) RETURN [n IN nodes(path) | n.name] AS ring, [r IN relationships(path) | r.amount] AS amounts"}'

One query. Three lines. In vanilla Cassandra, detecting this cycle would require a custom application with BFS/DFS logic, multiple CQL queries per hop, and manual cycle detection. With Ferrosa’s graph engine, the database does the traversal for you.

Enrich with context

Once you have identified suspicious accounts, use Cypher to pull their full network context — who they work with, who they follow, and how they are connected:

curl -X POST http://localhost:7474/graph/query \
  -H "Content-Type: application/json" \
  -d '{"query": "MATCH (suspect:Person)-[:TRANSFERS*2..4]->(suspect) WITH DISTINCT suspect MATCH (suspect)-[r]-(connected) RETURN suspect.name, type(r) AS relationship, connected.name ORDER BY suspect.name"}'

Recommendation Engine

Collaborative filtering is a natural fit for graph queries: "People who liked the posts you liked also liked these other posts." This pattern powers recommendation systems at Netflix, Spotify, and Amazon.

Basic collaborative filtering

Find posts that Alice has not liked but that were liked by people who share her taste (liked the same posts she did):

curl -X POST http://localhost:7474/graph/query \
  -H "Content-Type: application/json" \
  -d '{"query": "MATCH (a:Person {name: \"Alice\"})-[:LIKES]->(p:Post)<-[:LIKES]-(other:Person)-[:LIKES]->(rec:Post) WHERE NOT (a)-[:LIKES]->(rec) RETURN rec.content, COUNT(other) AS score ORDER BY score DESC"}'

Follow recommendations

"Suggest people that Alice’s friends follow, but Alice does not yet follow" — the classic "People You May Know" feature:

curl -X POST http://localhost:7474/graph/query \
  -H "Content-Type: application/json" \
  -d '{"query": "MATCH (a:Person {name: \"Alice\"})-[:FOLLOWS]->(friend)-[:FOLLOWS]->(suggestion:Person) WHERE NOT (a)-[:FOLLOWS]->(suggestion) AND suggestion.name <> \"Alice\" RETURN suggestion.name, suggestion.city, COUNT(friend) AS mutual_friends ORDER BY mutual_friends DESC"}'

Company network discovery

Find companies where people in Alice’s extended network work. This helps with B2B sales, recruiting, or professional networking:

curl -X POST http://localhost:7474/graph/query \
  -H "Content-Type: application/json" \
  -d '{"query": "MATCH (a:Person {name: \"Alice\"})-[:FOLLOWS*1..2]->(connection:Person)-[:WORKS_AT]->(c:Company) WHERE c.name <> \"Acme Corp\" RETURN c.name, c.industry, COLLECT(DISTINCT connection.name) AS connections"}'

Run all Cypher queries as a test suite

A bash script is provided that runs all Cypher queries and checks HTTP status codes:

#!/usr/bin/env bash
set -euo pipefail

# Cypher graph queries for the social network tutorial.
# Requires a running Ferrosa cluster with FERROSA_GRAPH_ENABLED=true.

FERROSA_GRAPH_HOST="${FERROSA_GRAPH_HOST:-localhost}"
FERROSA_GRAPH_PORT="${FERROSA_GRAPH_PORT:-7474}"
BASE_URL="http://${FERROSA_GRAPH_HOST}:${FERROSA_GRAPH_PORT}"

PASS=0
FAIL=0

run_query() {
    local label="$1"
    local endpoint="$2"
    local method="${3:-GET}"
    local body="${4:-}"

    printf "%-60s " "${label}..."

    if [ "${method}" = "GET" ]; then
        HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "${BASE_URL}${endpoint}")
    else
        HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \
            -X "${method}" "${BASE_URL}${endpoint}" \
            -H "Content-Type: application/json" \
            -d "${body}")
    fi

    if [ "${HTTP_CODE}" -eq 200 ]; then
        echo "PASS (${HTTP_CODE})"
        PASS=$((PASS + 1))
    else
        echo "FAIL (${HTTP_CODE})"
        FAIL=$((FAIL + 1))
    fi
}

echo "========================================"
echo "Ferrosa Graph Cypher Query Tests"
echo "Host: ${FERROSA_GRAPH_HOST}:${FERROSA_GRAPH_PORT}"
echo "========================================"
echo ""

# Health and schema
run_query "Graph health check" "/graph/health"
run_query "Graph schema" "/graph/schema"

# Direct follows
run_query "Direct follows (Alice)" \
    "/graph/query" "POST" \
    '{"query": "MATCH (a:Person {name: \"Alice\"})-[:FOLLOWS]->(b:Person) RETURN b.name, b.city"}'

# Friends of friends (2-hop traversal)
run_query "Friends of friends (Alice, 2-hop)" \
    "/graph/query" "POST" \
    '{"query": "MATCH (a:Person {name: \"Alice\"})-[:FOLLOWS]->()-[:FOLLOWS]->(friend2:Person) WHERE friend2.name <> \"Alice\" RETURN DISTINCT friend2.name"}'

# Reverse traversal: who follows Alice?
run_query "Reverse traversal (who follows Alice)" \
    "/graph/query" "POST" \
    '{"query": "MATCH (follower:Person)-[:FOLLOWS]->(a:Person {name: \"Alice\"}) RETURN follower.name"}'

# Coworkers at the same company
run_query "Coworkers (Alice at Acme Corp)" \
    "/graph/query" "POST" \
    '{"query": "MATCH (a:Person {name: \"Alice\"})-[:WORKS_AT]->(c:Company)<-[:WORKS_AT]-(coworker:Person) WHERE coworker.name <> \"Alice\" RETURN coworker.name, coworker.age, c.name AS company"}'

# Posts liked by people Alice follows
run_query "Posts liked by Alice'\''s friends" \
    "/graph/query" "POST" \
    '{"query": "MATCH (a:Person {name: \"Alice\"})-[:FOLLOWS]->(friend:Person)-[:LIKES]->(p:Post) RETURN friend.name, p.content"}'

# Shortest path between Alice and Hank
run_query "Shortest path (Alice to Hank)" \
    "/graph/query" "POST" \
    '{"query": "MATCH path = shortestPath((a:Person {name: \"Alice\"})-[:FOLLOWS*..6]->(h:Person {name: \"Hank\"})) RETURN [n IN nodes(path) | n.name] AS chain"}'

# Fraud ring detection: circular money flows
run_query "Fraud ring detection (transfer cycles)" \
    "/graph/query" "POST" \
    '{"query": "MATCH path = (a:Person)-[:TRANSFERS*2..4]->(a) RETURN [n IN nodes(path) | n.name] AS ring, [r IN relationships(path) | r.amount] AS amounts"}'

# Fraud ring context enrichment
run_query "Fraud ring context (suspect relationships)" \
    "/graph/query" "POST" \
    '{"query": "MATCH (suspect:Person)-[:TRANSFERS*2..4]->(suspect) WITH DISTINCT suspect MATCH (suspect)-[r]-(connected) RETURN suspect.name, type(r) AS relationship, connected.name ORDER BY suspect.name"}'

# Collaborative filtering: post recommendations for Alice
run_query "Collaborative filtering (recommend posts)" \
    "/graph/query" "POST" \
    '{"query": "MATCH (a:Person {name: \"Alice\"})-[:LIKES]->(p:Post)<-[:LIKES]-(other:Person)-[:LIKES]->(rec:Post) WHERE NOT (a)-[:LIKES]->(rec) RETURN rec.content, COUNT(other) AS score ORDER BY score DESC"}'

# Follow recommendations: people you may know
run_query "Follow recommendations (people you may know)" \
    "/graph/query" "POST" \
    '{"query": "MATCH (a:Person {name: \"Alice\"})-[:FOLLOWS]->(friend)-[:FOLLOWS]->(suggestion:Person) WHERE NOT (a)-[:FOLLOWS]->(suggestion) AND suggestion.name <> \"Alice\" RETURN suggestion.name, suggestion.city, COUNT(friend) AS mutual_friends ORDER BY mutual_friends DESC"}'

# Company network discovery
run_query "Company network discovery (Alice)" \
    "/graph/query" "POST" \
    '{"query": "MATCH (a:Person {name: \"Alice\"})-[:FOLLOWS*1..2]->(connection:Person)-[:WORKS_AT]->(c:Company) WHERE c.name <> \"Acme Corp\" RETURN c.name, c.industry, COLLECT(DISTINCT connection.name) AS connections"}'

echo ""
echo "========================================"
echo "Results: ${PASS} passed, ${FAIL} failed"
echo "========================================"

if [ "${FAIL}" -gt 0 ]; then
    exit 1
fi

When to Use Graph vs. CQL

Ferrosa gives you both CQL and Cypher against the same underlying data. Here is a practical guide for choosing between them:

Query type Use CQL Use Cypher

Key-value lookup by ID

Yes — fastest path

Overkill

Time-range scans

Yes — clustering key

No

High-throughput writes

Yes — CQL writes

No (read-only endpoint)

Single-hop lookups

Yes — partition key

Either works

Reverse lookups

Possible with ALLOW FILTERING

Yes — reverse adjacency index

Multi-hop traversals

No — requires app-side joins

Yes — native support

Path finding

No — impossible in CQL

Yes — shortestPath, allPaths

Cycle / pattern detection

No

Yes — variable-length patterns

Aggregations across relationships

No

Yes — COUNT, COLLECT, AVG

Recommendation / collaborative filtering

No

Yes — native pattern

Fraud ring detection

No

Yes — cycle detection

Tip
Use CQL for all writes and simple reads. Use Cypher for complex relationship queries and analytics. Both hit the same data — there is no penalty for mixing them in the same application.

A typical application architecture uses CQL in the hot write path (e.g., recording a new follow, inserting a post, logging a transaction) and Cypher in the read path for features that involve relationships (e.g., "People You May Know", fraud alerts, recommendation feeds). Since both read from the same tables, your data is always consistent.

What You Learned

In this tutorial you:

  1. Created graph-annotated CQL tables using WITH extensions to mark vertex and edge tables. No schema migration — just annotations on standard CQL DDL.

  2. Loaded data with ordinary CQL INSERT statements. The graph engine builds its adjacency index asynchronously — you don’t interact with it directly.

  3. Queried relationships with CQL and saw where it works well (direct lookups, secondary index scans) and where it breaks down (multi-hop traversals, reverse lookups, path finding).

  4. Queried the same data with Cypher and saw how friends-of-friends, coworker discovery, and shortest-path queries become single, readable statements.

  5. Detected a fraud ring with a 3-line Cypher query that would have required hundreds of lines of application code against vanilla Cassandra.

  6. Built a recommendation engine using collaborative filtering — a pattern that typically requires a separate graph database and ETL pipeline.

The core insight: same data, two query paradigms. CQL for speed and scale. Cypher for relationships and patterns. No data duplication. No separate database. No ETL. This is unique to Ferrosa.

Next steps