Lists and tuples in Python — theory with a class’s grades, slices and traversal patterns (and the list that would not let me raise the grades)

I had a list with my class’s grades and I wanted to give everyone half a point. I wrote a for that went through the list and added 0.5 to each grade. I ran the program, no error showed up, I printed the list and this is what I saw:

grades = [6.5, 4.0, 9.0, 7.5]


def raise_half_point(grade_list):
    for grade in grade_list:
        grade += 0.5


raise_half_point(grades)
print(grades)
[6.5, 4.0, 9.0, 7.5]

“What do you mean not a single grade has changed? I added… where did those 0.5 go?” I spent a good while checking the addition, the +=, the function. Everything was fine. The problem was somewhere else: going through a list is not the same as modifying it, and the difference depends on how you write the for.

Today we put sequences in order: what lists, tuples and strings are, how to reach their elements and pieces of them (slices), how to go through them and which patterns you will use again and again (traversal, selection, search, maximum, accumulator and counter). All with one running example: a class’s grades. If you are coming from loops in Python and modules and packages, this is where for loops start to really pay off. And when you finish, the lists and tuples practice is waiting with three complete programs.

What a sequence is

A sequence is a collection of elements lined up: there is a first one, and each one (except the last) has a next one. The months of the year are a sequence: January comes first, then February, and so on up to December. Since they are lined up, every element has an order number (its position).

Python has three sequence types you will use all the time:

TypeWritten withExampleCan it be modified?
str (string)quotes"PYTHON"No (immutable)
tupleparentheses("Lucía", 2024)No (immutable)
listsquare brackets[7.5, 4.0, 9.0]Yes (mutable)

All three are sequential containers: they store several elements in order and share most of the operations we are about to see. The big difference is that the list accepts changes and the other two do not. In this article we focus on accessing, traversing and computing; how to modify a list in depth is left for the next one.

Creating lists and tuples

grades = [7.5, 4.0, 9.0, 6.0, 8.5]       # list: square brackets
student = ("Lucía", 2024, "X928476")     # tuple: parentheses
word = "PYTHON"                      # string: quotes
empty = []
single = (10,)                         # the comma is what creates the tuple

print(type(grades), type(student), type(word))
print(len(grades), len(student), len(word))
print(type((10)), type((10,)))
print(empty, single)
<class 'list'> <class 'tuple'> <class 'str'>
5 3 6
<class 'int'> <class 'tuple'>
[] (10,)

Between the lines:

  • A list goes in square brackets and can mix types; a tuple goes in parentheses. The list is usually used for a collection of things of the same kind (the grades) and the tuple to group different data about a single thing (a student: name, year of enrolment and code).
  • len() returns how many elements any sequence has. In a string it counts characters.
  • The line that gets forgotten most is the one for single: (10,) is a tuple with one element, and (10) is just the number 10 inside parentheses. What creates the tuple is the comma, not the parentheses.

Writing ("Python") thinking it is a one-element tuple. It is a string with parentheses around it. To make it a tuple it needs the comma: ("Python",).

And since tuples are immutable, you cannot change an element once it is created:

Traceback (most recent call last):
  File "tuple_error.py", line 2, in <module>
    student[0] = "Ana"
    ~~~~~~~^^^
TypeError: 'tuple' object does not support item assignment

Reaching one element: indexes

Every element is reached with its index, in square brackets. Python counts from zero: the first position is 0. And there is a very handy trick: negative indexes count from the end, where -1 is the last one.

Position01234
Element1020304050
Negative index-5-4-3-2-1
grades = [10, 20, 30, 40, 50]

print(grades[0], grades[4])         # first and last, counting from the start
print(grades[-1], grades[-5])       # last and first, counting from the end
print(grades[len(grades) - 1])      # same as grades[-1]
10 50
50 10
50

The last element of a sequence of n elements is number n - 1 (or, more simply, -1). If you go past the end, Python complains:

Traceback (most recent call last):
  File "index_error.py", line 2, in <module>
    print(grades[5])
          ~~~~~~^^^
IndexError: list index out of range

Using grades[len(grades)] for the last element. A list of 5 elements goes up to index 4; index 5 does not exist and an IndexError is raised.

