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.
Table of Contents
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:
| Type | Written with | Example | Can it be modified? |
|---|---|---|---|
str (string) | quotes | "PYTHON" | No (immutable) |
tuple | parentheses | ("Lucía", 2024) | No (immutable) |
list | square 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.
| Position | 0 | 1 | 2 | 3 | 4 |
|---|---|---|---|---|---|
| Element | 10 | 20 | 30 | 40 | 50 |
| 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 variablegradeis a copy of the reference to each number. When you dograde += points, Python creates a new number and makesgradepoint to it. The list never finds out. - In
for pos in range(len(grade_list))you go through the indexes, andgrade_list[pos] += pointsdoes 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 Trueis inside theif: it leaves the function as soon as the first failing grade shows up. - The
return Falseis outside the loop, after it: you only get there if theforfinished 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 sequenceanswersTrueorFalse. 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-1if 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:
- Before anything is called, the global variable
gradespoints to a list box with two numbers (6.5and4.0), and each position of the list has its arrow to the number. - When it enters
raise_wrong, its frame appears withgrade_listpointing to the same box asgrades. On the first lap of thefor, the variablegradepoints to6.5. - With
grade += points,gradejumps 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. - In
raise_right, the key isgrade_list[pos] += points: here the arrow of the list position does jump to the new number. When it finishes,gradesholds7.0and4.5. - Notice that the variables
gradesandgrade_listshare the same box: that is what lets a function modify the caller’s list. We will talk about that in the next article.
Summary
| Concept | The essentials |
|---|---|
| Sequences | str, tuple and list: ordered elements, reachable by position |
| List / tuple | [ ] can be modified; ( ) cannot. (10,) is the one-element tuple |
| Indexes | From 0; negatives count from the end (-1 is the last) |
| Slice | s[start:end:step]: the start is included, the end is not; [::-1] reverses |
+ and * | Join sequences of the same type and repeat them |
| Unpacking | a, b = tuple: as many variables as elements |
for by element | For reading. It does not modify the list |
for by index | range(len(s)) and s[pos]: to modify or to know the position |
| Selective traversal | A traversal with an if before processing the element |
| Search | A traversal that stops with return when it finds; the return False goes outside the loop |
| Maximum / minimum | Initial candidate = first element |
| Accumulator / counter | Start from the neutral element; the counter always adds 1 |
Three ideas to take with you:
- Indexes start at 0 and the end of a slice is not included. With that and negatives, you reach any piece.
- If you want to change a list, go through the indexes. The variable of a
forby element is independent of the list. - All the patterns are the same traversal with small additions: an
if(selective), areturn(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 →
