FreeCampus Python

Building Results with for Loops

Use for-loop accumulator patterns to total, count, transform, filter, group, validate, and collect unique values while preserving source evidence.
python-foundations decisions-repetition for-loops
Open in Colab
  • Level: Python Foundations · Unit 4
  • Estimated time: 4–5.5 hours
  • You will learn: Initialize and update loop results for transforming, filtering, totaling, counting, grouping, validation, and unique collection.
  • Practice in: Google Colab, JupyterLab, or a local editor

1. A loop needs a product, not only repeated output

Unit 3 showed how a for loop receives collection values. Most useful loops do more than print them: they build a result that remains after traversal.

distances = [4, 7, 3]
total_distance = 0

for distance in distances:
    total_distance += distance

print(total_distance)
assert total_distance == 14

total_distance is an accumulator: state initialized before the loop and updated once for each relevant item. Its job can be stated precisely:

After each iteration, total_distance equals the sum of distances already visited.

That statement is a practical loop invariant. It helps you decide where to initialize the name, what each iteration must change, and what the final value means.

Questions this lesson will answer

  • Which starting value fits a total, count, list, dictionary, or set result?
  • How do transform and filter patterns differ?
  • How can one traversal preserve both accepted and rejected evidence?
  • How do counters and grouped records use dictionaries?
  • When is a built-in clearer and safer than a manual loop?

A result-building loop starts with an empty or neutral result, receives one item, and updates the result before moving to the next item.

flowchart LR
  A[Initialize result once] --> B[Receive next item]
  B --> C{Use this item?}
  C -- Yes --> D[Update result]
  C -- No --> E[Preserve or skip evidence]
  D --> F{Items remain?}
  E --> F
  F -- Yes --> B
  F -- No --> G[Use final result]

2. Initialize once, before traversal

Moving initialization inside the loop erases earlier work:

distances = [4, 7, 3]

for distance in distances:
    total_distance = 0
    total_distance += distance

print(total_distance)

The displayed value is 3, the final item, because each iteration resets the total. On an empty list, the body never runs and total_distance would not exist at all.

The correct pattern gives the result a meaningful empty-input value:

total_distance = 0

for distance in []:
    total_distance += distance

assert total_distance == 0

Common initial values follow the promised result:

Result job Initial value Update example
numeric total 0 total += value
number of matches 0 count += 1
ordered transformed or filtered values [] result.append(value)
values grouped or counted by key {} update one key
unique observed values set() seen.add(value)
every item is valid True set false when one fails
at least one item matches False set true when one matches

An initial value is not boilerplate. It defines what the result means before any input arrives.

3. Total and count answer different questions

A total adds item values. A count adds one for each qualifying item:

readings = [8, -2, 0, 5, -1]
usable_total = 0
usable_count = 0

for reading in readings:
    if reading >= 0:
        usable_total += reading
        usable_count += 1

print(usable_total, usable_count)
assert usable_total == 13
assert usable_count == 3

The zero reading contributes nothing to the total but still contributes one to the count. Using if reading: would incorrectly exclude it.

The average requires both accumulators and an empty-case policy:

if usable_count > 0:
    usable_average = usable_total / usable_count
else:
    usable_average = None

None says no average exists. Returning zero would confuse “no readings” with “readings whose average is zero.”

Checkpoint: accumulator foundations

4. Transform every item into a new value

A transformation produces one output for every input, usually preserving order:

celsius_readings = [0, 10, 25]
fahrenheit_readings = []

for celsius in celsius_readings:
    fahrenheit = celsius * 9 / 5 + 32
    fahrenheit_readings.append(fahrenheit)

assert fahrenheit_readings == [32.0, 50.0, 77.0]
assert celsius_readings == [0, 10, 25]

The source remains evidence. Build a separate result rather than overwriting the input unless in-place mutation is an explicit requirement.

For richer records, preserve relationships:

stations = [
    {"name": "north", "reading": 3},
    {"name": "east", "reading": 7},
]
labels = []

for station in stations:
    label = f"{station['name']}: {station['reading']} units"
    labels.append(label)

assert labels == ["north: 3 units", "east: 7 units"]

The loop target station is rebound to each dictionary. The list accumulator keeps derived strings in source order.

5. Filter by appending only accepted items

A filter can produce zero or one output per input:

readings = [8, -2, 0, 5, -1]
usable = []

for reading in readings:
    if reading >= 0:
        usable.append(reading)

assert usable == [8, 0, 5]

The append belongs inside the conditional because acceptance decides whether the item enters the result. If it were aligned with if, every item would be added.

