Summarizing Your Data

Applying Summary Patterns to Rows


Learning Objectives

  • You can count items in a list that match a condition, and total their values.
  • You can compute an average safely, handling the empty-data case.
  • You can unpack a row’s fields directly in the for loop header.

The first summaries you will write are the simplest ones: how many? and how much? You already have all the tools for these from the second part of the course. The new work is applying those tools to rows that have more than one field, and getting fluent with a small reading-the-row-in-the-loop-header idiom that makes the code clearer.

Shape of Data

Every example in this chapter works with the same shape of data: a list of sessions, where each session is a row of three fields — subject, minutes, date.

sessions = [
["math", 45, "2026-05-10"],
["physics", 90, "2026-05-10"],
["history", 30, "2026-05-11"],
["math", 60, "2026-05-11"],
["math", 25, "2026-05-12"],
]

As the get_session() function from the previous part calls .strip().lower() before storing a subject, the subjects are all lowercase in the saved data. The dates are still ISO strings.

Unpacking a Row in the Loop Header

So far, the project’s code has been accessing row fields by position — row[0], row[1], row[2] — which works but makes you remember what each column is. Python has a small piece of syntax that improves on this.

When the items in a list are themselves lists of a known length, the for loop can unpack them directly into named variables:

for subject, minutes, day in sessions:
print(f"{subject.title()}: {minutes} min on {day}")

The line for subject, minutes, day in sessions: walks through the sessions one at a time, the same way for session in sessions: would — but it pulls each row apart into three named pieces on the way in. Inside the loop, you use subject, minutes, and day directly.

The number of names must match the number of fields. If a row has three fields and you write for a, b in sessions:, Python raises ValueError.

When you do not care about one of the fields, the convention is to use an underscore as a placeholder:

for subject, minutes, _ in sessions: # date ignored
...

The underscore is a real variable name. By convention it signals I am unpacking this position because I have to, but I will not be using it to other readers of the code. It is a small but useful readability move to make it clear that the field is being ignored intentionally, rather than accidentally.

This chapter, and every chapter after it, uses unpacking by default. Reading code that says for subject, minutes, day in sessions: is easier than reading the same loop with row[0], row[1], row[2].

Counting Items That Match a Condition

The simplest counting summary asks “how many of these match this condition?” The pattern is small:

def count_long_sessions(sessions):
"""Return the number of sessions of 60 minutes or more.
Returns 0 for empty input.
"""
count = 0
for subject, minutes, day in sessions:
if minutes >= 60:
count += 1
return count

The structure is the familiar counting loop from the chapter Counting, Accumulating, and Searching: a counter starts at zero, walks the list, and increments once for each item that matches. The difference is that the condition is checked on a field of the row rather than on the row itself.

Notice the empty case. If sessions is [], the loop body never runs, and count stays at 0. The summary-contract table from the overview made this commitment explicit: Counts return 0 on empty input.

A small variant — counting matches against a specific subject — takes the subject as a parameter:

def count_sessions_for_subject(sessions, wanted_subject):
"""Return the number of sessions whose subject is `wanted_subject`."""
count = 0
for subject, minutes, day in sessions:
if subject == wanted_subject:
count += 1
return count

The shape is the same, but the condition is different. Most filtered-count functions look like this.

Count the matching rows

0 / 5 points

Consider this function:

def average_points_for_tag(rows, wanted_tag):
total = 0
count = 0
for tag, points, task_id in rows:
if tag == wanted_tag:
total += points
count += 1
if count == 0:
return None
return total / count

Why does an average-over-matching-rows function usually keep its own count variable?

Totalling the Items That Match

The companion pattern is “of the items that match, what is the total?” The shape is almost identical — the counter becomes an accumulator, the increment becomes an addition:

def total_minutes_for_subject(sessions, wanted_subject):
"""Return total minutes for sessions whose subject is `wanted_subject`.
Returns 0 for empty input or no matches.
"""
total = 0
for subject, minutes, day in sessions:
if subject == wanted_subject:
total += minutes
return total

It is the counting loop with two small changes: instead of += 1 you add the field you care about, and the condition selects whichever subset of items you want.

The same shape handles every variant. Total minutes on long sessions — replace the condition. Total minutes on math sessions today — combine two conditions with and. Total of all sessions — drop the condition.

Total with no matching rows

0 / 5 points

A function totals the minutes for rows with a chosen label. What is the most useful result when no rows match?

Averages and the Empty-List Trap

Average is calculated with total / count. If the count is zero, the average is not defined and the division raises ZeroDivisionError. This is a common edge case for averages, and it deserves a deliberate answer — the summary-contract table states: Averages return None on empty input.

A function for “average minutes for one subject” handles both the count and the total in the same loop, and returns None when there is nothing to average:

def average_minutes_for_subject(sessions, wanted_subject):
"""Return the average minutes for sessions whose subject is `wanted_subject`.
Returns None if there are no matching sessions.
"""
total = 0
count = 0
for subject, minutes, day in sessions:
if subject == wanted_subject:
total += minutes
count += 1
if count == 0:
return None
return total / count