Slices: pieces of a sequence

A slice is a copy of a piece of the sequence. It is written sequence[start:end:step], and any of the three parts can be left out. The important thing, and the one that is mixed up most often: the start is included and the end is not.

grades = [10, 20, 30, 40, 50]

print("grades[1:3]  ", grades[1:3])
print("grades[-3:-1]", grades[-3:-1])
print("grades[:3]   ", grades[:3])
print("grades[3:]   ", grades[3:])
print("grades[:-1]  ", grades[:-1])
print("grades[1:-1] ", grades[1:-1])
print("grades[::2]  ", grades[::2])
print("grades[::-1] ", grades[::-1])
print("grades[::-2] ", grades[::-2])
print("grades[:]    ", grades[:])
grades[1:3]   [20, 30]
grades[-3:-1] [30, 40]
grades[:3]    [10, 20, 30]
grades[3:]    [40, 50]
grades[:-1]   [10, 20, 30, 40]
grades[1:-1]  [20, 30, 40]
grades[::2]   [10, 30, 50]
grades[::-1]  [50, 40, 30, 20, 10]
grades[::-2]  [50, 30, 10]
grades[:]     [10, 20, 30, 40, 50]

How to read them:

  • grades[1:3] starts at index 1 and goes up to just before 3: it gives positions 1 and 2.
  • If you leave out the start, it begins at the beginning (grades[:3]); if you leave out the end, it goes up to the end (grades[3:]).
  • Negatives work like they do in a single index: grades[:-1] means “everything but the last one”.
  • The third number is the step. [::2] skips by twos, and a negative step goes backwards: [::-1] is the whole sequence turned around.
  • grades[:] copies the entire list.

With strings and tuples it works exactly the same:

word = "abcdefghij"

print(word[2:5])
print(word[:3])
print(word[-3:])
print(word[::2])
print(word[::-1])
cde
abc
hij
acegi
jihgfedcba

To avoid slipping with the end, remember that sequence[a:b] has b - a elements. So grades[1:3] has 2.

Wanting “the last two” and writing grades[-2:-1]. Since the end is not included, that gives you only one. To reach the end, leave the end empty: grades[-2:].

Joining, repeating and converting sequences

With + you join two sequences of the same type and with * you repeat a sequence a number of times:

first = [10, 20]
second = [30, 40]

print(first + second)          # concatenate: joins two sequences of the same type
print((1, 2) + (3,))
print("abc" + "def")
print([0] * 5)                    # repeat
print((1,) * 3)
print("ab" * 3)
print([1, 2, 3] * 2)
[10, 20, 30, 40]
(1, 2, 3)
abcdef
[0, 0, 0, 0, 0]
(1, 1, 1)
ababab
[1, 2, 3, 1, 2, 3]

[0] * 5 is the quick way to create a list of five zeros. But you cannot mix types with +:

Traceback (most recent call last):
  File "mix_error.py", line 1, in <module>
    print([1, 2] + (3, 4))
          ~~~~~~~^~~~~~~~
TypeError: can only concatenate list (not "tuple") to list

To go from one type to another you use the type’s name as a function:

digits = "123"

print(tuple(digits))
print(list(digits))
print(list((1, 2, 3)))
print(str((1, 2, 3)))
print(len(str((1, 2, 3))))
('1', '2', '3')
['1', '2', '3']
[1, 2, 3]
(1, 2, 3)
9

Look at the first two cases: when you turn a string into a tuple or a list, each character becomes an element. And when you turn a tuple into a string you do not get the elements joined together, but the tuple written as text, with its parentheses, commas and spaces (that is why it is 9 characters long).

Packing and unpacking

Putting several values into a tuple is packing. Taking them out into separate variables is unpacking:

student = ("Lucía", 2024, "X928476")      # pack: several values into one tuple
name, year, code = student           # unpack: one variable per element
print(name, year, code)

pair = name, year                      # the parentheses are optional
print(pair)

a, b = 1, 2
a, b = b, a                              # swap two variables
print(a, b)
Lucía 2024 X928476
('Lucía', 2024)
2 1

The swap a, b = b, a is one of the nicest advantages of Python: in other languages you would need an auxiliary variable. There is one rule: there must be as many variables on the left as elements on the right.