Silently dropping rejected data may make debugging harder. Preserve it when the program needs an audit trail:

usable = []
rejected = []

for reading in readings:
    if reading >= 0:
        usable.append(reading)
    else:
        rejected.append(reading)

assert usable == [8, 0, 5]
assert rejected == [-2, -1]

Now every source item belongs to exactly one result. A useful invariant is:

After each iteration, len(usable) + len(rejected) equals the number of items visited so far.

6. Combine transformation and filtering in an explicit order

Validate before applying an operation that assumes valid data:

raw_scores = ["8", "skip", "10"]
clean_scores = []
rejected_tokens = []

for token in raw_scores:
    if token.isdigit():
        score = int(token)
        clean_scores.append(score * 10)
    else:
        rejected_tokens.append(token)

assert clean_scores == [80, 100]
assert rejected_tokens == ["skip"]

The branch protects int(token). The transformation happens only on accepted text. This is a small data pipeline: inspect, classify, convert, store.

Unit 8 will teach exception handling for conversions whose valid forms are more complex. At this stage, choose inputs and guards that your current tools can explain.

Checkpoint: transform and filter

7. Count occurrences with a dictionary

A counter connects each observed value with how many times it appeared:

signals = ["blue", "red", "blue", "gold", "red", "blue"]
counts = {}

for signal in signals:
    if signal in counts:
        counts[signal] += 1
    else:
        counts[signal] = 1

assert counts == {"blue": 3, "red": 2, "gold": 1}

On the first occurrence, create the key with count 1. Later occurrences update the existing value. Dictionary .get() can express the same default:

counts = {}

for signal in signals:
    counts[signal] = counts.get(signal, 0) + 1

Read the right side first: obtain the existing count or zero, add one, then store the new count at the same key.

8. Group complete records by a key

Grouping maps one key to several original records:

observations = [
    {"zone": "north", "species": "owl"},
    {"zone": "south", "species": "fox"},
    {"zone": "north", "species": "moth"},
]
by_zone = {}

for observation in observations:
    zone = observation["zone"]
    if zone not in by_zone:
        by_zone[zone] = []
    by_zone[zone].append(observation)

assert [item["species"] for item in by_zone["north"]] == ["owl", "moth"]

The empty list must be created separately for each new key. Assigning one shared list to many keys would mix groups. Unit 6 develops aliasing in depth; for now, create the group exactly when its key first appears.

9. A set accumulator keeps unique observations

species = ["owl", "fox", "owl", "moth"]
unique_species = set()

for name in species:
    unique_species.add(name)

assert unique_species == {"owl", "fox", "moth"}

The set answers uniqueness and membership questions. It does not preserve a numeric position. If you need unique values in first-seen order, combine a set for fast “seen?” checks with a list for ordered output:

seen = set()
first_seen = []

for name in species:
    if name not in seen:
        seen.add(name)
        first_seen.append(name)

assert first_seen == ["owl", "fox", "moth"]

The two accumulators have different jobs and stay synchronized inside the same branch.

10. Validation accumulates a Boolean claim

To report whether every value is in range:

levels = [4, 8, 11]
all_valid = True

for level in levels:
    if not 0 <= level <= 10:
        all_valid = False

assert all_valid is False

all_valid starts true because no visited item has disproved the claim. Once false, it stays false. If you also need invalid values, collect them instead and derive all_valid = not invalid_levels afterward.

A separate “at least one” claim starts false and becomes true when a match is seen. Lesson 6 shows how a search can stop early when no additional evidence is needed.

11. Use positions and pairs only when the result needs them

enumerate() supplies a position with each value:

stops = ["dock", "ridge", "clinic"]
numbered = []

for number, stop in enumerate(stops, start=1):
    numbered.append(f"{number}. {stop}")

assert numbered == ["1. dock", "2. ridge", "3. clinic"]

zip() supplies aligned values from several collections and stops at the shortest:

names = ["north", "east", "south"]
readings = [4, 7, 2]
station_readings = {}

for name, reading in zip(names, readings, strict=True):
    station_readings[name] = reading

assert station_readings == {"north": 4, "east": 7, "south": 2}

Unit 3 taught those supply mechanics. Here they serve a result-building job. strict=True raises ValueError when one input ends early, preventing silent loss when aligned records are required.

12. Prefer a built-in when it says the complete job

Python already names common reductions:

values = [4, 7, 3]

assert sum(values) == 14
assert len(values) == 3
assert min(values) == 3
assert max(values) == 7

