Lesson 06

Aggregation & Grouping

Difficulty Intermediate Time

Learning objectives

After this lesson you will be able to:

  1. Explain when aggregation collapses many matched paths into one summary row per group.
  2. Use count, collect, sum, avg, min, and max correctly.
  3. Predict Cypher’s implicit grouping keys from non-aggregated expressions in RETURN or WITH.
  4. Place WITH as a grouping boundary so intermediate row multiplication does not corrupt later aggregates.
  5. Apply DISTINCT and count(DISTINCT …) to avoid inflated path counts.
  6. Read null-aware aggregation behavior for missing properties and optional matches.
  7. Write multi-step analytical queries against the Movie graph that mix filtering and aggregation safely.

Core concepts

Why aggregation exists in a graph query language

Pattern matching is excellent at expanding connectivity: one actor who starred in four movies can produce four rows; co-actor queries can explode further when two people share several films. Business questions rarely want every intermediate path. They want summaries—“how many movies did Tom Hanks act in?”, “what is the average review score for The Matrix?”, “list every director with the titles they directed.” Aggregation is the tool that turns a bag of matched paths into counts, totals, extremes, and collected lists.

In Cypher, aggregation is expression-driven rather than clause-driven like SQL’s explicit GROUP BY. When a WITH or RETURN contains aggregating functions, every non-aggregating expression in that clause becomes an implicit grouping key. Rows that share the same key values form one group; each aggregating function reduces the group to a single value. Understanding that rule is more important than memorizing function names.

All worked examples in this course assume the classic Neo4j Movie graph: nodes labeled Person and Movie, with relationships such as ACTED_IN, DIRECTED, PRODUCED, WROTE, and REVIEWED. Property names like name, title, released, and rating match the public Movie dataset conventions so you can paste queries into Neo4j Browser or Aura after loading the sample.

Core aggregating functions

count(*) counts rows in the group. Use it when every matched path should contribute, including rows that carry null properties. count(expression) counts only rows where the expression is non-null—critical after OPTIONAL MATCH or when a property may be missing. Prefer count(DISTINCT x) when path multiplicity would otherwise count the same entity twice.

collect(expression) builds a list of non-null values from the group. Empty groups yield an empty list, not null. Lists are first-class values: you can return them, pass them through WITH, later UNWIND them (Lesson 07), or filter them with list comprehensions (Lesson 09).

Numeric reducers—sum, avg, min, and max—ignore null inputs. avg over zero non-null values yields null. Mixing types that cannot compare or sum will fail at runtime; keep review scores numeric and release years integers when you plan to aggregate them.

CYPHER
// Implicit grouping key: person name; ORDER BY before collect for stable lists
MATCH (p:Person)-[:ACTED_IN]->(m:Movie)
WITH p, m
ORDER BY m.title
RETURN p.name AS actor,
       count(m) AS movies,          // rows per actor
       collect(m.title) AS titles,  // titles in title order
       min(m.released) AS earliest,
       max(m.released) AS latest
ORDER BY movies DESC

The query above never writes GROUP BY p.name. Cypher infers the group because p.name is not wrapped in an aggregating function while the other returned expressions are. If you accidentally return both p.name and m.title without aggregating the title, you are not grouping by actor alone—you are returning one row per actor–movie pair.

WITH as the grouping boundary

Real analytical pipelines almost never finish in a single clause. You filter people, expand movies, count them, then filter groups with high counts, then expand again. Each time you introduce aggregation, you must decide which bindings survive. That decision lives on WITH.

Think of WITH as a pipeline valve: only the expressions you project remain available downstream, and aggregation on that valve collapses rows before the next MATCH. Placing aggregation too late lets intermediate cartesian-style growth inflate counts. Placing it too early drops variables you still need—unless you collect them into lists first.

Filtering after aggregation uses WHERE on a subsequent clause (or a WITH … WHERE form). That is Cypher’s analogue of SQL HAVING: first form groups, then keep groups that satisfy a predicate on the aggregate.

CYPHER
MATCH (p:Person)-[:ACTED_IN]->(m:Movie)
WITH p, count(m) AS movieCount
WHERE movieCount >= 3
RETURN p.name AS actor, movieCount
ORDER BY movieCount DESC

DISTINCT and path multiplicity

Graphs create legitimate duplicate logical entities across different paths. If you match (a:Person)-[:ACTED_IN]->(m)<-[:ACTED_IN]-(b:Person) to find co-actors, Alice and Bob may share three movies and appear three times. A plain count(b) then answers “how many co-acting path rows exist,” not “how many unique co-actors exist.” Use count(DISTINCT b) or project distinct pairs before counting.

RETURN DISTINCT deduplicates entire result rows after projection. It is not a substitute for understanding multiplicity inside aggregation, but it is useful when you only need unique projected maps or values. Prefer precise DISTINCT inside aggregations when the goal is a correct count rather than a cosmetic unique list.

Version note: aggregation semantics for null skipping and list collection have been stable for years, but planner choices and available aggregating functions can grow across Neo4j releases. Always check the aggregating functions page of the Cypher Manual for your server major version when adopting newer helpers.