One pass through the data. Two accumulators — the count and the total — updated together inside the same if. The empty case is handled explicitly at the bottom: if no sessions matched, count is zero and the function returns None rather than dividing.

If you are averaging over a filtered set of items, you cannot use len(sessions) for the count — you have to count the matches as you go, because the number of matches is not known in advance.

Average with no matching rows

0 / 5 points

A function computes the average points for rows with a chosen tag. What should it return when no rows match the tag?

The caller decides what to do with None:

avg = average_minutes_for_subject(sessions, "math")
if avg is None:
print("No math sessions yet.")
else:
print(f"Average math session: {avg:.1f} minutes")

Average points for a tag

0 / 10 points

Write a function named average_points_for_tag(rows, wanted_tag).

Each row in rows has this shape:

[tag, points, day]

The function must return the average of the points values for rows whose tag equals wanted_tag.

Requirements:

  • return None when the list is empty or when no rows match the tag,
  • ignore rows with other tags,
  • return a float when at least one row matches,
  • do not print anything,
  • do not use input(...),
  • and do not change the original list.

Example:

rows = [
["review", 4, "2026-03-01"],
["draft", 2, "2026-03-01"],
["review", 6, "2026-03-02"],
]

average_points_for_tag(rows, "review") should return 5.0.

A Worked Example: A Short Summary Block

Putting the three patterns together: a small summary block that computes three things in one pass through the data.

sessions = [
["math", 45, "2026-05-10"],
["physics", 90, "2026-05-10"],
["history", 30, "2026-05-11"],
["math", 60, "2026-05-11"],
["math", 25, "2026-05-12"],
]
total_overall = 0
long_count = 0
math_total = 0
math_count = 0
for subject, minutes, day in sessions:
total_overall += minutes
if minutes >= 60:
long_count += 1
if subject == "math":
math_total += minutes
math_count += 1
if math_count == 0:
math_average = None
else:
math_average = math_total / math_count
print(f"Total minutes: {total_overall}")
print(f"Long sessions: {long_count}")
if math_average is None:
print("Math: no sessions yet.")
else:
print(f"Math: {math_count} sessions, average {math_average:.1f} min")

Three summaries — overall total, count of long sessions, and average length of math sessions — and one pass through the data computes all of them. The empty-data behaviour for each is decided in advance: the totals and counts start at zero and stay at zero on empty input, and the average uses an explicit if math_count == 0 to make the empty case visible.

Summary for a bug label

0 / 15 points

Write a function named summary_for_label(rows, label).

Each row in rows has this shape:

[label, effort, ticket_id]

The function must return a list with three values:

[count, total, average]

where count is the number of rows whose label matches label, total is the sum of their effort values, and average is the average effort for the matching rows.

Requirements:

  • return [0, 0, None] when the input is empty or when no rows match,
  • ignore rows with other labels,
  • do not print anything,
  • do not use input(...),
  • and do not change the original list.

Filtering and Summarising in Two Passes

The patterns above compute summaries in one pass through the data. Sometimes it is clearer to break the work into two passes — first filter the rows you care about, and then summarise the filtered list.

For example, if there is a common need to filter sessions by subject, you could write a helper function that does just that:

def filter_sessions_for_subject(sessions, wanted_subject):
"""Return a list of sessions whose subject is `wanted_subject`."""
filtered = []
for subject, minutes, day in sessions:
if subject == wanted_subject:
filtered.append([subject, minutes, day])
return filtered

Then, the count function could call this helper to get the filtered list, and then just return the length of it:

def count_sessions_for_subject(sessions, wanted_subject):
"""Return the number of sessions whose subject is `wanted_subject`."""
filtered = filter_sessions_for_subject(sessions, wanted_subject)
return len(filtered)

Similarly, with the helper, the average function could call helper to get a filtered list, and then compute the average with total / len(filtered):

def average_minutes_for_subject(sessions, wanted_subject):
"""Return the average minutes for sessions whose subject is `wanted_subject`.
Returns None if there are no matching sessions.
"""
filtered = filter_sessions_for_subject(sessions, wanted_subject)
if len(filtered) == 0:
return None
total = 0
for subject, minutes, day in filtered:
total += minutes
return total / len(filtered)

Check Your Understanding

  1. Write a loop that counts how many sessions are shorter than 30 minutes.
  2. Why does the loop header for subject, minutes, day in sessions: work? What error would Python raise if sessions contained one row with four fields?
  3. What should an average function return when given an empty list? What should a total function return?
  4. In average_minutes_for_subject, the count accumulator is updated together with total inside the same if. Why — could you have used len(sessions) instead?
  5. In the worked example, three summaries are computed in one pass. When might you want three separate loops instead?

AI Study Aid

Create a chapter diagram

We're looking into to what extent AI-generated diagrams could help with studying.

Use this study aid to generate an AI-generated visual summary of the material you just studied. Each diagram style emphasizes the content in a different way, so you can choose the focus that feels most useful.

Using the diagram generator is voluntary and does not affect your course progress. We encourage you to try it out and see if it helps, but it's totally up to you! Your answers help us understand how to make better study aids in the future.

Diagrams tried: 0