sum(values) is clearer than a manual total when no special filtering or trace is needed. min([]) and max([]) raise ValueError because an empty collection has no smallest or largest item. Supply a policy when empty input is expected:

values = []
smallest = min(values) if values else None
assert smallest is None

Do not replace a rich loop with a pile of built-ins if that hides the rule. Use the simplest operation that expresses the entire required artifact.

Checkpoint: structured results

13. Do not change the list you are currently traversing

Removing items from a list while a for loop visits it can skip values because positions shift:

numbers = [1, 2, 2, 3]

for number in numbers:
    if number == 2:
        numbers.remove(number)

print(numbers)

The result still contains a 2. After the first removal, the second 2 shifts into a position the loop has already advanced past.

Build a new result instead:

numbers = [1, 2, 2, 3]
without_twos = []

for number in numbers:
    if number != 2:
        without_twos.append(number)

assert numbers == [1, 2, 2, 3]
assert without_twos == [1, 3]

Preserving the source also makes before-and-after assertions possible.

14. Build an expedition report

Start with this source evidence:

observations = [
    {"station": "north", "species": "owl", "count": 2, "valid": True},
    {"station": "east", "species": "fox", "count": 1, "valid": True},
    {"station": "north", "species": "owl", "count": -1, "valid": False},
    {"station": "south", "species": "moth", "count": 4, "valid": True},
    {"station": "east", "species": "owl", "count": 3, "valid": True},
]

accepted = []
rejected = []
total_animals = 0
species_counts = {}
by_station = {}
unique_species = set()

In one for loop:

  1. place valid non-negative records in accepted and all others in rejected;
  2. update the total only for accepted records;
  3. add each accepted record’s count to species_counts;
  4. group accepted species names by station, preserving observation order; and
  5. collect unique accepted species.

Then derive station_lines with enumerate(..., start=1) over the station keys in their first-seen order. Run:

assert observations[2]["count"] == -1
assert len(accepted) == 4
assert rejected == [observations[2]]
assert total_animals == 10
assert species_counts == {"owl": 5, "fox": 1, "moth": 4}
assert by_station == {
    "north": ["owl"],
    "east": ["fox", "owl"],
    "south": ["moth"],
}
assert unique_species == {"owl", "fox", "moth"}
assert station_lines == ["1. north", "2. east", "3. south"]
Hint: let the validity branch own every update

Append rejected records in the first branch and accepted records in the other. Only the accepted branch should update totals, counters, groups, and the set. Use .get(species, 0) for totals by species, and create a station list only when its key is new.

Show one complete solution after attempting the lab
observations = [
    {"station": "north", "species": "owl", "count": 2, "valid": True},
    {"station": "east", "species": "fox", "count": 1, "valid": True},
    {"station": "north", "species": "owl", "count": -1, "valid": False},
    {"station": "south", "species": "moth", "count": 4, "valid": True},
    {"station": "east", "species": "owl", "count": 3, "valid": True},
]

accepted = []
rejected = []
total_animals = 0
species_counts = {}
by_station = {}
unique_species = set()

for observation in observations:
    if not observation["valid"] or observation["count"] < 0:
        rejected.append(observation)
    else:
        accepted.append(observation)
        total_animals += observation["count"]

        species = observation["species"]
        species_counts[species] = (
            species_counts.get(species, 0) + observation["count"]
        )
        unique_species.add(species)

        station = observation["station"]
        if station not in by_station:
            by_station[station] = []
        by_station[station].append(species)

station_lines = []
for number, station in enumerate(by_station, start=1):
    station_lines.append(f"{number}. {station}")

All result updates are controlled by the acceptance decision, and the original records remain available for comparison.

15. Explain the accumulator choices

  1. What does each accumulator mean before the first item arrives?
  2. Why does zero count as one accepted reading while adding zero to a total?
  3. How does collecting rejected records improve the audit trail?
  4. Why does each group key need its own list?
  5. When is sum() clearer than a manual loop, and when does a loop reveal needed filtering or evidence?

Key points

TipKey points
  • Initialize a result once before traversal with a value that correctly describes empty input.
  • A total adds values; a count adds one per match; transformation and filtering produce different numbers of outputs per input.
  • Lists preserve result order, dictionaries count or group by key, and sets preserve uniqueness.
  • State what an accumulator means after each iteration; that invariant guides initialization and updates.
  • Preserve rejected evidence when loss matters, and avoid mutating the list being traversed.
  • Prefer a built-in when it clearly states the whole job and its empty behavior is acceptable.

References

Back to top