Diagram of Person nodes connecting to Movie nodes, then collapsing into per-actor count and collect aggregates
Many ACTED_IN paths expand first; aggregation collapses them into one summary row per actor group.

Worked examples

These examples use concrete Movie-graph identities. Expected tables match the classic Neo4j Movie graph (37 movies, 133 people). If your database has been edited or uses a different seed, counts and row order may differ.

Dataset note: Expected results use the classic Neo4j Movie graph and can differ if the data changes.

Count movies per actor

Expand every ACTED_IN relationship, group by person name, and rank actors by how many movies they appear in. This is the aggregation “hello world” for graphs.

CYPHER
MATCH (p:Person)-[:ACTED_IN]->(m:Movie)
RETURN p.name AS actor, count(m) AS movieCount
ORDER BY movieCount DESC, actor
LIMIT 5

Expected result

actor movieCount
Tom Hanks 12
Keanu Reeves 6
Hugo Weaving 5
Jack Nicholson 5
Meg Ryan 5

Collect cast lists and release extremes

For each movie released in or after 2000, report cast size and a collected list of actor names. Order actors by name before collect so the list is deterministic. This shows collect and count together.

CYPHER
MATCH (m:Movie)<-[:ACTED_IN]-(p:Person)
WHERE m.released >= 2000
WITH m, p
ORDER BY p.name
RETURN m.title AS title,
       m.released AS released,
       count(p) AS castSize,
       collect(p.name) AS cast
ORDER BY released, title
LIMIT 3

Expected result

title released castSize cast
Cast Away 2000 2 ["Helen Hunt", "Tom Hanks"]
Jerry Maguire 2000 9 ["Bonnie Hunt", "Cuba Gooding Jr.", "Jay Mohr", "Jerry O'Connell", "Jonathan Lipnicki", "Kelly Preston", "Regina King", "Renee Zellweger", "Tom Cruise"]
The Replacements 2000 4 ["Brooke Langton", "Gene Hackman", "Keanu Reeves", "Orlando Jones"]

Average review ratings with WITH filtering

Reviewers leave REVIEWED relationships with a rating property. Compute average, count, min, and max in the same WITH grouping boundary—before rows collapse—then keep movies with at least two reviews. Do not call min(avgRating) after aggregation; low and high must come from r.rating in the same aggregating clause as avg.

CYPHER
MATCH (m:Movie)<-[r:REVIEWED]-(:Person)
WITH m,
     avg(r.rating) AS avgRating,
     count(r) AS reviews,
     min(r.rating) AS low,
     max(r.rating) AS high
WHERE reviews >= 2
RETURN m.title AS title,
       round(avgRating, 1) AS avgRating,
       reviews, low, high
ORDER BY avgRating DESC

Expected result

Only movies with at least two REVIEWED edges appear. One-review titles (for example Cloud Atlas, Jerry Maguire, The Birdcage, Unforgiven) are filtered out.

title avgRating reviews low high
The Replacements 75.7 3 62 100
The Da Vinci Code 66.5 2 65 68

Distinct co-actors for Tom Hanks

Count unique people who share a movie with Tom Hanks. Without DISTINCT, multi-film collaborations inflate the number.

CYPHER
MATCH (tom:Person {name: 'Tom Hanks'})-[:ACTED_IN]->(m:Movie)
      <-[:ACTED_IN]-(co:Person)
WHERE co <> tom
RETURN count(co) AS pathRows,
       count(DISTINCT co) AS uniqueCoActors

Expected result

pathRows counts every co-acting path (one row per shared movie). uniqueCoActors counts each person once even if they share several films with Tom Hanks—so path rows (39) exceed distinct co-actors (34).

pathRows uniqueCoActors
39 34

Pitfalls

Exercises

  1. 1. Directors ranked by film count

    Return each director’s name and the number of movies they directed, highest first. Use the DIRECTED relationship.

    STARTER
    MATCH (p:Person)-[:DIRECTED]->(m:Movie)
    // group by p.name and count movies
    RETURN p.name
  2. 2. Movies with more than three actors

    Aggregate cast size, keep groups where the count is greater than three, and return title plus cast size.

    STARTER
    MATCH (m:Movie)<-[:ACTED_IN]-(p:Person)
    WITH m, count(p) AS castSize
    // filter castSize and return
    
  3. 3. Sum and average of release years for an actor

    For Keanu Reeves, return the sum and average of m.released across movies he acted in. Round the average to one decimal place.

    STARTER
    MATCH (p:Person {name: 'Keanu Reeves'})-[:ACTED_IN]->(m:Movie)
    RETURN // sum(m.released), avg(...)
    
  4. 4. Distinct collaborators for a director

    Find people who acted in movies directed by Lilly Wachowski. Return the unique actor count and a collected list of distinct names sorted alphabetically (hint: sort after collect using a list function or collect then post-process).

    STARTER
    MATCH (d:Person {name: 'Lilly Wachowski'})-[:DIRECTED]->(m:Movie)
          <-[:ACTED_IN]-(a:Person)
    RETURN // count(DISTINCT a), collect(DISTINCT a.name)
    

Lesson quiz

Five questions. Submit once to see your score and the full answer key.

Loading quiz…

Resources

Video

Watch on YouTube (opens in a new tab).