Surfalytics
PySpark course overview

DataFrame basics I · Topic 10 Free

Grouping: groupBy() and agg()

Same tasks, other language: the SQL version of this topic.

Data model — the tables you will query
N 1 N 1 N 1 N 1 cohorts id PK INT name TEXT start_date DATE format TEXT students id PK INT name TEXT city TEXT country TEXT cohort_id FK INT background TEXT joined_at DATE courses id PK INT title TEXT category TEXT difficulty TEXT hours INT enrollments id PK INT student_id FK INT course_id FK INT enrolled_at DATE completed_at DATE score INT job_offers id PK INT student_id FK INT company TEXT role TEXT city TEXT salary_usd INT remote BOOLEAN offer_date DATE

How the tables join

Relationship Cardinality Join condition
students.cohort_id → cohorts.id many students → one cohort JOIN cohorts ON students.cohort_id = cohorts.id
enrollments.student_id → students.id many enrollments → one student JOIN students ON enrollments.student_id = students.id
enrollments.course_id → courses.id many enrollments → one course JOIN courses ON enrollments.course_id = courses.id
job_offers.student_id → students.id many job offers → one student JOIN students ON job_offers.student_id = students.id

In short: each student belongs to one cohort. Students enroll in courses through enrollments, and their job offers land in job_offers.

The summary queries you wrote with GROUP BY translate to a two-step chain: groupBy() then agg().

SELECT country, COUNT(*) AS student_count
FROM students
GROUP BY country;
students.groupBy("country").agg(F.count("id").alias("student_count"))

The aggregate functions

SQLPySpark
COUNT(col)F.count("col")
SUM(col)F.sum("col")
AVG(col)F.avg("col")
MIN(col) / MAX(col)F.min("col") / F.max("col")

Several aggregates in one pass:

job_offers.groupBy("city").agg(
    F.count("id").alias("offers"),
    F.avg("salary_usd").alias("avg_salary"),
    F.max("salary_usd").alias("top_salary"),
)

Like SQL, aggregates skip NULLs — F.avg("score") averages only completed enrollments.

HAVING is just another filter

Here PySpark is actually simpler than SQL. There is no special keyword: after agg() you have a normal DataFrame, so you filter it:

SELECT category, COUNT(*) AS course_count
FROM courses
GROUP BY category
HAVING COUNT(*) > 2;
(courses
    .groupBy("category")
    .agg(F.count("id").alias("course_count"))
    .filter(col("course_count") > 2))

And the full SQL clause order you memorized — WHERE → GROUP BY → HAVING → ORDER BY → LIMIT — becomes one readable pipeline:

(job_offers
    .filter(col("remote") == False)                        # WHERE
    .groupBy("city")                                       # GROUP BY
    .agg(F.avg("salary_usd").alias("avg_salary"),
         F.count("id").alias("n"))
    .filter(col("n") >= 2)                                 # HAVING
    .orderBy(col("avg_salary").desc())                     # ORDER BY
    .limit(5))                                             # LIMIT

That pipeline shape is 80% of the PySpark you will write in Databricks, Glue, or Fabric. Congratulations — you have mirrored the whole Basics of selection I section in PySpark.

Practice

Real Python runs in your browser with the PySpark DataFrame API. Solve the tasks, or just experiment.