Traceback (most recent call last):
  File "unpack_error.py", line 2, in <module>
    name, year = student
    ^^^^^^^^^^
ValueError: too many values to unpack (expected 2)

Always unpack when you go through a list of tuples. for name, grade in students: reads far better than for student in students: followed by student[0] and student[1].

Going through a sequence

When you want to do the same thing with every element, you use the traversal pattern:

for each item in the sequence: do something with item

In Python, that is a for. And it works with any sequence:

def show(sequence):
    for item in sequence:
        print(item, end=", ")
    print()


show([10, 12, 23, 35])
show((1, 2, 3))
show("abc")
10, 12, 23, 35, 
1, 2, 3, 
a, b, c, 

(end=", " makes print finish with a comma and a space instead of a line break, and the final print() closes the line.)

Going through a range

Sometimes you do not have a stored sequence, but a series of consecutive numbers. That is what range() is for, and it takes up to three parameters: range(start, end, step). Just like in slices, the end is not included:

print(list(range(5)))
print(list(range(2, 7)))
print(list(range(0, 10, 2)))
print(list(range(5, 0, -1)))
print(list(range(5, 0)))
[0, 1, 2, 3, 4]
[2, 3, 4, 5, 6]
[0, 2, 4, 6, 8]
[5, 4, 3, 2, 1]
[]

The last line shows that, if the start and the end are “crossed” and you do not give a negative step, the range is empty and the loop does not run a single lap.

By element or by index: the mystery from the beginning

Now we have everything to solve my problem. There are two ways of going through a list, and they do not do the same thing:

def raise_wrong(grade_list, points):
    for grade in grade_list:
        grade += points


def raise_right(grade_list, points):
    for pos in range(len(grade_list)):
        grade_list[pos] += points


grades = [6.5, 4.0, 9.0, 7.5]
raise_wrong(grades, 0.5)
print("With for by element:", grades)
raise_right(grades, 0.5)
print("With for by index:  ", grades)
With for by element: [6.5, 4.0, 9.0, 7.5]
With for by index:   [7.0, 4.5, 9.5, 8.0]

Why one works and the other does not:

  • In for grade in grade_list, the variable grade is a copy of the reference to each number. When you do grade += points, Python creates a new number and makes grade point to it. The list never finds out.
  • In for pos in range(len(grade_list)) you go through the indexes, and grade_list[pos] += points does change what is stored in that position of the list.

Modifying the for variable expecting the list to change. If you need to change the elements, go through the indexes. If you only need to read them, go through the elements: it is shorter and clearer.

Going through until a condition

The for is meant for sequences where you know where they end. If the end is only discovered when you hit it (a special mark), use while. In fact, everything a for does can be written with a while:

def show_for(sequence):
    for item in sequence:
        print(item, end=", ")
    print()


def show_while(sequence):
    pos = 0
    while pos < len(sequence):
        print(sequence[pos], end=", ")
        pos += 1
    print()


show_for([10, 12, 23])
show_while([10, 12, 23])
10, 12, 23, 
10, 12, 23, 
data = [7, 5, 9, -1, 8]          # the -1 marks the end: whatever comes after does not count


def sum_until_mark(sequence):
    pos = 0
    total = 0
    while sequence[pos] >= 0:
        total += sequence[pos]
        pos += 1
    return total


print(sum_until_mark(data))
21

In the second function the -1 acts as an end mark: everything before it is added up, and the 8 that comes after is not even looked at. The general pattern is: prepare the first element, and while the end has not been reached, process it and move on to the next.

Traversal with selective processing

Many times you do not want to process every element, only those that meet a condition. The pattern is the same traversal, with a question before processing each element:

for each item in the sequence: if item meets the condition: do something with item

def show_passed(grades):
    for grade in grades:
        if grade >= 5:
            print(grade, end=", ")
    print()


show_passed([7.5, 4.0, 9.0, 3.5, 6.0, 8.5, 5.0])
7.5, 9.0, 6.0, 8.5, 5.0, 

This pattern is the basis of the others: almost everything coming now is a traversal (sometimes selective) with an auxiliary variable that keeps remembering something.

Search: is there any that meets…?

