Working with properties on relationships

In Neo4j, not only nodes but also relationships can have properties. This makes it possible to store context or metadata directly on the connection between two entities — for example, the time of an action or a rating value.

The following example models a simple webshop where customers can like products. Each like has its own creation date and rating. Products also belong to one or more categories.

CREATE
  // Customers
  (c1:Customer {name: "Alice"}),
  (c2:Customer {name: "Bob"}),

  // Products
  (p1:Product {name: "Laptop"}),
  (p2:Product {name: "Smartphone"}),

  // Categories
  (cat1:Category {name: "Electronics"}),

  // Relationships between products and categories
  (p1)-[:HAS_CATEGORY]->(cat1),
  (p2)-[:HAS_CATEGORY]->(cat1),

  // Alice liked Laptop and Smartphone
  (c1)-[:LIKED {created_at: date("2025-10-01"), rating: 5}]->(p1),
  (c1)-[:LIKED {created_at: date("2025-10-03"), rating: 4}]->(p2),

  // Bob liked Smartphone
  (c2)-[:LIKED {created_at: date("2025-10-02"), rating: 3}]->(p2);
graph LR
    %% Customers
    c1(["👤 Alice"])
    c2(["👤 Bob"])

    %% Products
    p1(["💻 Laptop"])
    p2(["📱 Smartphone"])

    %% Category
    cat1(["🛍️ Electronics"])

    %% Relationships
    c1 -->|LIKED<br/>⭐5<br/>2025-10-01| p1
    c1 -->|LIKED<br/>⭐4<br/>2025-10-03| p2
    c2 -->|LIKED<br/>⭐3<br/>2025-10-02| p2
    p1 -->|HAS_CATEGORY| cat1
    p2 -->|HAS_CATEGORY| cat1

This diagram shows customers, products, and categories, connected through relationships. Each LIKED relationship stores its own rating and created_at property, giving extra meaning to the connection between a customer and a product.

Querying relationships with properties

Products liked by Alice

MATCH (c:Customer {name: "Alice"})-[r:LIKED]->(p:Product)
RETURN p.name, r.rating, r.created_at;

This query returns the products that Alice liked, together with the rating and the date she liked them.

Average rating per product

MATCH (:Customer)-[r:LIKED]->(p:Product)
RETURN p.name, avg(r.rating) AS average_rating;

Relationships can be aggregated just like nodes. In this case, the rating property is used to calculate the average rating per product.

Products liked by multiple customers

MATCH (c1:Customer)-[:LIKED]->(p:Product)<-[:LIKED]-(c2:Customer)
WHERE c1 <> c2
RETURN DISTINCT p.name, collect(DISTINCT c1.name + " & " + c2.name) AS shared_by;

This shows products that were liked by more than one customer.

collect() is an aggregation function in Cypher that gathers multiple values into a single list. It is similar to GROUP_CONCAT() in SQL or creating a list from grouped data in Python.

MATCH (c:Customer)-[:LIKED]->(p:Product)
RETURN p.name, collect(c.name) AS customers;

will result in: | p.name | customers | | ———- | —————- | | Laptop | [“Alice”] | | Smartphone | [“Alice”, “Bob”] |

Instead of returning one row per customer, the results are grouped by product, and all customer names are collected into a single list.

🧠 Question: List all products together with:

  • the number of customers who liked them, and
  • the list of those customers.
Click to reveal the answer ```cypher MATCH (c:Customer)-[:LIKED]->(p:Product) RETURN p.name AS product, COUNT(c) AS likes_count, collect(c.name) AS customers; ``` Explanation: MATCH finds all (Customer)-[:LIKED]->(Product) patterns. COUNT(c) counts how many customers liked each product. collect(c.name) gathers all customer names into a list. Result: | product | likes_count | customers | | ---------- | ----------- | ---------------- | | Laptop | 1 | ["Alice"] | | Smartphone | 2 | ["Alice", "Bob"] |


Filtering by category

MATCH (c:Customer)-[:LIKED]->(p:Product)-[:HAS_CATEGORY]->(cat:Category)
WHERE cat.name = "Electronics"
RETURN c.name, p.name, cat.name;

The same MATCH pattern can traverse across different relationship types to include category information.

🧠 Question: Return all customers who liked a product in the Electronics category with a rating of 4 or higher.

Click to reveal the answer ```cypher MATCH (c:Customer)-[r:LIKED]->(p:Product)-[:HAS_CATEGORY]->(cat:Category) WHERE cat.name = "Electronics" AND r.rating >= 4 RETURN c.name, p.name, r.rating; ``` This query looks for customers (c) who liked a product (p) in the Electronics category and gave it a rating of 4 or higher. - MATCH describes a three-step pattern: (Customer)-[:LIKED]->(Product)-[:HAS_CATEGORY]->(Category) This means: find all customers who are connected to a product that belongs to a specific category. - The WHERE clause adds two filters: - cat.name = "Electronics" limits the result to products in that category. - r.rating >= 4 filters out low ratings. - RETURN displays the customer name, the product name, and the rating from the LIKED relationship.

Comparing graph queries and relational databases

At first glance, the webshop example could also be implemented in a relational database. You would create three tables:

Table Purpose
customer Stores cutomer information
products Stores product information
likes Connects customer and products with columns created_at and rating

A simple SQL query can easily return who liked what:

SELECT customers.name, products.name, likes.rating, likes.created_at
FROM customers
JOIN likes   ON customers.id = likes.customer_id
JOIN products ON likes.product_id = products.id;

When a graph database becomes more powerful

The real difference appears when you start asking multi-hop or pattern-based questions — questions that go beyond a single join.

Example 1 — “Which customers like similar products?”

In SQL you would need a self-join on the likes table:

