groupBy + agg
Aggregate rows per group: counts, sums, averages, and how count(*) differs from count(col).
On this page
Show code in
Every code block on the page follows this.
You will learn
- How groupBy and agg turn many rows into one per group
- The difference between count(*), count(col) and countDistinct
- How to filter on an aggregate (HAVING)
- Why aggregations are cheaper than they look: partial aggregation
groupBy(keys).agg(...) returns one row per distinct key combination, with each aggregate computed over that group's rows. Aggregate functions skip nulls, except count("*").What it does
groupBy on its own returns a GroupedData object, not a DataFrame. Calling agg (or a shortcut like count()) finishes the job and returns a DataFrame with the grouping columns followed by the aggregates.
Step by step
employees
| name | dept | salary |
|---|---|---|
| Asha | Engineering | 72000 |
| Chitra | Engineering | 95000 |
| Ben | Sales | 48000 |
| Esha | Sales | 50000 |
| Dev | HR | 51000 |
Output
| dept | people | avg_salary |
|---|---|---|
| Engineering | 2 | 83500 |
| Sales | 2 | 49000 |
| HR | 1 | 51000 |
- 1Rows are split into groups by the key, here
dept. - 2Each aggregate is computed per group:
countcounts rows,avgaverages non-null salaries. - 3The output has one row per group. Output order is not guaranteed: add
orderByif you need one.
Run the example
from pyspark.sql import functions as F result = (employees .groupBy("dept") .agg( F.count("*").alias("people"), F.round(F.avg("salary")).alias("avg_salary"), F.max("salary").alias("top_salary"), ) .orderBy("dept"))
SELECT dept, COUNT(*) AS people, ROUND(AVG(salary)) AS avg_salary, MAX(salary) AS top_salary FROM employees GROUP BY dept ORDER BY dept
Switch to PySpark to edit and run this example in your browser.
Farid's department is null, and he still gets a group: groupBy treats all nulls in a key as one group. In ascending order that null group sorts first.
Three ways to count
| Expression | Counts | With nulls |
|---|---|---|
F.count("*") / COUNT(*) | Rows in the group | Counted |
F.count("manager_id") | Non-null values of that column | Skipped |
F.countDistinct("dept") | Distinct non-null values | Skipped |
For a quick estimate on huge data, F.approx_count_distinct uses HyperLogLog++ and is far cheaper than an exact distinct count, with a default relative error of about 5%.
Filtering on an aggregate
SQL has HAVING for conditions on aggregates. In PySpark you just filter after the aggregation; Catalyst produces the same plan.
(employees.groupBy("dept") .agg(F.count("*").alias("n")) .filter(F.col("n") >= 2))
SELECT dept, COUNT(*) AS n FROM employees GROUP BY dept HAVING COUNT(*) >= 2
Under the hood: partial aggregation
An aggregation needs all rows of a key on one executor, which means a shuffle. But Spark does not ship every row. It aggregates in two phases:
-
Map side
Each partition computes partial results per key
-
Shuffle
Only one partial row per key per partition moves
-
Reduce side
Partials are merged into final results
For avg, the partial result is a (sum, count) pair; the final step divides. That is why the plan shows two HashAggregate nodes around an Exchange. With few distinct keys, this shuffles almost nothing.
collect_list cannot shrink their input in the map phase, so they shuffle every value. They are the aggregations to watch on big data.Common mistakes
Selecting a column that is neither grouped nor aggregated
SELECT dept, name ... GROUP BY dept fails. In PySpark there is no way to express it: you must aggregate name somehow, for example with max_by or collect_list.Using count(col) when you mean rows
F.count("manager_id") skips the null manager and undercounts people.Relying on output order
orderBy when order matters.Key takeaways
groupBy(...).agg(...)returns one row per key combination.count("*")counts rows;count(col)skips nulls.- Filter after
aggto get SQL'sHAVING. - Spark aggregates before and after the shuffle, so most aggregations move little data.
Check yourself
3 questions1. A group has 5 rows; 2 have a null bonus. What does F.count("bonus") return?
Show the answer
3. count(col) counts non-null values only. count("*") would return 5.
2. What is the PySpark equivalent of SQL HAVING?
Show the answer
A filter after agg. Filter on the aggregated column after agg. Catalyst treats it exactly like HAVING.
3. Why does the physical plan of an aggregation show two HashAggregate nodes?
Show the answer
Partial aggregation before the shuffle, final aggregation after. Spark pre-aggregates on each partition so only partial results are shuffled, then merges them.
Practice it
Interview problems that use groupBy + agg: write the PySpark, run it, and get graded on hidden tests.
- Find Repeat Customers Easy
- Average Salary by Department Easy
- Departments with Large Teams Easy
- Find Partitions That Need Compaction Easy
- Word Count Medium
Go deeper
max_by / min_byPySpark functions
percentile_approxSpark internals
Narrow vs wide transformations
Primary sources: DataFrame.groupBy · GROUP BY clause