In a search you want to know whether any element meets a condition. It is a selective traversal with one important difference: as soon as you find it, you stop (with a return) and there is no need to go on until the end.

def has_fail(grades):
    for grade in grades:
        if grade < 5:
            return True      # found it: no need to go on
    return False             # went through everything and it never showed up


print(has_fail([7.5, 4.0, 9.0]))
print(has_fail([7.5, 6.0, 9.0]))
True
False

Between the lines:

  • The first return True is inside the if: it leaves the function as soon as the first failing grade shows up.
  • The return False is outside the loop, after it: you only get there if the for finished without finding anything.

And here comes the classic mistake:

Putting the return False in an else inside the loop. Then the function decides with the first element and never looks at the rest.

def has_fail(grades):
    for grade in grades:
        if grade < 5:
            return True
        else:
            return False


print(has_fail([7.5, 4.0, 9.0]))
False

The list had a 4.0 in the second position and the function says False. No error, no warning: it simply looked at the 7.5, saw it was not a fail, and the else did return False on the first lap.

Returning the value or the position

If what interests you is not “yes or no”, but which element it is or where it is, change what you return. For the position there is a very useful function, enumerate(), which gives (index, element) pairs:

def first_fail(grades):
    for grade in grades:
        if grade < 5:
            return grade
    return None


def fail_position(grades):
    for pos, grade in enumerate(grades):
        if grade < 5:
            return pos
    return -1


grades = [7.5, 6.0, 4.0, 9.0, 3.5]
print(first_fail(grades))
print(fail_position(grades))
print(fail_position([7.5, 6.0]))
4.0
2
-1

I return -1 when there is none because it can never be a valid position. Avoid returning False for “not found”: in Python False == 0, and 0 is a valid position.

The in operator and ready-made functions

Python comes with the most common searches already written:

languages = ["C", "Java", "Python"]
print("Python" in languages)
print("Pascal" in languages)
print(languages.index("Java"))

text = "abracadabra"
print("cad" in text)
print(text.find("cad"))
print(text.find("xyz"))
True
False
1
True
4
-1
  • value in sequence answers True or False. With strings it also looks for pieces of text, not just single characters.
  • .index() gives you the position of the first occurrence; .find() (strings only) does the same but returns -1 if it is not there.

Inside, all those operations run a traversal like the one we just wrote, so they cost the same. And watch out: .index() complains if the value is not there:

Traceback (most recent call last):
  File "value_error.py", line 2, in <module>
    print(languages.index("Pascal"))
          ^^^^^^^^^^^^^^^^^^^^^^^^^
ValueError: 'Pascal' is not in list

Before using .index(), check with in that the value is in the list.

Maximum and minimum

Here you do have to go through everything, because you cannot know which is the biggest until you have seen them all. You start from a candidate (the first element) and, every time a better one shows up, you change the candidate:

def maximum(grades):
    candidate = grades[0]
    for grade in grades:
        if grade > candidate:
            candidate = grade
    return candidate


def minimum(grades):
    candidate = grades[0]
    for grade in grades:
        if grade < candidate:
            candidate = grade
    return candidate


grades = [7.5, 4.0, 9.0, 3.5, 6.0]
print(maximum(grades))
print(minimum(grades))
print(max(grades), min(grades))
9.0
3.5
9.0 3.5

For the minimum only the direction of the comparison changes. And max() and min() already exist, but it is worth knowing how to build them: they will help you when the criterion is not the direct value.

Initializing the candidate with an invented value, such as 0. If every grade is greater than that 0, the “minimum” will always be 0, even if nobody got it:

0

That is why you start with the first element: it is a value that really is in the sequence. That has a consequence: with an empty list there is no first element.

Traceback (most recent call last):
  File "empty_error.py", line 9, in <module>
    print(maximum([]))
          ^^^^^^^^^^^
  File "empty_error.py", line 2, in maximum
    candidate = grades[0]
                ~~~~~~^^^
IndexError: list index out of range

Accumulator and counter

The accumulator pattern builds a result from all the elements. You start with the neutral element of the operation (0 for adding, 1 for multiplying, "" for joining strings, [] for joining lists) and keep accumulating:

initialize the accumulator with the neutral element; for each item: accumulate item