SELECT a.name AS customer1, b.name AS customer2, p.name AS common_product
FROM likes l1
JOIN likes l2 ON l1.product_id = l2.product_id AND l1.customer_id <> l2.customer_id
JOIN customers a ON l1.customer_id = a.id
JOIN customers b ON l2.customer_id = b.id
JOIN products p ON l1.product_id = p.id;

In Cypher, the same logic is expressed more intuitively:

MATCH (c1:Customer)-[:LIKED]->(p:Product)<-[:LIKED]-(c2:Customer)
WHERE c1 <> c2
RETURN DISTINCT c1.name, c2.name, p.name;

Example 2 — “Find customers who liked a product in the same category as something Alice liked.”

In SQL, this would require three joins and a subquery:

SELECT DISTINCT c2.name
FROM likes l1
JOIN products p1 ON l1.product_id = p1.id
JOIN categories cat ON p1.category_id = cat.id
JOIN products p2 ON p2.category_id = cat.id
JOIN likes l2 ON l2.product_id = p2.id
JOIN customers c1 ON l1.customer_id = c1.id
JOIN customers c2 ON l2.customer_id = c2.id
WHERE c1.name = 'Alice' AND c1.id <> c2.id;

In Cypher, this pattern is much easier to express and read:

MATCH (a:Customer {name:"Alice"})-[:LIKED]->(:Product)-[:HAS_CATEGORY]->(cat:Category)<-[:HAS_CATEGORY]-(:Product)<-[:LIKED]-(others:Customer)
WHERE a <> others
RETURN DISTINCT others.name, cat.name;

Visual comparison: relational vs graph model

flowchart LR
    %% Relational model (left)
    subgraph R[Relational model]
      c[customers]
      p[products]
      l[likes]
      c -->|customer_id| l
      p -->|product_id| l
    end

    %% Graph model (right)
    subgraph G[Graph model]
      C1(["👤 Alice"])
      C2(["👤 Bob"])
      P1(["💻 Laptop"])
      P2(["📱 Smartphone"])
      Cat(["🛍️ Electronics"])

      C1 -->|LIKED<br/>⭐5| P1
      C1 -->|LIKED<br/>⭐4| P2
      C2 -->|LIKED<br/>⭐3| P2
      P1 -->|HAS_CATEGORY| Cat
      P2 -->|HAS_CATEGORY| Cat
    end

    %% Connector between models
    R -.->|Joins become traversals| G

In a relational database, connections are stored indirectly using foreign keys and join tables. In a graph database, connections are first-class citizens — they are part of the data itself.

As a result:

  • Queries involving multiple joins (e.g. “friends of friends”, “customers with similar tastes”) become simple pattern matches in Cypher.
  • Relationships can also store their own properties (like rating or created_at),giving richer context to every connection.

Using WITH to control query flow

In Cypher, the keyword WITH is used to chain multiple query parts together. It works like a temporary handover of variables — you decide which data is passed from one part of the query to the next.

Why WITH is needed
Some queries in Cypher are built in steps:

  • First you find or aggregate some data
  • Then you filter, sort, or return part of it

Because of this, WITH acts as a bridge between query stages. You can think of it as a “pipeline” operator — it keeps the query readable and separates logical steps.

Example 1 – Filter after aggregation

The following query lists products that have an average rating of 4 or higher.

MATCH (c:Customer)-[r:LIKED]->(p:Product)
WITH p, avg(r.rating) AS average_rating
WHERE average_rating >= 4
RETURN p.name, average_rating;

Explanation:

  • MATCH finds all customers and products connected by a LIKED relationship.
  • WITH groups the results by product (p) and calculates the average rating for each product.
  • The WHERE clause filters products using the aggregated value average_rating.
  • Finally, RETURN displays only the product name and its average rating.

Example 2 – Passing additional variables

You can pass multiple variables through WITH, not just aggregates. For example, if you also want to list the customers who liked those products:

MATCH (c:Customer)-[r:LIKED]->(p:Product)
WITH p, avg(r.rating) AS average_rating, collect(c.name) AS customers
WHERE average_rating >= 4
RETURN p.name, average_rating, customers;

Here, both the aggregated values and the collected customer names are available after the WITH.

Concept Description Analogy
WITH Passes selected variables to the next part of the query Like a pipeline or temporary result table
MATCHWITHWHERERETURN Common flow in multi-step queries Similar to SQL: FROM ... GROUP BY ... HAVING ... SELECT
Why use it? Keeps complex queries readable and allows filtering after aggregation Enables modular thinking in Cypher


🧠 Question: Return all product categories that have an average rating of 4 or higher, and show for each category:

  • the average rating, and
  • the list of products in that category.
Click to reveal the answer ```cypher MATCH (:Customer)-[r:LIKED]->(p:Product)-[:HAS_CATEGORY]->(cat:Category) WITH cat, avg(r.rating) AS average_rating, collect(DISTINCT p.name) AS products WHERE average_rating >= 4 RETURN cat.name AS category, average_rating, products; ``` Explanation: - MATCH traverses the pattern from customers → products → categories. - WITH groups all results by category (cat) and calculates the average rating of all likes in that category. - collect(DISTINCT p.name) gathers the product names per category. - WHERE filters categories that have an average rating of at least 4. - RETURN lists the category, average rating, and the products that belong to it. Result: | category | average_rating | products | | ----------- | -------------- | ------------------------ | | Electronics | 4.0 | ["Laptop", "Smartphone"] |

Summary

This example demonstrates how relationships in a graph database can carry their own data. Properties such as rating or created_at make it possible to capture not only who is connected to what, but also how and when that connection occurred. This is especially useful for use cases such as recommendation systems, fraud detection, and social networks, where the details of interactions are as important as the entities themselves.


This site uses Just the Docs, a documentation theme for Jekyll.