If you add the question from the selective traversal, you get the selective accumulator. And if instead of adding the value of each element you always add 1, you get the counter:

def sum_all(grades):
    accumulator = 0
    for grade in grades:
        accumulator += grade
    return accumulator


def sum_passed(grades):
    accumulator = 0
    for grade in grades:
        if grade >= 5:
            accumulator += grade
    return accumulator


def count_passed(grades):
    counter = 0
    for grade in grades:
        if grade >= 5:
            counter += 1
    return counter


grades = [7.5, 4.0, 9.0, 3.5, 6.0, 8.5, 5.0]
print("Sum:", sum_all(grades))
print("Sum of passed:", sum_passed(grades))
print("Passed:", count_passed(grades))
print("Average:", round(sum_all(grades) / len(grades), 2))
print(sum(grades), len(grades), max(grades), min(grades))
Sum: 43.5
Sum of passed: 36.0
Passed: 5
Average: 6.21
43.5 7 9.0 3.5

The difference between an accumulator and a counter is just this: the accumulator adds something that depends on each element (grade), and the counter always adds the same thing (1). The last line shows Python’s shortcuts (sum, len, max, min), which apply these same patterns inside.

Watch it step by step in Python Tutor

Python Tutor runs your program line by line and draws the variables and objects in memory. Choose Python 3 and paste this program, which picks up the mystery from the beginning with just two grades so it is easy to see:

def raise_wrong(grade_list, points):
    for grade in grade_list:
        grade += points


def raise_right(grade_list, points):
    for pos in range(len(grade_list)):
        grade_list[pos] += points


grades = [6.5, 4.0]
raise_wrong(grades, 0.5)
print("By element:", grades)
raise_right(grades, 0.5)
print("By index:", grades)

Expected output:

By element: [6.5, 4.0]
By index: [7.0, 4.5]

What to watch, step by step:

  1. Before anything is called, the global variable grades points to a list box with two numbers (6.5 and 4.0), and each position of the list has its arrow to the number.
  2. When it enters raise_wrong, its frame appears with grade_list pointing to the same box as grades. On the first lap of the for, the variable grade points to 6.5.
  3. With grade += points, grade jumps to a new number (7.0). Look at the list: the arrows of its positions have not moved. That is why, when it exits, the list is still the same.
  4. In raise_right, the key is grade_list[pos] += points: here the arrow of the list position does jump to the new number. When it finishes, grades holds 7.0 and 4.5.
  5. Notice that the variables grades and grade_list share the same box: that is what lets a function modify the caller’s list. We will talk about that in the next article.

Summary

ConceptThe essentials
Sequencesstr, tuple and list: ordered elements, reachable by position
List / tuple[ ] can be modified; ( ) cannot. (10,) is the one-element tuple
IndexesFrom 0; negatives count from the end (-1 is the last)
Slices[start:end:step]: the start is included, the end is not; [::-1] reverses
+ and *Join sequences of the same type and repeat them
Unpackinga, b = tuple: as many variables as elements
for by elementFor reading. It does not modify the list
for by indexrange(len(s)) and s[pos]: to modify or to know the position
Selective traversalA traversal with an if before processing the element
SearchA traversal that stops with return when it finds; the return False goes outside the loop
Maximum / minimumInitial candidate = first element
Accumulator / counterStart from the neutral element; the counter always adds 1

Three ideas to take with you:

  1. Indexes start at 0 and the end of a slice is not included. With that and negatives, you reach any piece.
  2. If you want to change a list, go through the indexes. The variable of a for by element is independent of the list.
  3. All the patterns are the same traversal with small additions: an if (selective), a return (search), a candidate (maximum) or a variable that keeps accumulating (accumulator and counter).

In the lists and tuples practice you will build class statistics, a student finder and some moving averages. After that, the exercises will tell you whether it has stuck. And in the next topic we get into what makes lists special: mutability, copies, list comprehension and matrices.

Next step: practiceLists and tuples in Python — practice with class statistics, a student finder and moving averages (and the minimum grade that was a zero nobody had got)

Did this article help you? Here is how you can support SergioLearns →

Sigue aprendiendo

Leave a Reply

Your email address will not be published. Required fields are marked *