COMP 1510: Programming Methods
Welcome to COMP 1510. This course introduces programming through Python, with a focus on systematic problem solving and writing clear, working programs. You will build from the fundamentals toward more involved problems while learning how to organize code, work with data, test your solutions, and debug when things go wrong.
This is a living coursebook and our main learning resource for the course. It provides the structure for our lectures, examples, and review, and new chapters will be added as we progress through the term. Substantive additions or revisions will be announced through Learning Hub.
Python is the language we use to learn the larger ideas of programming and problem solving.
Getting Started
Course Logistics
Our course shell on Learning Hub is the source of truth for announcements, labs, assessments, due dates, and grades. This coursebook contains the instructional material we will use throughout the term, while the official course outline covers course outcomes, evaluation, and policies.
Toolchain
Everything you need is free:
- The Python language itself: Python 3.14.x, from python.org.
- PyCharm, an integrated development environment for Python. Its core features are free, and eligible students can obtain additional professional features through the JetBrains Student Pack.
- Git for version control, plus a free GitHub account for accessing and submitting course repositories.
Any modern operating system is fine: Windows, macOS, or Linux. Lab 1 walks through installing and configuring all of this, step by step.
Companion Reading
- Beej’s Guide to Python Programming by Brian “Beej Jorgensen” Hall: a free, friendly, informal guide that follows along with much of this course, with exercises and chapter projects along the way. It is a work in progress, available to read online or as a PDF.
Using This Coursebook
Formatting Convention
Bold introduces terms or concepts worth learning, italics add emphasis or contrast, and code formatting represents literal code, commands, programming names, or other computer text. A highlight marks a term collected in the index.
Using the Index
The chapters highlight the terms worth remembering, and the index at the back of the book, and of each week’s PDF, gathers them into one place. Each entry lists the sections where the term is introduced or discussed in depth. If the name you try is not listed, look for a related entry or simply use the search bar: type the word, and it takes you to the sections where it appears.
Credits
Special thanks to Christopher Thompson, who taught the course before the book existed, and whose support and materials shaped these pages. Thank you, Chris.
Acknowledgement
This coursebook was developed and edited by the instructor, with AI-assisted tools used during parts of the writing and revision process. All material was reviewed and verified by the instructor before publication. As the coursebook continues to evolve, minor typographical, formatting, link, or rendering issues may remain.
Image Credits
Image licences are listed in image-credits.md.
Chapters
Week 1
- A Brief History of Programming
- Programming Languages
- Getting Started with Python
- The Command Line
- Variables and Assignment
- Operators and Expressions
- Input and Types
- Text Editors, PyCharm, and Version Control
Week 2
Week 3
Week 4
Week 5
Week 6
Week 7
Week 8
1 A Brief History of Programming
The laptop in front of you is, at heart, billions of tiny switches, each either on or off. That sounds nothing like writing a Python program, and closing the gap between those two worlds took two ideas. The first is old: that a machine could follow a stored set of instructions without a person guiding its every move. The second is physical: that you could build such a machine out of something that simply switches on and off. This chapter tells both stories, and shows how they collided in the 1940s to produce the machine on your desk. Class time will only sample this story; here it is in full, so skim where it overlaps with the lecture and linger where your curiosity takes you.
1.1 The Idea of a Programmable Machine
Long before electronic computers existed, people had already discovered one of the central ideas of programming: a machine’s behaviour could be controlled by instructions encoded separately from the machine itself.
In 1804, the Jacquard loom used punched cards to control the pattern of a weave automatically. Change the cards, and the same machine produced a different pattern. The loom was not a computer, but the idea behind it was remarkably familiar: instead of rebuilding the machine for every new task, change the instructions it follows.
In the nineteenth century, Charles Babbage designed the Analytical Engine, a mechanical general-purpose computer that was never completed. Ada Lovelace translated an article about it and added her own notes. In Note G she described an algorithm for computing Bernoulli numbers, making it the first published algorithm intended for a machine. She also noticed that the engine could one day compose music if the rules of harmony could be expressed as operations.
| Charles Babbage (1791–1871), designer of the Analytical Engine | Ada Lovelace (1815–1852), wrote the first published algorithm for a machine |
QuoteWe may say most aptly, that the Analytical Engine weaves algebraical patterns just as the Jacquard-loom weaves flowers and leaves.
Ada Lovelace, Note A, 1843.
Lovelace saw what a machine that followed instructions could become, but no one yet had a fast, reliable way to build one. That problem starts somewhere completely different: electricity.
1.2 From Electricity to Switches
To do anything useful with electricity, we first need a way to control its flow through a wire. The switch was invented for exactly this purpose: to open and close a circuit, allowing current to pass or stopping it entirely. At first, controlling these switches was a manual task: a person flipped a lever, pressed a button, or turned a dial. Over time, we learned to control switches electronically, using one electrical signal to operate another. That step, letting electricity control itself, is the foundation of all computing.
1.3 Vacuum Tubes: The First Electronic Switches
The vacuum tube was the first practical electronic switch. Electromechanical relays, switches operated by an electrical signal, had come before it, but a vacuum tube could be turned on and off by another electrical signal with no moving parts at all. This opened the door to a wave of inventions that reshaped the twentieth century. With vacuum tubes we built radio, television, radar, sound recording and reproduction, large telephone networks, industrial process control, and, crucially for our story, the first analog and digital computers.
1.4 Analog vs. Digital
Early electronic devices worked with information in two different ways. Analog signals are translated into electrical impulses of varying amplitudes: that is, the strength of the signal carries the information, in a continuous range. A vinyl record is an analog medium: the shape of the groove varies continuously to represent the sound wave.
Digital information, by contrast, is represented using discrete values: there is no “in between”, and each piece of information is represented as one of a fixed set of values. Modern computers overwhelmingly use binary, representing those values with 0s and 1s. This distinction matters because digital representation is what makes modern computers possible. When your phone stores a photo or a song, there is no groove, no continuous physical trace. There is only a long pattern of 0s and 1s. Because the representation is discrete, copying that pattern reproduces the original exactly, without the gradual generation loss associated with analog media. Storage and communication systems can also use error-detection and correction techniques to protect that information, so the file can travel anywhere and be processed without degrading.
Modern digital computers overwhelmingly use the simplest possible code: two symbols. And that idea is much older than electronics.
1.5 Binary: The Language of Computers
Electronic digital computers use binary. Inside the hardware, a 1 and a 0 correspond to two distinguishable electrical states, such as two different voltage levels; the exact representation depends on the hardware. These 0s and 1s are known as bits, short for binary digits.
Binary itself is not a new idea. As far back as 1605, the English philosopher Francis Bacon devised a cipher in which each letter of the alphabet was represented by a sequence of five symbols drawn from a set of just two: the two typefaces of his biliteral cipher, conventionally written today as a and b, and mapped just as naturally to 0 and 1 (so aaaaa for one letter, aaaab for the next, and so on). Bacon was encoding text, not building a computer, but the underlying idea is exactly what makes digital computing work: any information can be represented using just two symbols.
Two breakthroughs are now in place: the idea of a machine that follows stored instructions, and a way to represent information with on-off switches. In the 1930s and 1940s, these two threads finally met.
1.6 A Universal Machine
In 1936, the British mathematician Alan Turing described a universal Turing machine: a theoretical device that reads symbols, follows a table of rules, and moves between states as it works. He proved that a single such machine could, in principle, compute anything that is computable. He was not building hardware; he was working out what “computation” itself means. Nearly a century after Lovelace’s notes, Turing had given the idea of a programmable machine a rigorous mathematical foundation.
A decade later, in 1945, the Hungarian-American mathematician John von Neumann helped articulate the stored-program computer architecture in the First Draft of a Report on the EDVAC. In a von Neumann machine, both the program (the instructions) and the data (the information the program works on) are stored in the same memory. This is such a fundamental idea that it can be hard to imagine computing any other way, but it was a genuine breakthrough at the time. Here the two threads of this chapter meet: the idea of a machine that follows instructions, built out of electronic switches, storing both instructions and data as 0s and 1s.
1.7 Managing Complexity
To see what the stored-program idea replaced, picture the earliest electronic computers: enormous machines, filling entire rooms and containing thousands of vacuum tubes. Configuring them to perform a task was a manual, painstaking process: technicians rewired the machine or set banks of switches by hand for each new computation. This approach did not scale.
In the stored-program computer, the CPU, or central processing unit, is the part that executes a list of instructions, each of which performs a small calculation. Instead of rewiring the machine, you change the list of instructions, the program, and let the CPU carry it out. The results of those calculations are stored in memory, a circuit that holds 0s and 1s in addressed locations, so that the CPU can read and write them as needed.
This separation is the heart of the von Neumann architecture, and it is how we manage complexity: we break a large problem down into a long list of small, simple steps, and let the machine carry them out one at a time. That is the same move you will make as a programmer. When a problem looks overwhelming, you will break it into steps small enough for the machine to execute, and solve them one at a time.
1.8 Miniaturization
Computers did not stand still. The vacuum tube gave way to the transistor, transistors were packed together into the integrated circuit, and integrated circuits shrank until an entire CPU fit on a single microprocessor. Each step made computers smaller, faster, and more reliable, and the laptop from this chapter’s opening, billions of switches on a few chips of silicon, is where that line of development leads.
1.9 Summary
Programmable machines separate instructions from the machinery that follows them. The Jacquard loom demonstrated this through punched cards; Babbage’s Analytical Engine and Lovelace’s notes explored more general possibilities. Turing later established a mathematical foundation for computation.
Electronic switches and binary representation made electronic computing practical. In a stored-program computer, instructions and data share memory, and the CPU executes the instructions. Vacuum tubes gave way to transistors, integrated circuits, and microprocessors, making computers smaller, faster, and more reliable.
1.10 References and Further Reading
- Lovelace’s Notes on the Analytical Engine (1843): the translation containing Note G, the first published algorithm for a machine
- Turing, On Computable Numbers (1936): the paper behind the universal machine
- Von Neumann architecture
- History of computing hardware
2 Programming Languages
A CPU (central processing unit) executes instructions for operations such as moving values, adding numbers, comparing values, and jumping to another instruction. But programmers want to express larger ideas, such as “calculate the average of these grades” or “print a message if the password is wrong.” Programming languages help bridge that gap.
Abstraction hides lower-level details so you can focus on the problem you want to solve.
2.1 Programming Language Levels
Programming languages express instructions at different levels of abstraction. We will start with machine language, move to assembly, and then see how high-level languages such as Python let programmers focus on the problem instead of processor details.
2.1.1 Machine Language: Programming in Binary
A CPU executes machine instructions encoded in binary: patterns of 0s and 1s. These instructions form its machine language. Each instruction tells the CPU to perform an operation, such as adding two values or moving a value into a register.
Different processor families can use different instruction sets and binary encodings. Machine code built for one instruction set generally cannot run directly on a processor with a different instruction set.
2.1.2 Assembly Language: A Human-Readable Step Up
Assembly language represents machine instructions using readable names. An assembler translates those instructions into machine code.
For example, these two forms represent the same instruction on an x86 processor:
10110000 01100001
MOV AL, 61h
Both tell the processor to put the value 61h (97 in decimal) into AL, a register. Registers are small, fast storage locations inside the CPU.
Assembly is easier to read than binary, but programmers still work closely with the processor’s instructions and storage. You will not write assembly in COMP 1510; this example shows what high-level languages let you leave behind.
2.1.3 High-Level Languages and Abstraction
Machine language and assembly are each a low-level language because they expose hardware details. A high-level language, such as Python, lets you express a solution without specifying individual processor instructions.
For example, calculating an average in Python can look like this:
average = sum(grades) / len(grades)
For a non-empty collection of grades, this adds the grades and divides by how many there are. You do not have to decide which CPU registers hold the values or which machine instructions perform the calculation.
The diagram compares how a programmer approaches an average calculation in Python and assembly. The assembly branch uses illustrative pseudocode and shows only the final division step, assuming the total and count have already been calculated.
Abstraction lets you express a calculation while leaving more of its implementation to language tools.
2.1.4 Compilers and Interpreters
A compiler translates source code into another form, often machine code or an intermediate form called bytecode.
An interpreter executes a program by carrying out the operations described in its source code or intermediate form. Compilation and interpretation can work together.
When you run a Python program, its source code is typically compiled into bytecode, which the Python interpreter executes. These steps happen automatically.
Python also provides an interactive interpreter called the REPL (Read-Eval-Print Loop). You enter some code, Python evaluates it, displays a result when appropriate, and waits for your next input. This makes it convenient to try small expressions and explore how the language works.
BackstoryKathleen Booth helped develop early assembly-language tools in the late 1940s. In 1952, Grace Hopper developed A-0, which combined stored routines into a program. This early use of the word compiler referred to collecting existing code, rather than translating a high-level language as compilers do today.
2.1.5 Different Languages for Different Problems
High-level languages serve different purposes. Fortran was developed for scientific calculations, SQL expresses queries over data, and Python supports a wide range of programming tasks.
These languages are not successive steps on a single ladder. They make different choices about how programmers express solutions. Python’s readability and interactive environment make it useful for learning the fundamentals in this course.
2.2 Programming Paradigms
Language levels describe how much hardware detail a programmer must manage. Programming paradigms describe how a programmer organizes a solution.
A paradigm is a way of organizing a program and expressing a solution.
| Paradigm | Main idea | Example |
|---|---|---|
| Procedural | Describe steps and group them into procedures or functions. | Read a temperature, calculate its Fahrenheit equivalent, and display it. |
| Object-oriented | Combine related data and behaviour in objects. | A Temperature object stores a value and provides a to_fahrenheit() method. |
| Functional | Build calculations from functions, emphasizing results without changes to outside state. | A conversion function returns a Fahrenheit value while leaving other data unchanged. |
| Logic | Express facts and rules, then ask questions about them. | Use prerequisite rules and a student’s completed courses to determine course eligibility. |
A problem can often be approached through more than one paradigm.
2.2.1 Procedural Programming
Procedural programming describes what the computer should do through explicit instructions, grouped into reusable procedures or functions.
A program might read a temperature, convert it from Celsius to Fahrenheit, and display the result. As programs grow, they can make decisions, repeat instructions, and call functions to perform particular tasks.
This is the style you will spend most of COMP 1510 practising.
2.2.2 Object-Oriented Programming
Object-oriented programming organizes code around an object, which combines data and behaviour.
In a game, a Player object might store its health and position and provide actions such as move() and attack(). This approach helps organize programs containing interacting things, each with its own data and actions. You will explore it in later programming courses.
2.2.3 Functional Programming
Functional programming emphasizes computing results through functions and limiting changes to state, the data a program holds at a particular time.
A temperature-conversion function, for example, can take a Celsius value and return a Fahrenheit value without changing anything outside the function. A pure function produces the same result for the same input and has no side effect, such as modifying outside data or printing a message. These properties make its behaviour easier to reason about.
Calling a function does not, by itself, make a program functional; procedural programs use functions too. The distinction is the emphasis on pure calculations and combining their results.
2.2.4 Logic Programming
Logic programming expresses facts and rules that a system uses to answer questions.
In Prolog, for example, you could state which courses Alex has passed and which prerequisites a course requires. The system can then use those facts and rules to determine whether Alex meets the prerequisites.
2.2.5 Multi-Paradigm Languages
Many languages support more than one paradigm. Python supports procedural and object-oriented programming, as well as functional techniques.
In COMP 1510, we begin procedurally: writing clear instructions and grouping related work into functions. Other approaches become useful as you encounter different kinds of problems.
2.3 Summary
Abstraction lets programmers focus on a problem while language tools handle lower-level details. Machine language contains the instructions a CPU executes; assembly gives those instructions readable names. High-level languages such as Python let us express calculations without specifying individual processor operations.
Compilers translate code; interpreters execute it. Python typically combines both approaches by compiling source code into bytecode and executing it through an interpreter.
Programming paradigms describe how we organize solutions. Procedural programming uses instructions grouped into functions; other approaches emphasize objects, pure calculations, or facts and rules. Python supports several paradigms, and COMP 1510 begins with procedural programming.
2.4 References and Further Reading
- Python glossary: bytecode: the intermediate form used when running Python programs.
- Using the Python interpreter: running Python, including interactive mode.
- Kathleen Booth: her contributions to early computing.
- Grace Hopper: her work on early programming systems.
- Programming paradigms: further examples of ways to organize programs.
- History of programming languages: an optional survey of how languages developed.
3 Getting Started with Python
In the previous two chapters we traced the history of computing hardware and surveyed the landscape of programming languages and paradigms. Now it is time to get our hands on a language. This chapter introduces Python, the language we will use throughout COMP 1510, and walks you through installing it, running it, and understanding its basic building blocks.
3.1 What Is Python?
Python is a high-level, general-purpose, multi-paradigm programming language designed for readability: Python code often reads almost like English, so the ideas stay front and centre while you learn. It is also genuinely practical, used across the industry in web development, data analysis, and automation, and it ships with a large standard library, meaning that many common tasks (working with files, fetching data from the web, doing math) can be done without installing anything extra.
For an introductory course, three reasons stand out:
- Readable syntax lets you focus on programming concepts rather than language mechanics.
- A large standard library means you can do a great deal without installing third-party packages.
- A huge community means tutorials, documentation, and help are easy to find.
3.2 Installing Python
Before you can write Python, you need to install it: download Python 3.14.x for your operating system from python.org and run the installer. Windows offers more than one installer, so follow the installation instructions for the one you choose; if it offers an “Add Python to PATH” checkbox, select it so your system can find Python from the command line.
3.3 The Python Interpreter
Once Python is installed, you can interact with it through the interpreter: the program that reads, evaluates, and executes your Python code.
3.3.1 Opening the Interpreter
-
Open a Terminal (on macOS or Linux) or PowerShell (on Windows).
-
Type the following to confirm that Python is installed and check its version:
python3 --versionYou should see a version number starting with
Python 3.14(for example,Python 3.14.7). On Windows, typepy --versioninstead; thepylauncher takes the place ofpython3on Windows, and that one substitution covers every command in this chapter. -
To start the interpreter, type:
python3(On Windows, use
py.)
3.3.2 The REPL
When the interpreter starts, you are in the REPL: the Read-Eval-Print Loop. The REPL does exactly what its name says:
- Read an instruction you type.
- Evaluate (execute) it.
- Print the result.
- Loop back and wait for the next instruction.
You will see the >>> prompt, which means the interpreter is waiting for you to type something. Try your very first Python instruction:
>>> print("Hello, COMP 1510!")
Hello, COMP 1510!
Congratulations: you have just run your first Python program.
3.3.3 Basic Output
The print function displays values on the screen. It can print the result of an expression, and it can print several values at once, separated by commas:
print("Hello, COMP 1510!")
print(2 + 3)
name = "Ada"
print("Hello,", name)
Running this program produces:
Hello, COMP 1510!
5
Hello, Ada
Notice that 2 + 3 was evaluated before being printed, and that the comma in print("Hello,", name) simply puts a space between the two values. You will use print constantly, both to make programs talk to their users and to see what your own code is doing while you develop it.
3.3.4 Evaluating Expressions
The REPL is not limited to running commands like print. It also evaluates expressions and displays their results automatically. Try a few simple calculations:
>>> 2 + 3
5
>>> 10 * 4
40
You type an expression, press Enter, and the REPL shows you the result. This makes the REPL an excellent tool for experimenting and learning: you can try things out one at a time and see immediately what happens.
3.3.5 Exiting the Interpreter
When you are done experimenting, you can exit the REPL in a few ways:
- Type
quit()and press Enter. - On Windows, press Ctrl-Z then Enter.
- On macOS or Linux, press Ctrl-D.
3.3.6 Source Files
The REPL is great for quick experiments, but real programs are usually too long to type interactively. Python programs can also be stored in a source file: a plain text file with a .py extension, such as hello.py. You write your code in the file, save it, and then run it as a program:
python3 hello.py
(On Windows, run py hello.py.) Save your hello.py into a folder you can find again, such as a comp1510 folder inside your Documents. Locating that folder and running the file from the command line is exactly the next chapter’s job.
3.4 Python Keywords
A keyword is a reserved word with a predefined meaning in the language. You cannot use keywords as variable names, because Python has already claimed them for specific purposes.
Here is the full table of Python keywords:
False |
await |
else |
import |
pass |
True |
break |
except |
in |
raise |
None |
class |
finally |
is |
return |
and |
continue |
for |
lambda |
try |
as |
def |
from |
nonlocal |
while |
assert |
del |
global |
not |
with |
async |
elif |
if |
or |
yield |
A few soft keywords, such as match, case, and type, are contextual: Python treats them as keywords only inside specific constructs, so they can still be used as names elsewhere.
Do not try to memorize all of these at once. You will learn them naturally as they become relevant: if, else, for, and while will show up almost immediately, while others like async and nonlocal you may not need for a while. For now, just know that these words are reserved, and if you ever get an error when trying to name a variable, check whether you accidentally used a keyword. The official list lives in the language reference.
3.5 The Zen of Python
Python has a set of design aphorisms known as the Zen of Python. They capture some of the values that have shaped Python and its programming culture.
You can display the full Zen at any time in the interpreter:
>>> import this
You do not need to memorize it. A few of its ideas are especially useful as you begin programming:
- Readability counts. Code is read by people as well as computers. Choose names and structures that make your intentions clear.
- Simple is better than complex. Prefer a straightforward solution when one is available.
- Explicit is better than implicit. Make important behaviour visible rather than relying on assumptions or hidden details.
- Beautiful is better than ugly. Well-organized code should be pleasant to read and work with.
- Now is better than never. You do not need to understand everything before you start writing programs. Experiment, inspect the result, and keep learning.
These principles will make more sense as you write larger programs. For now, keep one idea close at hand: readability counts.
3.6 Summary
Python is a high-level, general-purpose language designed for readability. Its standard library provides tools for many common tasks without requiring additional packages.
The REPL lets you enter code and inspect results immediately. A source file saves code in a .py file so you can run and revise a program. In both settings, print() displays messages and calculated results.
Python’s keywords have reserved roles in the language. Its design principles, collected in the Zen of Python, encourage clear, simple, readable code. You will become familiar with both through practice.
3.7 References and Further Reading
- PEP 20 — The Zen of Python
- Python 3.14 documentation
- Built-in functions
- A brief history of Python
- Beej’s Guide to Python Programming, chapter 3: installing Python and running your first commands
4 The Command Line
Chapter 3 left you with a hello.py saved in a folder such as Documents/comp1510. A saved file is only useful if you can get back to it: if you close the terminal and return tomorrow, how do you find that folder and run the program again? As your scripts pile up, you need a way to move around, name things clearly, and run them without retyping everything. That is what the command line gives you.
The command line is a text-based interface for interacting with an operating system and its tools. Where a graphical desktop lets you point and click, the command line asks you to type what you want done. Programmers use it to run programs, inspect files, create directories, use version control, manage packages, and automate repetitive tasks. You open it with Terminal on macOS or Linux, or PowerShell on Windows. This chapter’s examples use Terminal on macOS; readers on Windows can follow along in PowerShell, where the everyday commands in the table below use the same spellings.
This chapter introduces the ideas you need to navigate the command line confidently: paths, the difference between absolute and relative locations, and the essential commands you will use every day.
4.1 Paths
When you open the command line, you usually start at your home directory, a personal folder reserved for your user account. From there, every file on the computer can be reached by following a path: a list of nested folders, read from left to right, that leads to the file or folder you want.
The details depend on your operating system. On macOS and Linux, the root directory is written /. Home-directory paths commonly look like:
macOS:
/Users/moose
Linux:
/home/moose
On Windows, paths usually begin with a drive root such as C:\, and the same home path looks like:
C:\Users\moose
The delimiting character differs too: Windows accepts both \ and /, while macOS and Linux use / exclusively. The differences that matter are called out as they come up.
4.2 Absolute vs Relative Paths
Paths come in two flavours, and understanding the distinction will save you a great deal of confusion.
An absolute path points to the same location no matter where you happen to be working. It always begins from the root directory, so there is no ambiguity. For example:
cd /Users/moose/exams
This command takes you to exams regardless of your current directory, because the path starts at /.
A relative path, by contrast, starts from your current working directory. If you are already inside /Users/moose, you can reach the same exams folder with:
cd exams
Relative paths are shorter and convenient, but they only make sense relative to where you are. From /Users, cd exams would look for /Users/exams, not /Users/moose/exams. A relative path is interpreted from your current directory. Absolute paths are longer but unambiguous: cd /Users/moose/exams reaches the very same exams folder from anywhere. As you work, you will learn to choose whichever fits the moment.
4.3 Essential Commands
Most day-to-day command-line work comes down to a small set of commands, and the same spellings work in Terminal and in PowerShell:
| Action | Command | Example |
|---|---|---|
| Print the current directory | pwd |
pwd |
| List directory contents | ls |
ls |
| Enter a subdirectory | cd alpha |
cd comp1510 |
| Move up one directory | cd .. |
cd .. |
| Make a new directory | mkdir name |
mkdir comp1510 |
| Move or rename a file | mv old new |
mv hello.py greetings.py |
| Delete a file | rm file |
rm hello.py |
| Clear the terminal screen | clear |
clear |
A few of these are worth a second look. The single dot . always refers to the current directory, while the double dot .. refers to its parent. These two shorthand symbols are the foundation of relative paths and appear constantly in real work: for example, cd .. to back out of a folder, or cd ../.. to climb two levels at once.
You will also meet one more shorthand, the tilde ~. It is shorthand for your home directory, the folder your account starts in, so ~/Documents means the Documents folder inside your home directory.
One command deserves a warning before you use it: rm deletes a file immediately. Unlike dragging a file to the Recycle Bin or Trash, deleting from the command line generally cannot be undone, so check the filename before pressing Enter.
You do not need to memorize every command at once. Pick a handful (pwd, ls, cd, and mkdir are a fine starting set) and practise them until they become muscle memory. The rest will follow naturally as your projects grow more ambitious.
Command Prompt, the older Windows shell, still appears on some machines. If one greets you, the survival kit is dir to list files, del to delete, rename to rename, and cls to clear the screen; typing bare cd prints the current directory. Typing powershell there launches PowerShell, the shell Windows readers can follow along in.
4.4 From Folder to Program
Here is the whole round trip, from a freshly opened terminal to a running program. On macOS or Linux:
cd ~/Documents/comp1510
ls
python3 hello.py
On Windows (PowerShell):
cd ~\Documents\comp1510
ls
py hello.py
The cd moves you into the project folder, ls confirms that hello.py is there, and the last line runs it. The path spelling (/ versus \) and the Python launcher (python3 versus py) are the only differences.
Use the location where you actually saved your file; the paths here are examples. If a folder name contains spaces, enclose the path in quotes, such as cd "COMP 1510".
Three habits make all of this faster:
- Tab completion: type the first few letters of a file or folder name and press Tab, and the terminal completes it.
cd Docfollowed by Tab becomescd Documents. - Command history: press the up arrow to bring back a previous command, edit it if you need to, and run it again.
- Drag and drop: dragging a folder from Finder or File Explorer into the terminal window inserts its path right at the prompt, which saves typing long paths by hand.
4.5 Summary
The command line lets you interact with your computer by typing commands. Your current working directory is the starting point for relative paths. Absolute paths identify locations independently of it, while ., .., and ~ refer to the current directory, its parent, and your home directory.
The essential navigation commands are pwd to check your location, ls to list its contents, and cd to move elsewhere. Use mkdir to create a directory, and take care with rm: deleted files generally do not go to the Trash or Recycle Bin.
To run a Python source file, navigate to its folder and enter python3 hello.py, or py hello.py on Windows. Tab completion and command history reduce repeated typing.
The next chapter introduces variables, which let a program name values and reuse them as it runs.
4.6 References and Further Reading
5 Variables and Assignment
You can now navigate the file system and run a Python program. As programs grow, they need to reuse values and carry results from one step to the next. Variables give those values names so we can refer to them throughout a calculation. This chapter introduces variables and explains how assignment binds names to values while a program runs.
5.1 Statements
The text you write to create a program is called code. A single row of text in that code is called a line of code.
A statement is a complete instruction that Python executes. It tells Python to do something: assign a value, display output, import a module, and so on. An expression is code that produces a value; 2 + 2 is an expression that evaluates to 4. The two are related, not opposed: statements often contain expressions. In area = pi * radius ** 2, the whole line is an assignment statement, while pi * radius ** 2 is the expression whose value it stores. A Python program (sometimes called a script) is a sequence of statements, evaluated and executed one at a time, in order, by the Python interpreter.
Consider the following statements:
print('Able was I ere I saw Elba')
print('Out darn spot, out!')
print('Buckle up, buckaroos!')
print(4 + 8)
print(3 * 2.5 ** 2 + 2.5 * 2 + 4)
When the interpreter runs this script, it reads the first print statement, carries it out, moves to the second, carries it out, and continues down the list. Each statement is a complete instruction. Some print text, some print the result of an arithmetic expression, but all of them are executed in the order they appear. This top-to-bottom flow is the simplest model of how a program runs, and it is the foundation everything else is built upon.
5.2 Variables
Printing values is useful, but a program that prints only literal values, written directly into the code, is limited. What we really want is a way to remember values so we can use them again later, perhaps in many places. This is what a variable provides: a name bound to a value.
Here is a simple example:
pi = 3.1415926535
colour = "red"
The first line binds the name pi to the value 3.1415926535. The second binds the name colour to the value "red", the string of three characters r, e, and d. Once a name is bound, we can use it anywhere in the program, and Python works with the value it refers to. It helps to picture a variable as a label attached to a value rather than a box containing it.
The two assignments from the example above, drawn as bindings rather than boxes:
Physically, those values live in the computer’s memory, the RAM (random access memory), along with everything else the machine is working on right now. Python chooses exactly where each value sits and keeps track of it for you: when you use the name pi, Python follows the binding to the value’s place in memory and fetches it. You never see or type a memory address. The name is the only handle you need.
5.3 Assignment
A variable is just a name. On its own, a name is not very useful: we must assign a value to it. An assignment statement associates the value on the right of the equals sign (=) with the variable on the left.
There are two rules to keep in mind:
- In the simple assignments we use for now, the left side is a variable name.
- The right side can be a value or an expression.
When Python encounters an assignment, it first evaluates the right side completely, then binds the resulting value to the name on the left. This is why an expression like area = pi * (radius ** 2) works: Python computes the value of the expression using whatever pi and radius currently refer to, and only then stores the result under the name area. The expression is the circle-area formula you know from geometry, , translated into Python: each quantity gets a descriptive name, multiplication becomes *, and the superscript becomes ** 2.
5.4 Variable Names (Identifiers)
Every variable needs a meaningful name, and that name is called its identifier. For COMP 1510, use the following rules when choosing one:
- Identifiers can be composed of letters (
a–z,A–Z), underscores (_), and digits (0–9). - An identifier may not start with a digit.
player1is fine;1playeris not. - Python is case sensitive, so
Pythonandpythonare two completely different names.
(Python itself permits a broader range of Unicode letters; the rules above are the ones we follow in this course.)
These rules define what is legal, but they do not define what is good. That is a matter of convention. Python’s official style guide, PEP 8, codifies it in detail, and that brings us to naming practices.
5.5 Naming Conventions
Identifiers must describe their purpose. A name is documentation: it tells the next person reading your code, perhaps you, three weeks from now, what a variable represents. Compare the following two versions of the same calculation:
| Vague | Descriptive |
|---|---|
a = 3.14159 |
pi = 3.14159 |
b = 20.18 |
radius = 20.18 |
c = a * (b ** 2) |
area = pi * (radius ** 2) |
Both versions produce the same result, but only the second one is readable. The vague names cost you every time the code is read: weeks later, a could be anything and must be decoded anew; nothing says whether 20.18 is a radius, an angle, or an age; and the formula itself is unrecognizable, so you cannot check it against the problem it is supposed to solve.
A few practical guidelines follow from this principle:
- Avoid single-character variable identifiers. They rarely communicate purpose, and a reader has nothing to go on when the code is revisited weeks later.
- Use abbreviations only when they are widely understood. Names like
apporfaqare common enough to be clear; abbreviations likeusrfor “user” ortmpfor “temporary” save only a few keystrokes while costing readability. - Be specific. If a variable in a game stores a non-player character’s age,
age_in_yearsis better thana, and better even thanage, because it removes all ambiguity about the unit.
5.6 Naming Cases in Python
Identifiers can be written in several typographic styles, and the programming community has names for the most common ones:
- snake_case:
in_python_we_do_this, Python’s convention for variables and functions. (Python, the snake, uses snake case: folklore, but a memorable one.) - UPPER_CASE:
THIS_IS_UPPER_CASE, Python’s convention for constants: names for fixed values, such asTAX_RATE, that you do not intend to rebind. - CapWords (
ThisIsCapWords, also called CamelCase): Python’s convention for class names, which you will meet in a later course. Classes are not something you write yet, but you will recognize the style the moment you see it.
For the variables you write now, snake_case is the expected choice. Following it makes your code feel native to the language and easier for other Python programmers to read.
5.7 Summary
A statement is an instruction Python executes; an expression evaluates to a value. Statements often contain expressions, as in area = pi * radius ** 2.
A variable is a name bound to a value. Assignment evaluates the entire right-hand expression before binding its result to the name on the left. Assigning again can make that name refer to a different value.
Good names explain what values represent. Follow the course’s identifier rules, choose descriptive names, and use snake_case for variables. UPPER_CASE indicates constants by convention, while CapWords is used for class names.
The next chapter puts those named values to work through arithmetic, comparisons, and other expressions.
5.8 References and Further Reading
- PEP 8, the official style guide, naming conventions section
- Assignment statements, language reference
- Snake case
- Beej’s Guide to Python Programming, chapter 5: data, variables, and assignment
6 Operators and Expressions
The previous chapter gave you a way to name values so a program can remember them. But a name on its own doesn’t do much. The real work starts when you combine those values: add them, compare them, test conditions, and build up results. That is what operators do. This chapter looks at the families of operators Python provides, sees how they combine into expressions, and finishes with the rules that decide which operator goes first when several appear together.
6.1 Operators and Operands
The phrase to remember is simple: operators operate on operands. Consider the familiar plus sign. In an expression like 5 + 5, each 5 is an operand and + is the operator. Because it sits between two operands, + is called a binary infix operator: “binary” because it takes two operands, and “infix” because it appears in between them.
The operands in 5 + 5 are literals: raw values written directly in the code. Python has several kinds of literals:
- Numeric literals, such as
5or3.14 - String literals, such as
"Hello" - Boolean literals, namely
TrueandFalse
Literals are the raw material of expressions; operators are the tools that shape them.
6.2 Arithmetic Operators
Arithmetic operators do what you expect from mathematics, with a couple of additions that reflect how computers work with numbers.
| Operator | Name | Example |
|---|---|---|
+ |
Addition | 1 + 2.0 |
- |
Subtraction | 3.14 - 1.4142 |
* |
Multiplication | 5 * 5 |
// |
Floor Division | 4 // 2 |
/ |
Division | 6.0 / 4 |
% |
Remainder/Modulo | 10 % 4 |
** |
Exponent/Power | 4 ** 4 |
Two of these deserve special attention. The / operator always produces a floating-point result, even when the division is exact: 6 / 3 yields 2.0. The // operator, by contrast, performs floor division (older books sometimes call it integer division): it rounds the quotient down to a whole number, in the sense of the floor function , the greatest whole number at or below :
So 7 // 2 is 3, while -7 // 2 is -4, not -3: “down” means down the number line, past zero when the quotient is negative.
One caution: floor describes the value, not the type of the result. Floor division of two integers gives an integer: 7 // 2 is the int 3. But if either operand is a float, the result is a float: 7.0 // 2 is 3.0, rounded down just the same. The rounding is the operator’s job; the type follows the operands.
The % operator returns the remainder after floor division. Quotient and remainder always reassemble into the number you started with; dividing 10 by 4 gives the quotient 2 and the remainder 2:
In Python, 10 // 4 computes the quotient and 10 % 4 the remainder. For a positive divisor , the remainder always satisfies : divided by 2, a whole number leaves a remainder of 0 or 1 and nothing else, which is why n % 2 == 0 tests whether a number is even. The remainder is just as useful for cycling through a fixed set of values.
6.3 Comparison Operators
Comparison operators examine two values and produce a Boolean result: True or False.
| Operator | Meaning | Example |
|---|---|---|
< |
Strictly less than | 5 < 4 |
> |
Strictly greater than | "z" > "Z" |
<= |
Less than or equal to | 5 <= 7 |
>= |
Greater than or equal to | "better" >= "bad" |
== |
Equal to | 2 == 2.0 |
!= |
Not equal to | 3.14 != 3 |
Notice that comparison is not limited to numbers. Strings can be compared too, and Python orders them lexicographically, roughly alphabetically, using the numeric codes of each character. That is why "z" > "Z" is True: lowercase letters sort after uppercase ones in the character encoding. Also note the equality operator ==, which tests for equal value, is distinct from the assignment operator =. Confusing the two is one of the most common early mistakes, so keep them straight: = assigns, == compares.
The distinction is quickest to see in the REPL, where the assignment produces nothing to print while the comparison answers a question:
>>> age = 19
>>> age
19
>>> age == 19
True
The first line binds the name age to the value 19, silently. The last line stores nothing: it asks whether the two values are equal and evaluates to a Boolean, True here, and False whenever the sides differ.
6.4 Boolean Operators
Preview: You will see these operators in simple conditions soon; we will study Boolean logic in more depth in Week 5.
Boolean operators (some books call them logical operators) combine Boolean values, letting you build richer conditions.
| Operator | Name | Example |
|---|---|---|
and |
Boolean and | 4 < 5 and 9 < 10 |
or |
Boolean or | 6 < 5 or 9 < 10 |
not |
Boolean not | not True |
and yields True only when both operands are true. or yields True when at least one operand is true. not flips a Boolean value: not True is False, and not False is True. These descriptions assume Boolean operands, the True and False values that comparisons produce; handed values of other types, and and or behave a little differently, a detail we will meet much later. For now, the three operators are the glue that holds compound conditions together, letting you combine simple comparisons into richer ones.
6.5 Expressions
Operators and operands together form expressions. A value all by itself is the simplest possible expression: the literal 64738 evaluates to 64738. More generally, in mathematics and computing, an expression is a sequence of operators and their operands that specifies a computation.
Evaluating an expression produces a result (also called a value). The expression 2 + 2 generates the result 4. One built-in deserves a short note: the expression print("Hello") displays text, and the display is the whole point, but its own result is a special value, None, meaning “no useful value”. You will meet None again later in the course.
6.6 Operator Precedence
When an expression contains several operators, Python does not simply evaluate them left to right. Instead, it follows precedence rules that determine the order of evaluation. From highest to lowest, the order is:
( ): parentheses are always evaluated first- Arithmetic operators (
**,*,//,/,%,+,-): remember BEDMAS (Brackets, Exponents, Division and Multiplication, Addition and Subtraction) - Comparison operators (
<,<=,>,>=,==,!=) not: Boolean NOTand: Boolean ANDor: Boolean OR
Consider z - 45 * y < 53. Python evaluates the multiplication 45 * y first, then the subtraction z - ..., and finally the comparison ... < 53. The result is not the same as reading left to right, so precedence matters.
When several operators share a tier, Python evaluates them from left to right, so 12 / 3 * 4 divides first and then multiplies, giving 16.0. Exponentiation is the exception: it groups from right to left, so 2 ** 3 ** 2 means 2 ** (3 ** 2).
Exponentiation carries one wrinkle worth knowing: -2 ** 2 is -4, not 4, because ** binds more tightly than the unary minus. In mathematical notation, Python reads the expression as , not ; the two differ only in where the minus sign sits. If you mean the square of negative two, write (-2) ** 2.
To see the whole order at once, draw the earlier example, z - 45 * y < 53, as a tree:
The tree makes the order visible: 45 * y is evaluated first, the subtraction second, and the comparison last.
Precedence can also surprise you with Boolean operators. The expression x == 5 or y == 10 and z != 10 evaluates as (x == 5) or ((y == 10) and (z != 10)), because and binds more tightly than or. If that is what you intended, fine, but it is easy to misread.
The remedy is simple: use parentheses to make your intended order explicit. Writing (x == 5) or (y == 10 and z != 10) removes all doubt, both for Python and for the next person who reads your code. As the Python community likes to say, “Readability counts.” When precedence is unclear, parentheses are not redundant: they are a courtesy to your reader, and often to your future self.
6.7 Compound Operators
One assignment comes up so often that Python gives it its own shorthand. Suppose a variable already holds a value, and you want to increase it by one:
age_years = 19
age_years = age_years + 1 # age_years now refers to 20
The right side is evaluated first, using the value age_years currently refers to, and the name is then bound to the result. Because this read-then-update pattern is so common, Python provides a compound operator for each arithmetic operator, combining the operation with assignment:
age_years = 19
age_years += 1 # 20: age_years = age_years + 1
account_balance = 100.00
account_balance -= 50.00 # 50.0: subtraction
number_of_calls = 4
number_of_calls *= 2 # 8: multiplication
average = 7.5
average /= 2 # 3.75: division
slices = 7
slices //= 2 # 3: floor division
remainder = 10
remainder %= 4 # 2: the remainder of 10 by 4
For the simple numerical examples above, each compound operator behaves exactly like its longhand form: age_years += 1 does the same thing as age_years = age_years + 1, just shorter. After each line above, the name refers to the freshly computed value. Whenever you find yourself writing x = x <op> y, there is a compound operator that lets you write x <op>= y instead.
6.8 Summary
Operators act on operands to produce results. Arithmetic operators perform calculations, comparisons produce Boolean values, and Boolean operators let us combine or negate conditions. An expression evaluates to a value, even when its main job is something else: print("Hello") displays text and evaluates to None.
Division needs particular care. For the integer and float operands used here, / produces a float, while // rounds the quotient down, including for negative numbers. Floor division produces an integer when both operands are integers and a float when either is a float. The % operator gives the remainder.
Precedence determines how an expression is grouped. Multiplication takes precedence over addition, comparisons follow arithmetic, and the Boolean operators follow in the order not, and, or. Parentheses make the intended grouping clear. Compound operators such as += combine an operation with assignment, making common numerical updates shorter to write.
So far, we have supplied values directly in our code. The next chapter introduces programs that ask users for information and convert their responses into values we can calculate with.
6.9 References and Further Reading
- Operator precedence, the full table
- Modulo operation
- Beej’s Guide to Python Programming, appendix A: order of operations, floor division, and modulo
7 Input and Types
So far, every value in our programs has been written directly into the code. Real programs also need to work with information provided by users. This chapter introduces input() for reading what a user types, then looks at the different types of values Python can work with.
7.1 Basic Input
The input() function pauses the program and waits for the user to type something and press Enter. When it finishes waiting, the function returns the typed text: it hands the value back to your program, where it can be stored in a variable.
input() can also display a prompt: a short message that tells the user what to enter.
user_input = input()
user_age_years = input("Enter your age in years: ")
In the first line, the program waits silently for input. In the second, it displays Enter your age in years: before waiting.
Whatever the user types is accepted as input. The important detail is what Python does with that input, which we look at next.
7.2 input() Always Returns a String
The most important thing to remember about input() is simple: it always returns a string, even when the user types something that looks like a number.
If the user types 42, Python receives the string "42", not the integer 42. The string contains two characters: '4' and '2'.
For example:
age = input("Enter your age in years: ")
If the user enters 42, the variable age refers to the string "42".
This matters when we want to perform calculations. Python treats text and numbers differently, so we first need to understand the different types of values it can work with.
7.3 Data Types
Every value in Python has a type. A value’s type determines what kind of information it represents and which operations make sense for it.
You will work with four basic types from the beginning of this course:
| Type | Description | Examples |
|---|---|---|
int |
Integers (whole numbers) | -4, 17, 2022 |
float |
Floating-point numbers | 1.0, 3.14, -19.73 |
str |
Strings (text) | "hello", 'blue', "42" |
bool |
Boolean values (True or False) |
True, False |
An int represents a whole number. A float represents a floating-point number and can represent fractional values, although many decimal fractions cannot be represented exactly. A str represents text as a sequence of characters. A bool represents one of two truth values: True or False.
Python provides the built-in type() function when you want to check the type of a value:
print(type(42))
print(type(3.14))
print(type("hello"))
print(type(True))
The program prints:
<class 'int'>
<class 'float'>
<class 'str'>
<class 'bool'>
We can now use type() to confirm what happens with user input:
age = input("Enter your age in years: ")
print(type(age))
If the user enters 42, Python prints:
<class 'str'>
The characters look like a number to us. Python still sees the value returned by input() as a string.
7.4 Data Conversion
If we want to use numerical input in a calculation, we need to convert it from a string to a numeric type. This is called type conversion.
Python provides several built-in functions for conversion:
| Function | Creates | Examples |
|---|---|---|
int() |
Integers | int("42"), int(3.14) |
float() |
Floats | float("3.14"), float(42) |
str() |
Strings | str(42), str(3.14) |
The value you pass to a function is called an argument. Each of these functions takes one argument and produces a value of the requested type.
int("42") # 42
int(3.14) # 3
float("3.14") # 3.14
str(42) # "42"
A float argument to int() simply loses its fractional part. A string argument has to make sense as the requested type. For example:
int("42") # works
float("3.14") # works
int("3.14") # ValueError
int("hello") # ValueError
int("3.14") fails because the string does not contain an integer. int("hello") fails because the string does not represent a number at all.
These invalid string conversions raise a ValueError: Python accepts a string as an argument to int(), but the string must represent an integer. We will study errors and exceptions in more detail later in the course.
7.5 Implicit Conversion
Sometimes Python performs a conversion automatically.
Consider an expression that combines an int and a float:
result = 5 + 2.5
print(result)
Python produces:
7.5
For this operation, Python treats the integer 5 as the floating-point value 5.0, then performs the addition. The result is therefore a float.
This kind of automatic conversion is called implicit conversion, or coercion.
In mixed int and float arithmetic, Python converts the integer operand to a float for the operation. Converting the float to an integer could discard its fractional part.
7.6 Putting It Together
We can now combine input and conversion in a small program:
price = input("Price: ")
quantity = input("Quantity: ")
total = float(price) * int(quantity)
print(total)
Suppose the user enters:
Price: 19.99
Quantity: 3
input() gives us two strings:
"19.99"
"3"
The program then converts "19.99" to the float 19.99 and "3" to the integer 3. Once both values are numeric, Python can multiply them and produce:
59.97
If we tried this instead:
total = price * quantity
Python would raise a TypeError because both values are strings and string-by-string multiplication is not defined.
The pattern is worth remembering:
- Read input as a string.
- Convert it to the type you need.
- Compute with the converted value.
You will use this pattern throughout the course.
7.7 Built-ins So Far
A built-in function is a function Python makes available automatically. No import is required.
You have now met these built-in functions:
| Function | Purpose | Example |
|---|---|---|
print() |
Display values on the screen | print("Hello") |
input() |
Read a line typed by the user | input("Age: ") |
type() |
Report the type of a value | type(3.14) |
int() |
Convert a value to an integer | int("42") |
float() |
Convert a value to a float | float("3.14") |
str() |
Convert a value to a string | str(42) |
Python provides many more built-in functions. You can find the complete list in the official built-in functions reference.
7.8 Summary
input() returns a string, even when the user enters a number. It can display a prompt before waiting for the user to type and press Enter.
Every value has a type that determines which operations it supports. The four types introduced here are int, float, str, and bool; type() reports a value’s type.
Type conversion lets us turn input into values suitable for calculations. Use int() for integers, float() for floating-point numbers, and str() for text. Invalid string conversions, such as int("hello"), raise a ValueError. Python also performs some conversions automatically during mixed numeric arithmetic.
The practical pattern is read → convert → compute: collect the input, convert it to the type you need, and use the result.
The next chapter introduces the tools you will use to write, run, and share those programs: an editor, an IDE, and version control.
7.9 References and Further Reading
- Built-in functions: includes
input(),type(),int(),float(), andstr() - Type system
- Floating-point arithmetic
- Beej’s Guide to Python Programming, chapter 5: user input, data types, and converting between them
8 Text Editors, PyCharm, and Version Control
Our first chapters were about ideas: the history of computing, the levels of programming languages, and the core building blocks of Python itself. This chapter is about the workshop. Writing programs for real means writing, running, fixing, and sharing files, and for that you need tools. In COMP 1510 we use three: a code editor, an integrated development environment called PyCharm, and the version control system git together with the GitHub service. This chapter explains what each tool is for and why it matters.
8.1 From Text Editor to IDE
A Python source file is nothing more than plain text, so in principle any text editor can write one. In practice, the choice of editor makes an enormous difference:
- A plain editor (like Notepad) shows raw text and offers no help at all.
- A programmer’s editor adds conveniences aimed at code: syntax highlighting colours keywords, strings, and numbers so mistakes stand out, and automatic indentation keeps your program’s structure visible.
- An integrated development environment (IDE) goes further still: it combines an editor with tools to run and debug programs, manage projects, and work with version control, all in one application.
We use PyCharm, an IDE built specifically for Python. Among other things, it will:
- Highlight syntax and flag likely errors as you type, before you ever run the program.
- Run your programs with a single click.
- Include a built-in terminal, so the command-line skills from an earlier chapter are always one keystroke away.
- Integrate with git.
8.2 Projects
PyCharm organizes your work into a project: a folder on disk that holds your code plus PyCharm’s own settings, including which Python interpreter to use. Creating a project and choosing its Python interpreter takes only a few minutes. After that, opening PyCharm and opening your project is all it takes to pick up where you left off.
When you create a new project, PyCharm normally creates a virtual environment, usually stored in a folder named .venv inside the project folder. A virtual environment is an isolated Python environment for that project, so each project can manage its own packages without interfering with other projects or your system. We will only take a brief look at it this week; it will become more important later in the course, when we begin installing packages.
8.3 Version Control with git
Sooner or later, every programmer loses a working version of a program to a careless edit. Saving copies by hand (assignment01.py, assignment01_v2.py, assignment01_final.py, assignment01_final_FINAL.py) only postpones the problem.
git solves it. A git repository records a series of snapshots of your project, each called a commit. A commit stores a snapshot of the exact state of the files git is tracking, along with a short message you write describing what changed and why. A commit does not automatically sweep in every file in the folder: git records only the files you have told it to track, and staging chooses which of their current edits belong in the snapshot. Work that has been committed can normally be recovered from the repository’s history, even if the current files get deleted or damaged. At any moment you can look back through the history, compare versions, or return to any earlier snapshot.
The workflow is a small loop you will repeat thousands of times:
- Edit your files.
- Stage your changes: tell git which edits belong in the next snapshot.
- Commit: take a snapshot with a message, such as “add input validation”.
- Keep working.
Commit early and commit often. Small commits are easy to understand and easy to roll back; big ones are neither. This is “now is better than never” applied to your work history.
Drawn as a loop, the workflow looks like this:
The highlighted step is the one that matters: the commit is the snapshot that makes your work recoverable. Push and pull just move commits between your machine and GitHub (pull also integrates what it fetches into your copy), which the next section explains. PyCharm runs most of this loop for you: it stages your changed files, commits them with your message, and pushes, all from a few clicks.
8.4 GitHub
git works entirely on your own machine, which raises an obvious question: what happens if your laptop dies the night before a deadline? GitHub is the answer. It is a website that hosts git repositories in the cloud, giving you backup, sharing, and collaboration.
Here is the three-way distinction, stated plainly. A commit records a snapshot in your repository’s history, on your machine. A push transfers your new commits from your machine to a remote copy. GitHub is the hosting service where that remote copy lives: git is the tool, GitHub is a place to store repositories that use it.
The daily loop gains two steps:
- Push: send your new commits from your machine to GitHub.
- Pull: fetch any new commits from GitHub and integrate them into your local copy. Pulling is really two steps in one: it fetches the new commits, then combines them with your own work so your copy is up to date.
8.5 Summary
PyCharm brings programming tools into one workspace. Its editor helps you read and write code, while its other tools let you run programs, investigate errors, and manage projects. Each project uses a selected Python interpreter, often through a virtual environment that keeps its packages separate from other projects.
git records your project’s history. Staging selects the changes for the next commit; committing records them with a message. Small, regular commits make it easier to understand how your program developed and return to earlier work.
GitHub hosts a remote copy of your repository. Pushing sends your commits to GitHub, while pulling fetches remote commits and integrates them into your local copy.
8.6 References and Further Reading
- Pro Git, the definitive book on git, free to read online
- Official git documentation
- GitHub Docs
- PyCharm documentation
9 Computational Thinking and Program Design
In Week 1, you learned the basic pieces of a Python program: expressions, names, input() and print(), and type conversions such as int() and float(). You also learned how to write and run a program.
Most of those programs followed a simple path from top to bottom: read some input, perform a calculation, and display a result. The steps were either given to you or simple enough that the design was almost invisible.
Real programming problems rarely arrive that way. Before a request can become Python, someone has to decide what information matters, what steps are needed, and what should happen in different situations. That is the focus of Week 2.
In this chapter, we will make a problem precise, design a solution, and represent that solution with an algorithm and a flowchart. We will turn the design into Python in the next chapter.
9.1 From Writing Programs to Designing Them
Before writing a program, we need to understand the problem and work out how to solve it. Choosing the steps, their order, and the decisions they involve is design. Translating that solution into Python is implementation.
For a very small program, the two can feel like the same activity. As programs become larger, separating them becomes increasingly useful. A few minutes spent reasoning about a solution before coding can prevent much more time being spent debugging a program that implements the wrong idea.
Design and implementation are parts of a larger programming lifecycle. We design a solution, implement it, run and test the program, and revise our work when the result differs from what we expected:
Testing gives us evidence about both the program and its design. When the program behaves as expected, it has passed that particular check. When it does not, we investigate why. The code may not follow the design correctly, or the design may not solve the original problem as intended.
The programming lifecycle is therefore flexible rather than strictly linear. We can check a design before implementing it, revise code after testing it, or return to the design when implementation exposes a missing detail. Each stage helps us understand the problem and improve the solution.
As programs grow, their instructions need a clear organization. A long, tangled sequence of instructions is difficult to understand, test, and change. Structured programming organizes control flow using three main structures: sequence, selection, and repetition.
Indirection provides another form of organization. It allows one part of a solution to refer to another operation by name without showing all of its steps at that point. You have used it already: when you write input(), the steps that actually read the keyboard live inside Python, and you refer to that whole job by a single name.
| Idea | Purpose |
|---|---|
| sequence | Perform steps in order |
| selection | Choose a path based on a condition |
| repetition | Repeat steps while needed |
| indirection | Refer to another operation by name |
In this chapter, we will focus on sequence and selection, and we will meet repetition in flowchart form. We will implement repetition in Python when we study loops. We will return to indirection when we learn how to define and use functions.
9.2 Computational Thinking
Computational thinking is a way of analyzing problems and expressing solutions precisely enough that a person or computer can carry them out.
In her 2006 article Computational Thinking, computer scientist Jeannette Wing argued that this way of thinking belongs alongside reading, writing, and arithmetic as a skill for everyone. Its value lies in making difficult problems manageable: choosing a useful representation, separating a task into smaller parts, and finding an approach that can work in other situations too.
That argument has new relevance now that generative AI can produce code from a description of a problem. A 2026 systematic review of generative AI in computing education found that AI often helps students complete programming tasks faster, but those gains do not reliably transfer to independent performance. The authors identify verification, including reading, testing, modifying, and explaining code, as essential to learning. Doing that well still requires students to understand the problem and reason about the proposed solution.
In this course, we will organize computational thinking around four useful techniques:
- Decomposition breaks a larger problem into smaller pieces that can be understood and solved separately.
- Pattern recognition notices similarities between the current problem and problems or structures we have seen before. Recognizing a familiar shape can suggest a familiar solution.
- Abstraction decides which details matter and which can be ignored. A transit map, for example, preserves stations and connections while leaving out most streets, buildings, and geographic detail.
- Algorithm design turns a solution into precise steps. The result is an algorithm: a procedure that can be followed to solve a problem. When those steps are expressed as a program, a computer can carry them out automatically.
These techniques work together. Deciding which details matter can make a problem easier to break apart, and recognizing a familiar pattern can help us design the steps of a solution.
Precision is especially important when the solution will be carried out by a computer. People often follow instructions that leave room for judgment, such as:
Cook until golden brown.
A person may understand that instruction from experience. A computer cannot act on it unless “golden brown” is represented using something it can evaluate or measure.
The same issue appears in a request such as:
Choose the shipping rate.
A shipping clerk may already know the rate policy and apply it without describing every decision. A program needs the policy stated explicitly: which information determines the rate, where the rate changes, and what result should be produced.
We will use this shipping problem to see how the four techniques help turn an informal request into a design that we can inspect before writing Python.
9.3 Designing the Shipping Algorithm
Imagine a local courier with these shipping rates:
| Package weight | Shipping rate |
|---|---|
| 2 kg or less | $8 |
| More than 2 kg | $14 |
We want to build a small shipping calculator. The customer enters the package weight, and the program displays the shipping rate.
Before thinking about Python, we can make the problem precise:
| Part of the problem | Our decision |
|---|---|
| Input | The package weight in kilograms |
| Given information | The two weight ranges and their rates |
| Output | The shipping rate |
| Assumptions | Weight is a number greater than zero |
Identifying the input, output, and assumptions is an example of abstraction. For this simplified policy, we keep the package weight and ignore details that do not affect the rate, such as the colour of the box or the customer’s payment method.
We can then use decomposition to divide the task into smaller steps:
- Read the package weight.
- Determine which shipping rule applies.
- Use the rate from that rule.
- Display the rate.
The rate table also reveals a useful pattern: the result depends on which range contains the input. The same pattern appears when deciding whether an order qualifies for free delivery or whether a temperature is below freezing. This pattern recognition tells us that the solution needs a choice between alternatives.
These steps form an algorithm. They describe what the solution must accomplish without committing us to Python syntax.
The algorithm combines two of the structures introduced earlier. Reading the weight, choosing a rate, and displaying it happen in sequence. Choosing between the two rates requires selection.
9.4 Representing the Algorithm
A flowchart represents an algorithm visually. Shapes describe different kinds of operations, while arrows show how control moves between them. This movement is called control flow: the order in which instructions run.
The most useful flowchart symbols for this course are:
| Symbol | Name | Use |
|---|---|---|
Termini (START and END) |
Pill-shaped symbols that mark where the algorithm begins and ends; they are not ovals | |
| Rectangle | A process step, such as a calculation or an assignment | |
| Diamond | A decision containing a condition that evaluates to True or False |
|
| Parallelogram | Input or output, such as reading keyboard input into a variable or displaying output on the screen | |
| Arrow | Movement from one step to the next |
Flow normally moves from top to bottom and left to right. In this course, write each decision as a Boolean condition without a question mark, and label its outgoing paths True and False. This notation closely matches the conditions used in Python.
Flowcharts can also describe processes carried out by people. Use process rectangles for actions performed as part of the process. In our program flowcharts, use input/output parallelograms when the program reads input or displays output.
Consider a temperature-conversion program like those from Week 1:
This algorithm uses sequence: each step runs once, in order, along a single path.
The shipping algorithm also uses selection. It allows a condition to determine which path is followed. In this example, one condition is enough to choose between the two rates:
A package weighing 2 kg or less follows the True path. A heavier package follows the False path. Both paths set a rate before joining at the shared display step. At this stage, the important point is the shape of the solution: one condition separates two possible actions. Chapter 10 will explain how Python expresses this choice and will extend the idea to a problem with three alternatives.
A decision and a loop-back arrow can also express repetition. Suppose the shipping calculator must reject weights of zero or less. The validation part of the algorithm displays an error and returns to the input step until the user enters a positive weight:
A weight of zero or less follows the True path, displays an error, and returns to the input step. A positive weight follows the False path and continues to rate selection. The same steps can run as many times as needed. We will implement this behaviour in Python when we study loops; for now, the loop-back arrow is enough to represent it in a flowchart.
These familiar shapes make the structure visible. A straight path represents sequence, a decision diamond introduces selection, and a loop-back arrow represents repetition. More complex programs can be assembled from the same small set of structures.
Algorithms can also be written as pseudocode: a compact, code-like description that communicates the logic without requiring the exact syntax of a programming language. There is no universal pseudocode language. Its purpose is to make the steps and their order clear.
For example, the temperature-conversion algorithm could be written as:
READ celsius
SET fahrenheit TO celsius * 9 / 5 + 32
DISPLAY fahrenheit
The temperature-conversion flowchart and pseudocode represent the same sequence. The flowchart emphasizes shapes and connections, while the pseudocode emphasizes actions and order. We can choose the representation that makes a design easiest to understand and discuss.
9.5 Checking the Design
A design should be checked against the original problem before it becomes code. For the shipping calculator, we can ask:
- Does the design use the package weight as its input?
- Does each accepted weight follow one path?
- Does each path lead to the correct rate?
- Do both paths display a rate?
- Are the assumptions about the input stated clearly?
We can also trace the flowchart by choosing an input and following its path by hand. Useful checks include a weight below the point where the rate changes, the value at that point, and a weight above it:
| Weight | Path | Expected rate |
|---|---|---|
1.5 kg |
True | $8 |
2 kg |
True | $8 |
3 kg |
False | $14 |
Following each value through the flowchart connects the proposed algorithm back to the rate table. These examples do not establish that the design works for every possible weight, but they help us check both paths and the point where the result changes. Chapter 10 will examine such boundary values more carefully.
9.6 Summary
Program design begins by identifying the input, output, rules, and assumptions. Computational thinking supports this work through decomposition, pattern recognition, abstraction, and algorithm design. Structured programming organizes control flow through sequence, selection, and repetition; indirection lets one part of a solution refer to another operation by name.
A flowchart makes the structure of an algorithm visible, while pseudocode records its steps without requiring programming-language syntax. In the next chapter, we will express selection in Python and extend it from two paths to several alternatives.
9.7 References and Further Reading
-
Wing, Jeannette M. “Computational Thinking.” Communications of the ACM 49, no. 3 (2006): 33–35. Argues that computational thinking is a fundamental skill for everyone, not only computer scientists.
-
Kumar, Arun, Thakerng Wongsirichot, and Aziz Nanthaamornphong. “Generative AI in Computing Education: A Systematic Review and a Framework for Responsible Integration.” Computers and Education: Artificial Intelligence 11 (2026): 100677. Reviews evidence on learning, over-reliance, assessment, and responsible uses of generative AI in computing education.
-
Wikipedia: Flowchart. An accessible overview of flowcharts and the common symbols used to represent processes, decisions, input/output, and control flow.
-
Beej’s Guide to Python Programming, chapter 2: Pólya’s problem-solving process, from understanding the problem to looking back, and breaking large problems into subproblems.
10 Selection in Python
In Chapter 9, we used a flowchart to choose between two shipping rates. This chapter expresses that design in Python, then extends it to a cinema-ticket problem with three possible prices. We will examine how if, elif, and else express decisions, how indentation organizes their statements, and how to test each path.
10.1 From a Flowchart to Python
The shipping calculator from Chapter 9 charges $8 for a package weighing 2 kg or less and $14 for a heavier package. Here is that flowchart expressed in Python:
weight = float(input("Enter the package weight in kilograms: "))
if weight <= 2:
shipping_rate = 8
else:
shipping_rate = 14
print("Shipping rate: $", shipping_rate, sep="")
Closer LookBy default,
print()puts a space between its arguments. Here,sep=""removes that separator so the rate appears immediately after the dollar sign. Changing the separator affects the displayed message, not the chosen rate.
We continue to assume that the input is a number greater than zero. As you learned in Week 1, input() returns a string, so float() converts that string to a number and allows a weight such as 1.5. The decision diamond has become the condition weight <= 2, and the two assignments correspond to its paths. The final display statement follows both paths. A weight of 1.5 or 2 produces the lower rate, while a weight of 3 produces the higher rate.
You also met comparison operators in Week 1. A comparison produces True or False; here, that result can determine which path the program follows.
An expression that produces True or False is a Boolean expression. Here, weight <= 2 determines which path runs. Remember that = assigns a value, while == compares values for equality.
10.2 The if Statement and Indentation
An if statement runs one or more statements when its condition is True. Consider a program that displays a message for packages in the lower weight range:
weight = float(input("Enter the package weight in kilograms: "))
if weight <= 2:
print("The lower shipping rate applies.")
print("Thank you for checking our shipping rates.")
For a weight of 1.5, both messages appear. For a weight of 3, the first message is skipped, but the thank-you message still appears.
In Python, the statements controlled by an if form a block. Indentation shows which statements belong to that block. The if header ends with a colon, and every statement in its block is indented beneath it at the same level. Use four spaces for each indentation level.
Returning to the previous indentation level ends the block. A statement at that level is outside the if block. Moving a statement into the block therefore changes when it runs:
weight = float(input("Enter the package weight in kilograms: "))
if weight <= 2:
print("The lower shipping rate applies.")
print("Thank you for checking our shipping rates.")
Now both messages belong to the if block. For a weight of 3, neither appears. Both versions are valid Python, but their indentation gives them different behaviour.
An if statement on its own either runs its block or skips it. Adding else gives the program an alternative block, as in our shipping calculator:
if weight <= 2:
shipping_rate = 8
else:
shipping_rate = 14
Exactly one assignment runs. The else has no condition of its own: it covers every case rejected by the if condition. Its header ends with a colon and aligns with the if to which it belongs.
The display statement belongs after the entire selection because both paths need it. This combines selection with the surrounding sequence of input, processing, and output.
10.3 Choosing Among Several Paths
Now consider a fictional cinema with three ticket prices:
| Age | Ticket | Price |
|---|---|---|
| Younger than 13 | Child | $8 |
| 13 through 64 | Adult | $14 |
| 65 or older | Senior | $10 |
The customer enters an age, and the program displays the corresponding price. For now, we assume the input is a whole number greater than or equal to zero. We will handle negative ages later in this chapter.
One condition separates two paths. To choose among three prices, we need another condition. The keyword elif means “else if.” Python checks its condition only when the earlier conditions in the same chain were False:
age = int(input("Enter your age: "))
if age < 13:
ticket_price = 8
elif age >= 65:
ticket_price = 10
else:
ticket_price = 14
print("Ticket price: $", ticket_price, sep="")
Python checks the conditions from top to bottom and runs only the first matching branch, one of the chain’s alternative paths. For an age of 70, age < 13 is False and age >= 65 is True, so Python assigns the senior price. For an age of 10, the first condition is True, so Python does not check the elif. For an age of 30, both conditions are False, so the else block assigns the adult price.
An if-elif-else chain can contain several elif clauses. The final else is optional, but when present it must come last. Without an else, no branch runs when every condition is False. In this program, the earlier conditions leave ages 13 through 64 for else. The shared display statement then runs after the chain.
Condition order matters when conditions overlap. Consider this incorrect attempt:
if age >= 13:
ticket_price = 14
elif age >= 65:
ticket_price = 10
else:
ticket_price = 8
An age of 70 satisfies age >= 13, so the first branch assigns the adult price. Python never reaches the senior condition. Arrange overlapping conditions so that an earlier branch does not capture a case intended for a later one.
10.4 Combining Conditions
In Week 1, you previewed the Boolean operators and, or, and not: and requires both conditions to be True, or requires at least one of them, and not reverses its condition. We can now use them to combine comparisons in a single condition. We will study Boolean logic in more depth later in the course.
For example, an adult ticket applies when the age is at least 13 and at most 64:
if age >= 13 and age <= 64:
print("The adult ticket price applies.")
Both comparisons must be True. An age of 30 satisfies both, while an age of 70 fails the second.
For a non-negative age, either the child condition or the senior condition identifies a customer who does not pay the adult price:
if age < 13 or age >= 65:
print("A child or senior ticket price applies.")
The result is True when either comparison is True. In this example, that means the age is in the child or senior range.
A common mistake is to leave out part of a comparison. To check whether an age is exactly 12 or exactly 65, write each comparison in full:
age == 12 or age == 65
Do not write:
age == 12 or 65
The second expression does not compare age with 65. As a condition, it succeeds for every age; we will see why later in the course.
10.5 Independent and Nested Decisions
An if-elif chain chooses at most one branch. Separate if statements make independent decisions, so more than one block can run.
Suppose the cinema displays one message for a child ticket and another for a booking of five or more tickets:
age = int(input("Enter your age: "))
ticket_count = int(input("How many tickets are you buying? "))
if age < 13:
print("A child ticket is needed for this customer.")
if ticket_count >= 5:
print("Please collect this booking at the group counter.")
For an age of 10 and a ticket count of 5, both messages appear. Changing the second if to elif would make Python skip the ticket-count check whenever the child condition was True. Use separate if statements when both actions may occur. Use an elif chain when the branches are alternatives.
A nested decision places one decision inside another decision’s block. This is useful when the inner decision should happen only on a particular outer path.
We can use nesting to check whether an age is valid before choosing a ticket price. The first decision separates invalid ages from valid ones. Ticket selection happens only on the valid path, so it is nested inside that path. Here is that design expressed in Python:
age = int(input("Enter your age: "))
if age < 0:
print("Age must be zero or greater.")
else:
if age < 13:
ticket_price = 8
elif age >= 65:
ticket_price = 10
else:
ticket_price = 14
print("Ticket price: $", ticket_price, sep="")
The outer decision checks whether the age is negative. If it is, the program displays an error; otherwise, the inner selection chooses the ticket price. Its headers are indented four spaces, and their blocks are indented eight spaces. The final print() returns to four spaces, so it follows the inner selection but remains inside the outer else. A negative age therefore never reaches the price display.
This program checks one input and then ends. Asking again after an error will require a loop; Chapter 9 represented that repetition with a loop-back arrow. The program also assumes that int() can convert the input. We will handle failed conversions separately.
10.6 Testing Decision Paths
Testing compares what a program does with what it should do. Debugging finds and fixes the cause when those results differ.
In Chapter 9, we checked a design by following a path through its flowchart. Now we can run the Python program with chosen inputs and compare the displayed results with the cinema’s rules.
For the version that checks negative ages, predict the expected output for each age below before reading the table’s second column:
| Age | Expected output | Why this case helps |
|---|---|---|
-1 |
Age must be zero or greater. |
Just below the accepted range |
0 |
Ticket price: $8 |
Lowest accepted age |
12 |
Ticket price: $8 |
Last age in the child range |
13 |
Ticket price: $14 |
First age in the adult range |
64 |
Ticket price: $14 |
Last age in the adult range |
65 |
Ticket price: $10 |
First age in the senior range |
These boundary cases sit at or beside the points where behaviour changes. They help expose mistakes such as using <= where < was intended.
Ordinary values such as 10, 30, and 70 are also useful because they exercise each price branch. Passing these tests provides evidence that the program follows the requirements, although a finite set of tests cannot cover every possible input.
Choose the expected result from the requirements before running the program. Otherwise, it is easy to accept the program’s answer simply because it produced one.
When a result differs from what you expected, trace that input through the code. Check:
- The conditions: do the comparisons include the correct boundaries?
- The branch order: does an earlier condition capture the input unexpectedly?
- The indentation: does each statement belong to the intended path?
For independent decisions, include cases where both conditions are True, only one is True, and neither is True. This can reveal an accidental change from two if statements to an if-elif chain.
10.7 Summary
Selection chooses which statements run for a given input. An if statement can run or skip a block, else provides an alternative, and elif extends the choice to more paths. Indentation defines the blocks, and a chain runs only its first matching branch.
Boolean operators combine conditions. Separate decisions allow multiple actions, while nesting makes one decision depend on another. Test each path and the values where behaviour changes, using the requirements to determine the expected results.
10.8 References and Further Reading
- Python Tutorial:
ifStatements: examples ofif,elif, andelse. - Python Language Reference: The
ifStatement: the formal structure and execution rules for selection. - PEP 8: Style Guide for Python Code: conventions for indentation and naming.
- Beej’s Guide to Python Programming, chapter 6: flow control, Boolean expressions, and the
ifstatement.
11 Working with Strings
So far, we have used strings mainly to read input and display messages. Working more closely with text requires us to look inside those values. A string is an ordered sequence of characters: we can find its length, select a character by its position, and examine the text it contains.
We can also combine strings, adjust capitalization, remove surrounding spaces, and insert calculated values into messages. These operations produce new values without changing the original string. Understanding that distinction will help us use strings correctly as our programs become more involved.
11.1 Strings and String Literals
Chapter 6 introduced string literals, and Chapter 7 introduced str as the type used for strings. We can now distinguish the notation written in code from the value it produces.
A string literal is the quoted notation used to write a string value directly in source code. The surrounding quotes mark the literal’s boundaries; they are not part of the value.
>>> "COMP 1510"
'COMP 1510'
>>> 'hello'
'hello'
Closer LookThe REPL uses quotes to show that a result is a string, and may choose a different quote style from your literal.
print()displays the string’s contents without those surrounding quotes, so the same value can look different in the two displays.
You can use single or double quotes. Choosing the outer quotes lets you include the other kind inside the text:
print("It's easy")
print('She said "hi"')
The empty string, written "", contains no characters. It is still a string, just as zero is still a number.
11.2 Escape Sequences
Some characters affect how text is displayed. A newline moves text to the next line, and a tab adds horizontal spacing. Python writes these characters inside a literal with an escape sequence: a backslash followed by a character with a special meaning. For example, \n represents one newline character.
| Escape sequence | Meaning |
|---|---|
\n |
Newline: moves output to the next line |
\t |
Tab |
\\ |
A single backslash |
\' |
A single quote |
\" |
A double quote |
print("Line one\nLine two")
print("Name:\tAda")
The first statement displays its text on two lines. The second inserts a tab between Name: and Ada. Although \n is written with two characters in the source code, it represents one newline character in the string.
Often, choosing the other quote style is easier than using an escape. For example, the apostrophe in "It's easy" does not need to be escaped because the string literal uses double quotes.
11.3 Characters, Length, and Indexes
Each letter, digit, space, or punctuation mark in a string counts as one character. The built-in len() function returns the number of characters.
Each character sits at a numbered position called an index. Python numbers indexes from zero:
The string "Python" contains six characters, with indexes from 0 through 5. In a non-empty string, the first index is 0 and the final non-negative index is one less than the length.
Square brackets after a string select the character at an index:
>>> word = "Python"
>>> len(word)
6
>>> word[0]
'P'
>>> word[1]
'y'
>>> word[len(word) - 1]
'n'
Asking for an index outside the string raises an IndexError. The valid indexes of this six-character string run from 0 to 5, so word[6] fails.
The expression len(word) - 1 calculates the final index without assuming a particular string length. When text comes from input, we must first check that it contains at least one character:
name = input("Name: ")
if len(name) > 0:
print("The first character is", name[0])
print("The final character is", name[len(name) - 1])
else:
print("No name was entered.")
The condition protects both indexing expressions. An empty string has length 0 and has no valid indexes, so trying name[0] without checking would raise an IndexError.
Negative indexes count from the end of a string. The index -1 refers to the final character:
>>> word[-1]
'n'
Python can also extract several characters at once. We will study that operation, called slicing, alongside lists later in the course.
11.4 Strings Are Immutable
Selecting a character does not mean that you can replace it. This assignment fails:
>>> word[0] = "J"
Python raises a TypeError. Strings are immutable, which means that an existing string value cannot be changed in place.
If a string cannot be changed in place, how can a program produce different text? A method is a function that belongs to a value, and strings provide many of them. Some methods return a new string while leaving the original unchanged.
You have already seen calls such as len(course), where the value goes inside the parentheses. A method call uses a different form: course.upper(). Here, course refers to a string, the dot connects it to the method named upper, and the parentheses call that method. The call returns an uppercase version of the string, which you can assign to a name:
course = "Introduction to Programming"
uppercase_course = course.upper()
print(course) # Introduction to Programming
print(uppercase_course) # INTRODUCTION TO PROGRAMMING
You can also assign the result back to the same name:
course = course.upper()
This does not change the original string. It makes course refer to the new string.
This difference explains a common surprise:
>>> name = "Ada"
>>> name.upper()
'ADA'
>>> name
'Ada'
The method produces a result, but this example does not store it. If the program needs the uppercase value later, it must assign the result to a name:
>>> uppercase_name = name.upper()
>>> uppercase_name
'ADA'
The same principle applies throughout this chapter: an operation can inspect a string or produce another value, but it does not edit the original string.
11.5 Combining Strings
Joining strings is concatenation. Python uses the + operator for it:
>>> first = "COMP"
>>> course = first + " 1510"
>>> course
'COMP 1510'
The * operator repeats a string a whole number of times:
>>> "ha" * 3
'hahaha'
The + operator cannot directly combine a string and a number. Although str() can convert a number first, f-strings are usually a clearer way to mix text with values.
11.6 A Few Useful String Methods
We have already used upper() to produce uppercase text. Here are six useful string methods. In the table, s is the string being used and t is the text to search for:
| Method | Result |
|---|---|
s.upper() |
A new string with each letter uppercase |
s.lower() |
A new string with each letter lowercase |
s.isupper() |
True if the string has at least one letter and all its letters are uppercase |
s.islower() |
True if the string has at least one letter and all its letters are lowercase |
s.strip() |
A new string with surrounding whitespace removed |
s.find(t) |
The index of the first occurrence of t, or -1 if it is absent |
>>> course = " Comp 1510 "
>>> course.strip()
'Comp 1510'
>>> course.lower()
' comp 1510 '
>>> "COMP 1510".isupper()
True
>>> "comp 1510".islower()
True
>>> "Comp 1510".islower()
False
>>> "COMP 1510".find("M")
2
>>> "COMP 1510".find("z")
-1
These methods leave the original string unchanged, but their results have different types. upper(), lower(), and strip() return strings; isupper() and islower() return True or False; find() returns an integer position, or -1 when the text is absent. That -1 reports a failed search; it does not mean that a match was found at the final character.
strip() removes whitespace from the beginning and end, including spaces and tabs. It leaves spaces inside the text intact, so " Comp 1510 ".strip() preserves the space between Comp and 1510.
What a method returns determines how you can use it. A method that returns a string can provide cleaned or normalized text:
name = input("Name: ")
name = name.strip()
print("Hello,", name)
Methods are also useful in conditions. Converting text to lowercase before comparing it lets the program accept several capitalizations:
answer = input("Continue? ")
answer = answer.strip()
answer = answer.lower()
if answer == "yes":
print("Continuing")
else:
print("Stopping")
First, strip() removes surrounding whitespace. Then lower() produces lowercase text, allowing "yes", "YES", and " Yes " to satisfy the same condition. Each assignment makes answer refer to the next result.
Python provides many more string methods. You do not need to memorize a large list. Learn a small useful set now, and consult the string methods documentation when a program needs something else.
11.7 Formatting Output with F-Strings
An f-string is a string literal prefixed with f. Expressions inside braces are evaluated and placed into the text:
>>> name = "Ada"
>>> age = 19
>>> f"{name} is {age} years old"
'Ada is 19 years old'
>>> f"Age next year: {age + 1}"
'Age next year: 20'
The braces can contain a variable or another expression that produces a value.
Compare two ways to display the same result:
name = "Ada"
score = 92
print(name, "scored", score)
print(f"{name} scored {score}")
Both statements work. The f-string keeps the spaces and wording together, making the intended output easier to see in the program.
A colon after an expression introduces a format specification. Writing :.2f displays a number with exactly two digits after the decimal point, rounding the displayed value when needed:
>>> shipping_rate = 8
>>> f"Shipping rate: ${shipping_rate:.2f}"
'Shipping rate: $8.00'
Formatting changes the text produced for display; it does not change the value stored in the variable.
Here is the shipping calculator from Chapter 10 with its output formatted using f-strings. As before, this version assumes the user enters a positive numeric weight:
weight = float(input("Enter the package weight in kilograms: "))
if weight <= 2:
shipping_rate = 8
else:
shipping_rate = 14
print("COMP Couriers")
print(f"Weight: {weight} kg")
print(f"Shipping rate: ${shipping_rate:.2f}")
Use f-strings when output combines text with values. They keep the output pattern readable and give you control over how numbers appear.
F-strings create strings; they do not print by themselves. In the REPL, Python displays the resulting value, but a program normally passes the f-string to print() or assigns it to a name:
customer = "Ada"
message = f"Welcome, {customer}!"
print(message)
This prints Welcome, Ada!. Building a string and displaying it are separate operations, even when a program performs them in the same statement.
11.8 Summary
A string is an immutable sequence of characters. A string literal is text written directly in source code to create a string value. len() returns the number of characters, and an index selects one character from the sequence.
Strings cannot be changed in place, but expressions and methods can produce new strings. The methods upper(), lower(), and strip() return new strings, while find() returns an integer index. An f-string places the values of expressions into text, and :.2f displays a number with two digits after the decimal point.
11.9 References and Further Reading
- String methods: the complete list of methods supported by strings.
- F-strings in the language reference: the formal syntax of formatted string literals.
- Beej’s Guide to Python Programming, chapter 7: strings, immutability, and f-string formatting.
12 Using Python Modules
Python programs often use code that is already available. You have called built-in functions such as input() to read text, int() to convert a value, and print() to display it. Python also includes a standard library of reusable tools.
A module groups related functions and values. To use functions from a module such as math, you first import it. We will use math and random to see how to access their tools and use the results in calculations.
12.1 Importing a Module
The import statement makes a module available under its name. Imports normally appear at the top of the file, before the statements that use them. This example uses math.sqrt() to calculate a square root:
import math
number = float(input("Enter a non-negative number: "))
root = math.sqrt(number)
print(f"Square root: {root:.2f}")
The expression math.sqrt(number) uses dot notation: math identifies the module, sqrt selects its square-root function, and the parentheses call that function with number.
The example assumes that the user enters a number greater than or equal to zero. The prompt describes the expected input; it does not check it. We will add input checks when we study loops.
The dot also appeared with string methods in Chapter 11. The name before the dot tells you where the operation comes from:
name.upper()calls a method on the string referred to byname.math.sqrt(25)calls a function provided by the importedmathmodule.
After import math, write math.sqrt(25); the import does not create a separate name called sqrt.
12.2 The math Module
The math module contains mathematical functions and constants. We will use two of them here:
| Name | Purpose | Example |
|---|---|---|
math.sqrt(x) |
Returns the square root of x |
math.sqrt(25) produces 5.0 |
math.pi |
Python’s floating-point approximation of | Approximately 3.141592653589793 |
math.pi is a value, so it is used without parentheses. math.sqrt is a function, so parentheses call it:
>>> import math
>>> math.pi
3.141592653589793
>>> math.sqrt(81)
9.0
12.2.1 Calculating the Area of a Circle
For a circle with radius , the area is . This program uses math.pi for and assumes the user enters a positive numeric radius:
import math
radius = float(input("Enter the circle radius: "))
area = math.pi * radius ** 2
print(f"Area: {area:.2f}")
Python squares radius with ** 2 and multiplies the result by math.pi. The f-string displays two decimal places without changing the value stored in area.
12.2.2 Calculating Distance with math.sqrt()
Suppose two points are 3 units apart horizontally and 4 units apart vertically. By the Pythagorean theorem, the distance between them is . The program first stores the sum of the squares in squared_distance, then passes that value to math.sqrt():
import math
horizontal = 3
vertical = 4
squared_distance = horizontal ** 2 + vertical ** 2
distance = math.sqrt(squared_distance)
print(f"Distance: {distance:.2f}")
horizontal and vertical are starting values. squared_distance is an intermediate variable holding 25. math.sqrt() returns 5.0, which is assigned to distance and displayed. You could pass the sum directly as math.sqrt(horizontal ** 2 + vertical ** 2); the name lets you inspect it.
12.3 The random Module
The random module provides randint(a, b) for choosing an integer between two limits:
>>> import random
>>> random.randint(1, 6)
4
>>> random.randint(1, 6)
1
Reading The OutputThe numbers above are sample results, not output your calls must match. A repeated roll can return the same number; that alone does not mean the program is stuck.
Both limits are included, so random.randint(1, 6) can return any integer from 1 through 6, like a six-sided die. The limits must be integers, and the first must not exceed the second.
12.3.1 Choosing a Random Point
A program can import more than one module. This example chooses a point at random and calculates its distance from the origin:
import math
import random
x = random.randint(-5, 5)
y = random.randint(-5, 5)
distance = math.sqrt(x ** 2 + y ** 2)
print(f"Point: ({x}, {y})")
print(f"Distance from the origin: {distance:.2f}")
The names before the dots identify which module supplies each function. Two calls to random.randint() choose the point’s x and y coordinates; math.sqrt() uses them to calculate the distance. A later run may choose a different point, or the same one again.
When you need more from a module, check its documentation for available functions, the arguments they take, and the values they return.
12.4 Summary
The standard library is ready-made code that comes with Python. An import statement makes a module available, and dot notation selects a function or value from that module.
The math module provides math.sqrt() and the value math.pi. The random module provides random.randint(a, b), which returns a whole number from the inclusive range a through b. Keeping the module name visible makes the source of each library tool clear.
12.5 References and Further Reading
- The Python Standard Library: the official index of modules that ship with Python.
math: Mathematical functionsrandom: Generate pseudo-random numbers- Beej’s Guide to Python Programming, chapter 12: importing and using modules.
13 Repetition in Python
A program often needs to perform the same work more than once. It might ask again after invalid input, examine each character in a string, or keep a running total. Writing a separate statement for every repetition becomes impractical when the input determines how many are needed.
A loop lets us write the repeated steps once and control how often they run. Chapter 9 showed this idea with a loop-back arrow in a flowchart. We will now express it in Python using while and for.
13.1 From Repeated Statements to Loops
Suppose a program needs to display the numbers from one through five. One approach is to write five separate statements:
print(1)
print(2)
print(3)
print(4)
print(5)
This works, but the program describes every repetition separately. Extending it to one hundred would require ninety-five more statements. If the counting rule changed, many lines might need to change with it.
The repeated lines can be replaced by a rule: display the current number, increase it, and stop after five. One pass through a loop’s block is an iteration.
Before writing a loop, answer three questions:
- What values, if any, must be set before the repetition begins?
- Which statements need to run during each iteration?
- What will change from one iteration to the next, and how will the loop know when to stop?
Python provides two loop statements. A while loop repeats as long as a Boolean condition is True. A for loop takes items from a sequence one at a time; in this chapter, the sequence will be a string. We will begin with while because its condition makes the decision to repeat or stop explicit.
13.2 The while Statement
The while loop repeats its block while its condition is True. This loop counts from one to five:
count = 1
while count <= 5:
print(count)
count += 1
Three parts make this loop work:
- Before the loop,
countis initialized to1. - At the top of every pass, Python tests
count <= 5. If it isTrue, the indented block runs. If it isFalse, the loop ends. - The statement
count += 1increasescountby one before the next condition check.
Trace each condition check to see how count changes and why the loop stops:
| Condition check | count at the test |
count <= 5 |
Output |
|---|---|---|---|
| 1 | 1 |
True |
1 |
| 2 | 2 |
True |
2 |
| 3 | 3 |
True |
3 |
| 4 | 4 |
True |
4 |
| 5 | 5 |
True |
5 |
| 6 | 6 |
False |
No output |
Python checks the condition six times, but the body runs only five times. At the final check, count is 6, so count <= 5 is False and no sixth iteration begins.
The condition is tested before every pass, including the first. If it is False immediately, the body does not run. A while loop therefore runs its body zero or more times.
The flowchart below shows the two possible paths after a condition check:
In the counting example above, the diamond represents count <= 5 and the loop body contains print(count) and count += 1. After both statements run, execution returns to the condition for another check. The False path leaves the loop without running its body.
Indentation determines which statements repeat. Adding a statement after the loop makes the difference visible:
count = 1
while count <= 3:
print(count)
count += 1
print("Done")
Both indented statements belong to the loop, so both repeat. print("Done") is dedented, which ends the loop’s block. It runs once, after the condition becomes False.
Chapter 10 used boundary cases to check selection. Loop boundaries require the same care: predict what the loop just above would print if you changed count <= 3 to count < 3, then check yourself in the REPL. Getting a boundary like this wrong causes an off-by-one error, where a loop repeats once too often or once too few.
13.2.1 Avoiding Infinite Loops
If nothing in a loop changes the condition, the loop may never stop:
count = 1
while count <= 5:
print(count)
An infinite loop keeps repeating without ending on its own. Here, count never changes, so count <= 5 remains True and the program repeatedly prints 1. Press Ctrl-C in the terminal to interrupt a program stuck in a loop.
An update can be present and still move in the wrong direction:
count = 1
while count <= 5:
print(count)
count -= 1
After the first iteration, count becomes 0, then -1, then -2. All these values are still less than or equal to 5, so the condition never becomes False. The starting value, condition, and update must work together for a counting loop to stop.
A Useful HabitBefore running a counting loop, trace its starting value through two updates and check the condition each time. If the values move away from the stopping point, fix the update first.
13.2.2 Validating Input with while and break
Chapter 9’s shipping flowchart keeps asking for a package weight until the weight is positive. We can now express that behaviour in Python by reading and checking a new weight inside a loop.
For this task, the decision to stop comes after reading the input. We use while True to keep the loop running while we read and check each weight. Its condition is always True, so it cannot become False and end the loop.
To end the loop after a successful check, we use the break statement. When Python reaches break, it leaves the loop immediately. Placing break inside if weight > 0: lets a positive weight end the repetition.
As before, assume the input can be converted to a number. The loop checks whether it is positive; handling text that cannot be converted comes later.
The input statement runs at the start of every iteration:
while True:
weight = float(input("Enter the package weight in kilograms: "))
if weight > 0:
break
print("Weight must be positive.")
if weight <= 2:
shipping_rate = 8
else:
shipping_rate = 14
print(f"Shipping rate: ${shipping_rate:.2f}")
Suppose the user enters 0, then -2, then 1.5. Trace the decision after each input:
| Value entered | weight > 0 |
What happens next |
|---|---|---|
0 |
False |
Display the error and ask again |
-2 |
False |
Display the error and ask again |
1.5 |
True |
Run break and leave the loop |
After break, execution continues with if weight <= 2:. The program can now choose a shipping rate using the accepted weight.
The same pattern applies to other inputs:
- Start a
while Trueloop and read a value inside it. - If the value is valid, use
breakto leave the loop. - Otherwise, explain the problem and let the next iteration ask again.
13.3 The for Statement
Suppose you want to display every character in a string. A for loop visits the characters one at a time, so you can describe what to do with each one:
course = "COMP"
for letter in course:
print(letter)
Read the header as “for each letter in course.” Here, letter is the loop variable. On each iteration, Python automatically assigns the next character to it and runs the indented block. When there are no characters left, the loop ends.
| Iteration | Value of letter |
Output |
|---|---|---|
| 1 | "C" |
C |
| 2 | "O" |
O |
| 3 | "M" |
M |
| 4 | "P" |
P |
An empty string contains no characters, so a for loop over "" runs zero times. A one-character string produces exactly one iteration.
As with while, the header ends with a colon and the loop’s block is indented. Use a singular name such as letter for the loop variable because it refers to one character at a time.
A for loop can contain more than one statement:
word = "Code"
for letter in word:
print(letter)
print(letter.lower())
print("Done")
Both indented statements run for each character. print("Done") is outside the loop, so it runs once after the final character.
A condition inside the loop can choose how to handle each character:
course = "COMP 1510"
for character in course:
if character == " ":
print("space")
else:
print(character)
The loop still visits every character, including the space. The if statement chooses which print() call runs during each iteration.
13.4 A Simple Accumulator
The previous for examples display something during each iteration. You can also use a loop to build one result and display it after the loop finishes.
An accumulator is a variable initialized before a loop and updated inside it to build a running result. This example counts lowercase "a" characters in a word:
word = "banana"
count = 0
for letter in word:
if letter == "a":
count += 1
print(count)
In the earlier while loop, count controlled when repetition stopped. Here, it begins at 0 and increases for each matching letter; its final value is the result.
Each statement has a deliberate position. Initialization belongs before the loop; placing count = 0 inside the loop would erase the earlier work on every iteration. The update belongs inside the if because only a matching character should increase the count. The final print(count) belongs after the loop because the program should report the completed result once.
Trace the value carefully:
letter |
letter == "a" |
count after the iteration |
|---|---|---|
"b" |
False |
0 |
"a" |
True |
1 |
"n" |
False |
1 |
"a" |
True |
2 |
"n" |
False |
2 |
"a" |
True |
3 |
The loop checks every letter, while the if statement changes count only for a match.
The comparison matches lowercase "a" only. Convert the input to lowercase first to count uppercase and lowercase forms:
word = input("Word: ")
word = word.lower()
count = 0
for letter in word:
if letter == "a":
count += 1
print(f"The word contains the letter a {count} times.")
The counting loop is unchanged; the program now reads and lowercases the word first.
13.5 Tracing Loops by Hand
Chapters 9 and 10 showed how to trace a flowchart or a decision with a chosen input. The tables above use the same technique to follow loops. When you trace a loop yourself, record the small set of values that change. For a while loop, record its condition and changing value; if it uses break, record when that statement runs. For a for loop, record the current character and any accumulator.
A useful trace does not need to reproduce every line of the program. It only needs enough information to answer three questions:
- What value controls or advances the loop?
- What changes during this iteration?
- What must eventually cause the loop to stop?
For a while loop, pay particular attention to the first pass and the condition check or break that ends it. For a for loop, follow the first and last items, and consider what happens with an empty string. These cases help reveal incorrect initial values, comparisons, and updates.
13.6 Choosing Between while and for
Use for when you need to work through the items in a sequence. To count the "a" characters in a word, you need to examine every character, so for is a natural choice.
Use while when you need to repeat an action based on a condition or until a decision inside the loop tells you to stop. To get a positive package weight, the program asks on each pass and uses break when the weight is valid. The values the user enters determine how many times the loop repeats.
13.7 Summary
A loop repeats an indented block. A while loop checks its condition before each pass. It stops when the condition becomes False or a break statement ends it. Input validation can use while True to ask again until the value is valid.
A for loop visits a string’s characters one at a time; its loop variable holds each character in turn. An accumulator starts before a loop and builds a running result inside it.
13.8 References and Further Reading
- Python Language Reference: The
whilestatement - Python Language Reference: The
breakstatement - Python Language Reference: The
forstatement - Beej’s Guide to Python Programming, chapter 6:
whileloops,forloops, and choosing between them.
14 Functions
Quote“Simplicity is a great virtue but it requires hard work to achieve it and education to appreciate it.”
Edsger W. Dijkstra, On the Nature of Computing Science (EWD 896).
You can now write programs that make decisions and repeat steps. As those programs grow, keeping every step in one long sequence makes it harder to see how the pieces fit together. Functions let you give a name to a particular job, then use that name wherever the program needs the job done.
You already work this way when you call len(word) or random.randint(1, 6): you supply the information a function needs and use the result without writing its steps yourself. You will now define your own functions. You will begin with a function that displays a greeting, then write functions that receive values, calculate results, and work together.
As you read, follow the information through each call: what goes into the function, what happens inside it, and what comes back. That same pattern will help you understand both a short calculation and a program built from several functions.
14.1 Defining and Calling a Function
To display a welcome message at several points in a program, put that job in a function, a reusable block of code. Write a function definition using def, a descriptive name, parentheses, and a colon:
def display_welcome():
print("Welcome!")
display_welcome()
display_welcome()
The indented statement is the function’s body. Defining the function makes it available, but does not run it. Each display_welcome() is a function call: Python runs the body, then continues after the call. Two calls display the message twice.
A call placed above the definition stops the program, because the name does not exist until the def runs:
NameError: name 'display_welcome' is not defined
Python simply has not reached the definition yet. Keep definitions above the calls that use them.
To personalize the greeting, the function needs to receive a name. A parameter is a name in the definition that receives a value when the function is called:
def display_greeting(name):
print(f"Hello, {name}!")
display_greeting("Maya")
display_greeting("Sam")
Each call supplies an argument, a value passed to the function. The parameter name receives "Maya" for the first call and "Sam" for the second. The body stays the same while the supplied value changes.
To display a person’s name alongside a message, give the function two parameters. Arguments are matched to parameters by position:
def display_message(name, message):
print(f"{name}: {message}")
student_name = "Maya"
display_message(student_name, "Ready to begin")
Here, name receives the value of student_name, and message receives "Ready to begin". The calling code’s variable and the function’s parameter do not need matching names.
These definitions require one argument per parameter. If one is missing, Python stops with a TypeError, and the message names the parameter that received nothing:
TypeError: display_message() missing 1 required positional argument: 'message'
Choose names that describe the job, as display_welcome, display_greeting, and display_message do here. A reader should understand what a call does without opening its definition.
A Useful HabitWhen checking a function, change one argument at a time. For
display_message(), try another message for the same person, then another person with the same message. Each argument should move one part of the output and nothing else: an argument that changes nothing is probably unused, and an effect in the wrong place usually means the arguments are swapped.
14.2 Returning a Value
Displaying a greeting finishes that function’s job. Other functions produce values that you can store or use in further calculations, such as a duration converted from minutes to seconds.
The return statement sends a return value back to the caller, the code that made the call. The caller can store that value or use it as part of an expression:
def minutes_to_seconds(minutes):
return minutes * 60
seconds = minutes_to_seconds(3)
print(seconds)
print(minutes_to_seconds(3) + 30)
Assigning the result of minutes_to_seconds(3) to seconds stores 180 for later use. In the final line, the same call is part of a larger calculation that produces 210. Change the argument to 5 in both calls and predict the results before running them.
A function can have more than one return. You can use the selection statements you already know to choose what to return. This function selects the longer of two words, choosing the first when their lengths are equal:
def longer_word(first, second):
if len(first) >= len(second):
return first
return second
print(longer_word("sun", "planet"))
print(longer_word("tree", "moon"))
Executing return ends that call immediately. When the condition is true, Python returns first without reaching the final statement. Otherwise, it returns second. Putting the final return in an else branch is also valid; leaving out else here is a style choice.
14.3 Printing Is Not Returning
When a function displays the answer you expected, it is easy to assume that the caller received that answer too. But displaying a value and passing it back are separate actions: print() produces output for a person, while return supplies a value to the calling code. To see the difference, assign the result of a function that only prints its calculation:
def display_seconds(minutes):
print(minutes * 60)
result = display_seconds(3)
print(f"Stored result: {result}")
Although 180 appears on screen, the assignment receives None, the special value used here to indicate no useful result. The final line therefore displays Stored result: None.
Python returns None whenever a call reaches the end without executing a return. A bare return, with no expression after it, also ends the call and returns None.
Printed output and returned values can look the same in the REPL. These two functions both make 180 appear:
>>> def minutes_to_seconds(minutes):
... return minutes * 60
...
>>> def display_seconds(minutes):
... print(minutes * 60)
...
>>> minutes_to_seconds(3)
180
>>> display_seconds(3)
180
The REPL echoes the first call’s returned 180. The second call prints 180 and returns None, which the REPL does not echo. Storing and displaying the result, as the first program in this section does, reveals what the caller received.
Try It YourselfIn the first program in this section, compare displaying the calculation with returning it to the caller:
- Add
print(result + 30)as a new last line. Predict what it displays, then run the program. If an error occurs, look at the two types it names.- Replace
print(minutes * 60)withreturn minutes * 60. Predict the output again, then run it and compare with the first run.
14.3.1 Returning on Every Path
Checking that a function contains a return is not enough. If selection allows a call to reach the end without executing that statement, the caller still receives None. Consider this unfinished version of longer_word(), which is meant to follow the same rule as before:
def longer_word(first, second):
if len(first) >= len(second):
return first
print(longer_word("planet", "sun"))
Try It YourselfTry the unfinished
longer_word()with a different pair of words:
- Change the arguments so the second word is longer. Predict the result, then run the program.
- Add the missing
returnso the function returns a word on both paths.- Run it with the first word longer, the second word longer, and two different words of equal length. Check the displayed word each time.
14.4 Documenting a Function
Once a function has a clear job, you should be able to explain how to use it without walking through its code. Someone calling minutes_to_seconds() needs to know what values it accepts and what its result means. A docstring records that information inside the function, where both readers and Python’s help tools can find it.
Place the docstring as the first statement in the body, enclosed in triple quotation marks. Write one short sentence for the job. Add :param and :return: entries only when the inputs or the result need explanation, such as a unit or a limit on the values:
def minutes_to_seconds(minutes):
"""
Convert a duration in minutes to seconds.
:param minutes: a non-negative number of minutes
:return: the equivalent duration in seconds
"""
return minutes * 60
print(minutes_to_seconds(3))
From the docstring, you can tell which units the function accepts and returns. Its first line alone carries most of that explanation; the entries help when a reader could misunderstand a unit or an input limit. The docstring does not enforce the expectation: negative arguments still produce negative results. Input checks require executable code.
14.4.1 Reading Docstrings
The built-in help() function displays a function’s name, parameters, and docstring. A file can carry a docstring too: a module docstring is a triple-quoted string placed as the first statement in the file to describe its purpose. Calling help() on an imported module displays that description along with information about its functions.
Try It YourselfMake a docstring visible through Python’s help tool:
- Use the complete
longer_word()example in the Returning a Value section. Add a short docstring as the first statement in the function, describing the result and which word it returns when the lengths are equal.- Add
help(longer_word)as the program’s last line and run it. Look for your docstring beneath the function’s name and parameters.
14.5 Functions That Answer Questions
So far, your functions have returned numbers and strings. A function can also answer a question, such as whether some text is long enough. Giving that check a name lets you use it wherever the program needs to make the same decision.
A predicate function answers a yes-or-no question by returning True or False. longer_word() chose between two values with an if. Since a comparison already produces a Boolean value, you can return it directly. Names beginning with is_ or has_ make the call itself read like the question:
def has_minimum_length(text, minimum):
return len(text) >= minimum
print(has_minimum_length("hello", 5))
if has_minimum_length("hello", 5):
print("The text is long enough.")
has_minimum_length("hello", 5) returns True, which you can display with print() or use directly as an if condition. Adding == True compares True with True, or False with False, and changes nothing.
14.6 Using Loops Inside Functions
In Chapter 13, you used an accumulator to count matching characters. You can put that same pattern inside a function so the caller supplies the text and receives the completed count. Here, the function counts spaces:
def count_spaces(text):
count = 0
for character in text:
if character == " ":
count += 1
return count
print(count_spaces("one two three"))
print(count_spaces("word"))
print(count_spaces(""))
Each call starts with count = 0, so it does not carry over the previous call’s count. The return sits after the loop because the function must examine every character before the total is ready. An empty string still returns 0: the loop runs zero times, but execution reaches the return.
Try It YourselfWhat changes when you move
returninside the loop?
- Run the example above, then indent
return countto line up withif.- Predict the three new results, then run it. Which calls reach
return count, and after how many characters?
This is also where return differs from break: inside a function, break ends only the loop it sits in, while return ends the whole call, even from inside a loop.
14.7 Decomposing a Program
In Chapter 9, you used decomposition to plan a solution as a series of smaller steps. Functions now let you organize the code around those jobs. Look for steps that serve one clear purpose, then consider what information that job needs and what result it should produce. These choices give each function a responsibility and define how it works with its callers.
Preparing a duration message involves two jobs: converting minutes to seconds and building the text. Keeping the conversion separate lets other code use its numeric result:
def minutes_to_seconds(minutes):
return minutes * 60
def duration_message(minutes):
seconds = minutes_to_seconds(minutes)
return f"Duration: {seconds} seconds"
print(duration_message(3))
To build the message, duration_message() calls minutes_to_seconds(). The conversion returns 180, which the message function uses to build and return "Duration: 180 seconds". The outer print() displays that string. Arguments carry information into each job, and return values carry the results back to its caller.
Here, minutes_to_seconds() acts as a helper function: it handles a smaller job that duration_message() needs.
Separating these responsibilities gives each function a reason to exist. The conversion can serve another calculation without producing a message, and the message wording can change without changing the conversion. Because both functions return their results, the caller decides whether to display, store, or use them in further work.
14.8 Running and Importing a File
You have used functions from math and random by importing those modules. Your own Python file can also act as a module, allowing another program to use its functions. For example, the duration conversion can be useful even when a program does not need a duration message.
During the first import in a running program, Python executes the file’s statements from top to bottom. Each def makes a function available without running its body. In the previous example, the final print(duration_message(3)) call would also run, displaying a message even if the importing program only wanted the conversion.
14.8.1 Why the Main Guard Matters
A main function, conventionally named main(), gathers the work needed to run the program into one place. It gives you one call to control. A main guard makes that call only when the file is run directly, so importing the file does not start that work.
In durations.py below, main() coordinates the duration program, and the final if statement controls its call:
def minutes_to_seconds(minutes):
return minutes * 60
def duration_message(minutes):
seconds = minutes_to_seconds(minutes)
return f"Duration: {seconds} seconds"
def main():
minutes = 3
message = duration_message(minutes)
print(message)
if __name__ == "__main__":
main()
The guard tests __name__, which Python sets according to how the file is used. When you run durations.py directly, its value is "__main__". The condition is True, so main() runs and displays Duration: 180 seconds.
When another program imports durations, the module’s __name__ is "durations". The condition is False, so Python skips the call to main(). Its functions are still available to the importing program. A file named use_duration.py in the same folder can use the conversion like this:
import durations
print(durations.minutes_to_seconds(2))
This program displays:
120
The call uses the same dot notation as math.sqrt(25): durations names the module, and minutes_to_seconds names its function. The returned value goes to this caller, which displays it with print().
The guard controls only the call indented beneath it. A print() outside the functions and the guard would still run during import. Keeping the program’s starting work inside main() lets the importing program choose which functions to call and how to use their results.
Try It YourselfCompare the guard’s effect on running and importing
durations.py:
- Predict the output of both
durations.pyanduse_duration.pyif the guard is replaced by an unindentedmain()call.- Make that change and run both files. Which output changes, and why?
14.9 Reassigning a Parameter
Returning a result gives the caller a value to use. It does not automatically update the variable supplied as an argument. To update that variable, the caller must assign the returned value to it.
Here, score receives the value of points when increment_score() is called. The function adds 1 to score and returns the new value. Compare what happens when the caller ignores that result and when it stores it:
def increment_score(score):
score += 1
return score
points = 42
increment_score(points)
print(points)
points = increment_score(points)
print(points)
After the first call, points is still 42: the function returned 43, but the caller did not store it. In the second call, points = increment_score(points) assigns that returned 43 to points.
The parameter score and the caller’s variable points are separate names. Reassigning score changes its value inside the function, leaving points unchanged. Chapter 15 explores where names are available and how this separation works.
14.10 Summary
A function names a reusable job. Parameters receive arguments, and return ends the call and sends a result to the caller. Printing only displays output; without an explicit return value, a call produces None. Check every expected path, and give the function a docstring that says what it does.
Decomposition connects smaller jobs into a program. By convention, a main function coordinates the program’s work. A main guard lets the file run directly while keeping its functions available for import. Each call has its own local names, so reassigning a parameter does not change the caller’s variable. The caller must store the returned result when it needs to keep it.
14.11 References and Further Reading
15 Variable Scope and Constants
Quote“[T]he complete meaning of a word is always contextual, and no study of meaning apart from a complete context can be taken seriously.”
J. R. Firth, The Technique of Semantics, p. 37, excerpt.
Dividing a program into functions separates the work, but it also separates the names used to do it. A variable created inside one function is not automatically available to the rest of the program. This is why Chapter 14’s functions used arguments and return values to exchange information.
Knowing where a name is available helps you decide where to keep a value. A calculation may need a value only during a function call, while a fixed discount rate may be useful throughout a module. This chapter explains those boundaries so you can follow how functions use and update values.
15.1 Local Names and Function Calls
Suppose a function calculates the total price for a purchase. It needs names for the price, the quantity, and the result of multiplying them. The caller needs the final value, but it does not need access to every name used along the way.
The scope of a name determines where in a program Python can use it. A parameter or a name assigned inside a function is a local variable. The name stays within the function’s local scope. In the example below, price, quantity, and subtotal all belong to the call to calculate_total():
def calculate_total(price, quantity):
subtotal = price * quantity
return subtotal
total = calculate_total(6, 4)
print(total)
The caller receives 24 and assigns it to total. The value has come back from the function, but the name subtotal remains local to the call. Adding this line below print(total) stops the program with a NameError:
print(subtotal)
Python cannot find subtotal outside calculate_total(), so the report ends with NameError: name 'subtotal' is not defined.
Each call has its own values for these local names. Another function can use names such as price or subtotal for its own work without changing the values in this call. That separation lets you understand a function’s calculation on its own.
A function you call cannot see the local names of the function that called it. In the following program, main() passes its total as an argument so that display_total() can use the value:
def display_total(total):
print(f"Total: ${total}")
def main():
total = 24
display_total(total)
main()
There are two names spelled total: one in main() and one in display_total(). The argument passes the value between them. Their matching spelling is convenient, but it is not what connects them.
Try It YourselfTry calling
display_total()without passing the value frommain():
- Remove
totalfrom the parameter list ofdisplay_total()and from the call inmain(). Leave theprint()unchanged.- Predict whether the program will print a total, then run it. If an error occurs, look at the name it identifies.
15.1.1 Scope and Indented Blocks
You have also created names inside if statements and loops. Those blocks are indented, just like a function body, but they do not create separate scopes. A name assigned inside an if, while, or for block within a function still belongs to that function call.
This lets a decision or loop prepare a value for the statements that follow it. Here, the if statement chooses a label, and the function returns that label after the branches rejoin:
def length_label(text):
if len(text) >= 8:
label = "Long"
else:
label = "Short"
return label
print(length_label("notebook"))
print(length_label("pen"))
Both branches assign the same local name, label. The return can use label even though it sits outside the if block.
Being in scope does not guarantee that a name has a value. An UnboundLocalError means Python tried to use a local name before that call had assigned it a value. Scope tells you where the name belongs; the path through the code determines whether an assignment has happened.
Try It YourselfSee what happens when only one branch assigns
labelinlength_label():
- Remove the
elseline and its indented assignment, leavingreturn labelin place.- Predict what each of the two calls will do. Run the code and compare the output and any error message with your prediction.
15.2 Global Names and Constants
Local names keep each function’s work separate, but some values are useful to several functions. A fixed discount rate, for example, can sit at the top level of the module so those functions have one named setting to read.
A name assigned at the top level, outside any function, is a global variable. The following function receives a price through its parameter and reads the discount rate from a global name:
DISCOUNT_RATE = 0.10
def discounted_price(price):
discount = price * DISCOUNT_RATE
return price - discount
print(discounted_price(20))
DISCOUNT_RATE has global scope in this module. The parameter price and the calculated discount are local to each call of discounted_price(). A name assigned inside a top-level if statement or loop is global too, because those blocks do not create separate scopes.
A constant is a name the program intends not to reassign. The ALL_CAPS spelling communicates that intention to readers; Python does not enforce it. Constants usually appear after imports and before function definitions, so you can find the program’s fixed settings together.
When discounted_price() runs, Python looks up each name in this order:
| Order | Python looks in | Example |
|---|---|---|
| First | The call’s local names | price and discount in discounted_price() |
| Then | The module’s global names | DISCOUNT_RATE |
| Finally | The built-ins | print |
This lets discounted_price() read DISCOUNT_RATE without receiving it as an argument. It also raises a question: what happens if the function has a local name with the same spelling?
15.3 When One Name Hides Another
Using the same name in different scopes does not make it one shared variable. If a function assigns to a name that also exists globally, Python normally treats that name as local throughout the function. Inside that function, the local name hides the global one. This is called shadowing.
Follow the output before, during, and after display_x() to see whether its assignment changes the global x:
x = 5
def display_x():
x = 9
print(f"Inside display_x: {x}")
print(f"Before display_x: {x}")
display_x()
print(f"After display_x: {x}")
Before display_x: 5
Inside display_x: 9
After display_x: 5
The assignment changes only the local x. The repeated spelling can make this look like an update to one variable, but the function call and the module each have their own x.
Names can hide built-ins too. If you assign a value to print or str, later code in that scope will find your value instead of the built-in function. Choose names that describe your data and leave familiar function names available.
15.4 Keeping Changing Values Local
Shadowing can be especially confusing when you intend to update a value. In display_x(), the assignment supplies a new value directly. An update such as +=, however, needs to read the old value first. Consider this attempt to increase a global task count:
task_count = 2
def add_task():
task_count += 1
return task_count
print(add_task())
Running this program stops with an UnboundLocalError:
UnboundLocalError: cannot access local variable 'task_count' where it is not associated with a value
The += update is an assignment, so Python treats task_count as local throughout add_task(). Adding 1 requires reading that local value first, but the call has not assigned it a starting value. The global value of 2 does not supply the missing local value.
You can avoid this confusion by making the exchange explicit. Let the caller keep the count, pass it to the function, and assign the returned result:
def add_task(task_count):
return task_count + 1
def main():
number_of_tasks = 2
number_of_tasks = add_task(number_of_tasks)
print(number_of_tasks)
main()
Now main() holds the count and decides when to update it. add_task() receives a starting value and returns the new one. Neither function needs access to the other’s local names, and neither name needs to be global.
Try It YourselfCompare the printed count when
main()keeps the returned value and when it discards it:
- Replace
number_of_tasks = add_task(number_of_tasks)with justadd_task(number_of_tasks).- Predict the printed count, then run the program to check your prediction.
- Restore the assignment and run again to compare the two results.
Keeping a name within the part of the program that needs it is called minimizing scope. Here, the count changes in main(), so you know where to look for its updates. A global constant is useful for a fixed setting; a changing count is easier to follow when the code responsible for updating it keeps it locally.
15.5 Summary
Scope determines where Python can use a name. Parameters and names assigned inside a function are local, and each call has its own values for those names. Names assigned at the top level are global to the module. Arguments and return values let functions exchange information while keeping their local names separate.
Selection and loop blocks do not create separate scopes. A name assigned in one of those blocks remains part of the surrounding function or module, but the assignment must happen before you use it. Trying to read an unassigned local name raises UnboundLocalError, even when a global name has the same spelling.
Use ALL_CAPS for constants whose values the program intends not to change. Avoid accidental shadowing of global names or built-ins, and keep changing values local where possible. Passing values in and returning results makes it clear which part of the program is responsible for an update.
15.6 References and Further Reading
16 Errors and Assertions
QuoteWhatever you’re testing, you want it to fail early and you want it to fail spectacularly. Early success is a terrible teacher.
Chris Hadfield, address to research trainees at SickKids (2015).
Functions give you a smaller piece of a program to examine. You can follow an argument into a function, trace its operations, and compare the return value with what you expected. Chapters 14 and 15 explained how values move between calls; that understanding now helps you find mistakes.
Some mistakes stop a program with an error message. Others are quieter: the program runs to completion with a wrong answer, and nothing announces the problem. A loud failure is easier to investigate because its error report points to the failed instruction. A quiet one calls for the testing approach from Chapter 10, which compares actual results with the requirements. Assertions connect the two: they record an expected result as a condition the program checks, so a hidden mistake fails early and spectacularly instead of surviving in silence.
16.1 Understanding Error Reports
The examples follow a shop’s delivery rule: orders below $50 have a $5 fee; orders of $50 or more have free delivery. The function delivery_fee() receives an order total and returns the fee. Totals are non-negative integers representing whole dollars.
That rule establishes the expected result, regardless of how the function is written. A $30 order has a $5 fee, while a $70 order has a $0 fee. First, though, Python needs to execute the code that calculates it.
Each code example can run on its own. Several deliberately raise an error for you to examine. Browser reports may have different file labels and extra traceback entries compared with those shown here.
16.1.1 Syntax Errors
Before Python can run a file, its statements must follow the language’s grammar. A syntax error breaks those rules and prevents the file from executing. We have seen this kind of error already. The delivery-fee function illustrates how a small omission can cause it: the if header is missing the colon that introduces its indented body.
def delivery_fee(order_total):
if order_total >= 50
return 0
return 5
print(delivery_fee(30))
Python reports SyntaxError: expected ':'.
Restoring the colon lets this example print 5. Even with valid syntax, an operation can still fail during execution.
16.1.2 Runtime Errors and Tracebacks
A runtime error occurs during execution. The function below has valid syntax, but main() passes the string "30". The comparison inside delivery_fee() needs a number:
def delivery_fee(order_total):
if order_total >= 50:
return 0
return 5
def main():
fee = delivery_fee("30")
print(fee)
main()
Python raises an exception to signal the failure. Here, a TypeError reports that >= cannot compare a string with an integer. Execution stops before the function returns a fee.
The traceback shows the calls leading to the failure. For delivery.py, the report below omits full paths and expression underlining:
Traceback (most recent call last):
File "delivery.py", line 10, in <module>
main()
File "delivery.py", line 7, in main
fee = delivery_fee("30")
File "delivery.py", line 2, in delivery_fee
if order_total >= 50:
TypeError: '>=' not supported between instances of 'str' and 'int'
Read the traceback from the bottom up. The final line explains the TypeError; above it, line 2 identifies the failed comparison. Line 7 shows the call that passed "30", and line 10 shows the call to main().
Following the calls can reveal a mistake in an argument rather than in the failed operation. Here, the comparison is correct for a numeric total; the problem is the string supplied by main(). Changing "30" to 30 on line 7 lets the function return 5. Check both the failed operation and the values passed to it.
Try It YourselfCan a statement before an error still execute? Compare the syntax-error and runtime-error examples above:
- Add
print("Starting")as the first line of the syntax-error example. Predict whether the message will appear, then run it.- Add the same line at the beginning of the runtime-error example. Predict what will appear, then run it and compare the results.
- What does the difference tell you about when Python detects each error?
16.2 When a Result Is Wrong
Once the code has valid syntax and receives a suitable argument, Python can carry out its instructions, and this time nothing will interrupt it. But executing the instructions is not the same as the instructions being right. Python knows nothing of the shop’s rule; whether the code expresses it is a question the interpreter cannot answer. This version uses > in its comparison:
def delivery_fee(order_total):
if order_total > 50:
return 0
return 5
print(delivery_fee(30))
print(delivery_fee(50))
print(delivery_fee(70))
The output is:
5
5
0
For orders of $30 and $70, the fees agree with the rule, but an order of exactly $50 is charged incorrectly. A semantic error occurs when a program’s instructions produce behaviour that does not meet its requirements. Python performed the comparison correctly: 50 > 50 is false, so the function reaches return 5. The mistake is the choice of comparison. The rule includes $50 in free delivery, so the code needs >=.
This illustrates the connection between testing and debugging from Chapter 10. Testing reveals a mismatch between an expected result and an actual result. Debugging follows the execution that produced it to find the cause. Here, the particular input 50 makes the incorrect return path visible. Correcting the comparison and checking that input again establishes whether the repair addresses the problem.
16.3 Checking Results with Assertions
A later change could bring back the wrong fee at $50, even after you have repaired the comparison. Printing the fee still leaves you to judge it against the shop’s rule. An assertion records your expectation as a condition Python checks.
An assertion uses assert followed by a condition. A true condition lets execution continue silently; a false condition raises AssertionError. In this example, the comparison is repaired and the assertion checks the fee at $50:
def delivery_fee(order_total):
if order_total >= 50:
return 0
return 5
assert delivery_fee(50) == 0
print("Check passed.")
Python calls delivery_fee(50) and compares its return value with 0. The condition is true, so the check passes and execution reaches the final print(). The assertion keeps the input and expected fee together, ready to check again when the code changes.
You can add a message after a comma to explain the expectation when an assertion fails. The following version uses the original > comparison again:
def delivery_fee(order_total):
if order_total > 50:
return 0
return 5
assert delivery_fee(50) == 0, "Delivery should be free at $50"
print("Check passed.")
Because delivery_fee(50) returns 5, the assertion’s condition is false. The assertion’s message appears in the final line of the report:
AssertionError: Delivery should be free at $50
Although the function completed its calculation normally, the returned fee did not meet the expectation. Execution therefore stops at the assertion, before the final print(), and the traceback identifies the failed check.
Use assertions to check expectations about your code during development. Input validation handles unsuitable user values, for example by asking again inside a loop. That response belongs in the program’s ordinary if statements or loops.
Try It YourselfCompare two ways a check can fail. Start with the example above, whose assertion includes a message:
- Run the code as written and keep its error report for comparison.
- Change
>to>=. Predict whether"Check passed."will appear, then run the code to check the repair.- Change the assertion’s call from
delivery_fee(50)todelivery_fee("50"). Predict whether the error report will include the assertion’s message, then run it and compare the two failure reports.
16.4 Choosing Useful Test Cases
Passing the check at $50 tells you about that input alone. A function that always returned 0 would pass it, even though it would charge the wrong fee for smaller orders. A test case pairs an input with its expected result; choosing several cases lets you examine different parts of a function’s behaviour.
For the delivery rule, the important change occurs at $50. A boundary case lies at or beside that point. Checking just below, at, and just above the threshold examines both return paths and where the choice changes. Several large orders would all take the free-delivery path, leaving that transition untested.
Where do the expected results come from? From the shop’s rule, never from the function’s own calculation. A check that copies the function’s comparison repeats whatever mistake it contains, and the incorrect result passes. The example below includes checks at the threshold and just below it. Before running it, predict whether both assertions agree with the rule:
def delivery_fee(order_total):
if order_total >= 50:
return 0
return 5
assert delivery_fee(50) == 0
assert delivery_fee(49) == 0
# Add another assertion here.
print("All checks passed.")
Try It YourselfUse the shop’s rule to repair an expectation and extend the checks in the example above:
- Run the code. Identify the failed check and decide whether the function or its expected result needs changing. Correct it using the shop’s rule.
- Replace the placeholder comment with an assertion for a total just above
$50. Work out the expected fee before running the code. Do all the checks pass?
16.5 From Individual Checks to a Test Suite
Even after a mistake is corrected, you can reuse the delivery-fee checks. A test suite is a collection of tests that you can run together. Rerunning the suite after a change checks both the behaviour you meant to alter and the behaviour that should stay the same.
When assertions run in one sequence, a failure stops execution before any later checks can run. A testing tool such as pytest runs checks in separate test functions and reports a result for each. Under its default settings, a failure ends the current test, while the remaining tests continue. You can therefore see which cases passed alongside those that failed.
That pattern of results can guide an investigation. If the case at $50 fails while those just below and above pass, the comparison at the threshold is a useful place to look. After a repair, rerunning the whole suite checks whether the correction fixed that case and left the others working.
Passing the suite means that the cases examined met their expectations. It does not show that every possible input works, so choosing useful cases remains important even with a testing tool. Writing and organizing your own pytest suites comes later.
16.6 Summary
A syntax error stops a file before it can run. A runtime error interrupts an operation during execution: the exception message explains the failure, and the traceback connects it to the calls that led there. A semantic error is the quiet one; the program runs normally and produces a result that does not meet the requirements.
An assertion records an expectation as a condition, turning a quiet mistake into a loud, early one. Test cases pair inputs with expected results, and those results come from the requirements, never from the function’s own code. Paths, boundaries, and limits help you choose useful cases. Assertions are development checks; required input validation stays in the program’s ordinary logic.
A test suite preserves cases for later changes, and pytest reports each test individually. Testing reveals mismatches; debugging investigates their causes. Both the implementation and the expected result have to agree with the requirements.
16.7 References and Further Reading
- SickKids: Colonel Chris Hadfield Inspires SickKids Trainees to Reach for the Stars, the source of the opening quotation.
- Python Tutorial: Errors and Exceptions, for syntax errors and reading exception reports.
- Python Language Reference: The
assertStatement, for assertion syntax and failure messages. pytest: Get Started, for an introduction to separate tests and failure reports.
17 Lists and Slicing
Quote“Data dominates. If you’ve chosen the right data structures and organized things well, the algorithms will almost always be self-evident.”
Rob Pike, Notes on Programming in C, Rule 5.
As your programs handle more information, you need ways to keep related values together and process them as a group. A list lets you give a collection one name and work with its elements, even as its contents change.
The indexing and loops you know from strings carry over to lists, whose elements can be complete strings, numbers, or other values. You can add, replace, or remove elements, process the collection, and build new lists from selected or transformed values. An operation may change the existing list, return a value, or do both. Keeping those effects distinct helps you follow what your program does. Slicing lets you select the part of a sequence you need.
17.1 Keeping Related Values Together
A collection keeps related values together so you can work with them as a group: len() counts the values it holds, a for loop can visit each one in turn, and you can ask whether a value is inside. One kind of collection is a list, which stores its values in order. Each value in the list is called an element.
Suppose you are planning a walk from the harbour to the museum, then to the library and back to the harbour. You can record those stops with a list literal, a list written directly in your code. Square brackets ([]) enclose the elements, with commas between them:
stops = ["Harbour", "Museum", "Library", "Harbour"]
print(stops)
print(len(stops))
print(stops) displays the list, and len(stops) reports 4 because the route has four stops. The two "Harbour" elements record separate visits. A list keeps the order you give it, including repeated values; it does not automatically sort the names. If your plans change, you can add, remove, or replace stops.
Like a string, a list is a sequence: each element has a position you can select with an index. A for loop visits the elements in order, and each one is a complete place name rather than a single character.
You can check whether the route includes a place with a membership test. It checks for a whole element. "Museum" in stops is True because "Museum" is one of the stops. "Gallery" not in stops is also True because "Gallery" is absent.
[] creates an empty list, which could represent a route with no stops yet.
Closer LookEvery stop above is a string, but a list can hold any type of value, and Python even permits mixing types in one list.
["Harbour", 3, True]is a valid list.
17.2 Accessing and Replacing Elements
The indexing rules you know from strings also apply to lists. Index 0 selects the first element, and index -1 selects the last:
stops = ["Harbour", "Museum", "Library", "Station"]
print(stops[0])
print(stops[-1])
print(stops[len(stops) - 1])
The last two expressions select the same element: len(stops) - 1 is the last position, counted from the front rather than from the end. Each result is a complete string, rather than one of its characters. A list’s length counts elements, not the characters inside them.
An index must identify an existing position. Accessing one past either end raises IndexError, just as it does for a string. An empty list has no valid element index, including 0 and -1.
A list is mutable: operations can change its existing contents. To replace one stop, assign to its indexed position:
stops = ["Harbour", "Museum", "Library", "Harbour"]
stops[1] = "Gallery"
print(stops)
Assigning to stops[1] changes the existing list, replacing "Museum" with "Gallery" while keeping four stops. Assignment has the same rule as reading: the position must already exist, and a list does not grow to make room for a new one. An immutable string, by contrast, lets you read a character by index but not replace it this way.
Try It YourselfIn the replacement example above, change
stops[1]tostops[4]. Predict what happens, then run it and inspect the report. Restore the original index afterward.
17.3 Adding and Removing Elements
A route may gain a new stop without losing an existing one. List methods can add values at the end or insert them between current elements:
stops = ["Harbour", "Museum"]
stops.append("Library")
print(stops)
stops.insert(1, "Gallery")
print(stops)
stops.extend(["Park", "Station"])
print(stops)
Each displayed route has grown. append() adds one element at the end. insert() places an element before the given position, shifting later elements along. extend() adds each item from another iterable, a value that a for loop can visit. Appending a list adds that whole list as one element; extending with it adds its elements separately.
A string is also iterable: a for loop over a string visits its characters one at a time.
Try It YourselfIn the route example above, replace only
stops.extend(["Park", "Station"])withstops.extend("Park"), dropping the square brackets. Predict the final list, then run it. Compare with a version that usesstops.append("Park")in that same position. Which call treats the name as one stop?
17.3.1 Removing by Value or by Position
Choose the removal operation according to what you know. remove() searches for a value and removes its first occurrence. pop() removes an element at a position and returns it; with no argument, it uses the final position:
stops = ["Harbour", "Gallery", "Museum", "Harbour"]
if "Harbour" in stops:
stops.remove("Harbour")
print(stops)
last_stop = stops.pop()
print(last_stop)
print(stops)
The two calls remove different "Harbour" elements because remove() chooses by value and pop() by position. An absent value makes remove() raise ValueError, so the membership check comes first. Calling pop() on an empty list raises IndexError.
17.3.2 Changing a List and Returning a Value
Changing a list and returning a value are separate parts of a method’s behaviour. append(), extend(), insert(), and remove() change the list and return None. You can see both effects by keeping the result under a separate name:
stops = ["Harbour", "Museum"]
result = stops.append("Park")
print(stops)
print(result)
The list now includes "Park", but result is None. Assigning that result back with stops = stops.append("Park") would leave stops referring to None. When you only need the change, write stops.append("Park") as its own statement. By contrast, pop() also provides a useful result: the removed element.
17.4 Processing a List
A for loop can visit complete list elements just as it visited string characters. Use the element directly when the task does not require its index:
stops = ["Harbour", "Gallery", "Museum"]
for stop in stops:
print(f"Next stop: {stop}")
Each pass assigns a complete string to stop, so the loop body can work with one route element at a time. A separate position counter would add no useful information here.
Calling stop.upper() inside this loop would produce a new string without replacing the element in stops. To keep transformed values, you can build a new list, as we will do shortly.
17.4.1 Totals and Extremes
Processing a collection may mean combining its values into one result. Suppose you want to know how busy a bicycle rack gets during the day, so you visit it four times and record how many bicycles are parked there each time. Each count is one observation, and the four observations make up the list:
counts = [6, 9, 4, 7]
print(sum(counts))
print(min(counts))
print(max(counts))
The results are 26, 4, and 9. sum() adds the values; min() and max() select the extremes. min() and max() need at least one element, while sum([]) is 0.
len(counts) counts the observations, and dividing the sum by that count gives the average. That calculation, like finding an extreme, needs a decision about what to do when there are no observations.
17.4.2 Checking for an Empty Collection
Before reporting a largest count, the program needs to know whether there are any observations. An empty list is false in a condition, and a non-empty list is true, so the list itself can provide the check:
counts = []
if counts:
print(f"Largest count: {max(counts)}")
else:
print("No observations recorded.")
For a list, if counts: asks the same emptiness question as if len(counts) > 0:. Here, the check keeps max() from running without an observation. The next chapter examines how values behave in conditions and how one check can protect another operation.
17.4.3 Counting Matches
Sometimes you need a count of only certain observations. As with counting characters in a word, an accumulator starts at 0 and the loop adds one whenever an observation passes the test. This function counts observations that meet a chosen minimum:
def count_busy_observations(counts, minimum):
"""
Count the busy observations recorded at a bicycle rack.
:param counts: a list of non-negative bicycle counts
:param minimum: the smallest count considered busy
:return: the number of busy observations
"""
busy_count = 0
for count in counts:
if count >= minimum:
busy_count += 1
return busy_count
print(count_busy_observations([6, 9, 4, 7], 7))
print(count_busy_observations([], 7))
At a minimum of 7, the observations 9 and 7 qualify, so the first call returns 2. With an empty list, the loop makes no passes and the function returns the initial count of 0. In both cases, it reads the supplied list and returns a number without changing the list’s contents.
17.5 Building a New List
Counting matches gives you one number. When you need the matching values themselves, a list can collect them. Here, busy_counts starts empty and receives each qualifying observation:
counts = [6, 9, 4, 7]
busy_counts = []
for count in counts:
if count >= 7:
busy_counts.append(count)
print(busy_counts)
print(counts)
Selecting elements by a condition is filtering. The condition is a test each element either passes or fails, and the new list keeps only what passes. The loop visits counts from front to back, so the result [9, 7] contains the observations at or above 7, in their original order. Appending to busy_counts leaves counts unchanged, as the second output confirms.
Try It YourselfIn the filtering example, change
>= 7to> 7. Predict which observation will be affected, then check the result. Restore>=and choose one threshold that keeps every observation and another that keeps none. What does each case help you check?
You can check a filtered result with ==. Two lists compare equal with == when they contain equal elements in the same order. For example, [7, 9] == [9, 7] is False, even though both contain the same numbers.
You can also transform values instead of selecting them. If each bicycle count needs a correction of one, the new list can hold one adjusted value for every observation:
counts = [6, 9, 4, 7]
corrected = []
for count in counts:
corrected.append(count + 1)
print(corrected)
The result is [7, 10, 5, 8]. This loop appends on every pass, so the new list has the same length as counts. Filtering can produce fewer elements; this transformation keeps one result per source element.
Both examples read one list and append to another. Adding or removing elements from the list you are currently traversing can shift positions and cause the loop to skip or revisit items. A separate result keeps the traversal stable while you select or transform values.
17.6 Sorting and Finding Values
The loops above leave counts in its original order. When you want to change a list’s order instead, sort() rearranges its elements into ascending order in place:
counts = [6, 9, 4, 7]
counts.sort()
print(counts)
The output is [4, 6, 7, 9]: the sorted values are in counts itself. Like append(), sort() changes the list and returns None, so write the call as its own statement. After sorting, counts records numerical order rather than the order of observations, so consider whether your program still needs the original sequence.
Sorting requires values that Python can compare. A list of integers or a list of strings works; mixing integers and strings raises TypeError, even though Python allows both types in the same list.
Other methods search or change a list’s contents. Treat each row below independently, with values starting as [6, 9, 6, 7] each time:
| Operation | Purpose | Result or Change |
|---|---|---|
values.count(6) |
Count occurrences of a value | Returns 2 |
values.index(9) |
Find the first matching position | Returns 1 |
values.reverse() |
Reverse the current order | Changes the list to [7, 6, 9, 6]; returns None |
values.clear() |
Remove all elements | Changes the list to []; returns None |
index() raises ValueError when the value is absent. Use membership when presence is all you need. Reversing is not the same as sorting into descending order: it flips whatever order the list currently has.
17.7 Taking a Slice
Indexing selects one element at a position. A slice selects a portion of a sequence. In sequence[start:stop], selection begins at start and ends before stop:
stops = ["Harbour", "Gallery", "Museum", "Park", "Station"]
middle = stops[1:4]
print(middle)
The result is ['Gallery', 'Museum', 'Park']. The highlighted positions show why stops[1:4] selects three elements:
Index 4 marks the first excluded element. This makes neighbouring slices fit together: stops[:2] ends before index 2, and stops[2:] begins there. Together they cover the route without gaps or overlap.
Omitting a bound uses the corresponding end of the sequence. Negative indexes count from the end, just as they do when selecting one element:
| Slice | Selected Elements |
|---|---|
stops[:2] |
The first two |
stops[2:] |
Everything from index 2 onward |
stops[-2:] |
The final two |
stops[:] |
All elements |
stops[2:2] |
No elements ([]) |
Unlike a single out-of-range index, slice bounds beyond the ends are adjusted to the available sequence. With this route, stops[:20] includes all five elements. That is useful when asking for up to a certain number of items, but it can also hide an incorrect bound if you never inspect the result.
The optional third part, sequence[start:stop:step], supplies a step between selected positions. A positive step moves forward; a negative step moves backward:
stops = ["Harbour", "Gallery", "Museum", "Park", "Station"]
print(stops[::2])
print(stops[::-1])
print("Vancouver"[::2])
A step of 2 selects every second element: the stops at indexes 0, 2, and 4. A negative step selects backward, so with -1 the whole route comes out in reverse order. Strings are sequences too, so "Vancouver"[::2] works the same way. When the step is negative, omitted bounds adapt to the backward direction, so stops[::-1] alone is enough. A step of zero is invalid and raises ValueError.
In this example, change stops[::2] to stops[1::2]. Predict the selected stops before running. What changes when the step stays the same but its starting position changes?
17.7.1 A List Slice Creates a New List
Slicing does not shorten or reorder the source list. It produces a new list containing the selected elements:
stops = ["Harbour", "Gallery", "Museum", "Park"]
short_route = stops[:2]
short_route[0] = "Station"
print(short_route)
print(stops)
Replacing the first element of short_route affects the new list, while stops still begins with "Harbour". A full slice, stops[:], likewise creates a new list containing all the source elements.
Try It YourselfIn the
short_routeexample, replacestops[:2]withstops[1:3]. Predict both output lines. Which source elements are selected, and which one does index0identify in the new list? Run the complete example with that one change.
17.7.2 Copying a List Before Changing It
sort(), reverse(), and clear() change the whole list in one call. After sort() or reverse(), the earlier order is lost, and clear() empties the list too. When your program still needs the list as it was, take a full slice first and change the copy:
counts = [6, 9, 6, 7]
backup = counts[:]
backup.reverse()
print(counts)
print(backup)
The reversal changed only the copy: backup now holds [7, 6, 9, 6], and counts still holds the observations in the order they were recorded. Writing backup = counts instead would not copy anything: both names would refer to the same list, so backup.reverse() would flip counts too. Because [:] selects every element and a slice always builds a new list, a full slice is a copy.
17.8 Moving Between Text and Lists
Slicing selects text by position, but often the useful boundaries are spaces or other separators. With no argument, split() separates a string on runs of whitespace and returns a list of strings. join() combines string elements with a chosen separator:
request = " sketch paint fold "
words = request.split()
print(words)
print("/".join(words))
split() drops the whitespace at each end and treats repeated spaces as one separator, so words is ['sketch', 'paint', 'fold']. In "/".join(words), the string before the dot supplies the separator and the argument supplies the elements to combine.
Try It YourselfIn the request example above, change
request.split()torequest.split(" "). Predict both output lines before running. How does supplying a single-space separator affect repeated, leading, and trailing spaces?
17.9 Summary
Lists keep values in order, preserve duplicates, and let you change their contents. Indexing reads or replaces an element; methods add, remove, or rearrange elements. A method’s effect on the list and its returned value are separate, so consider both when using a call’s result.
Membership checks whether a complete element is present. Loops can combine list values into one result or build a new list by filtering and transforming them. An emptiness check protects operations that need at least one element. Slices select parts of a sequence using an exclusive stop and an optional step; slicing a list creates a new list, which makes a full slice a quick copy before a whole-list change. split() and join() connect lists with text processing.
17.10 References and Further Reading
- Python Tutorial: More on Lists, for list methods and their return behaviour.
- Python: Membership test operations, for checking whether an element is present.
- Python: Common sequence operations, for indexing and slicing.
- Python: String methods, including splitting and joining.
18 Boolean Logic in Depth
Quote“Take care of the sense, and the sounds will take care of themselves.”
Lewis Carroll, Alice’s Adventures in Wonderland, Chapter IX.
Working with lists gave you practical reasons to check before acting. A stop must be present before you remove it, and observations must be available before you find their maximum. Sometimes a decision needs several checks together: does a route have a first stop, and is that stop the harbour? You already know how to combine conditions with and, or, and not. Understanding how Python groups and evaluates those conditions lets you express the decision clearly and keep its operations safe.
A compound condition does more than produce an answer. An earlier check can make a later one unnecessary, so Python may skip an indexing operation or an entire function call. The order of those checks therefore matters. Choosing the question matters too: in asks about membership, which means a substring in a string but a complete element in a list, == compares complete values, and numbers that come from a calculation often need a tolerance rather than exact equality. In every case, the difference shows up in what a running program does.
18.1 Reading a Compound Condition
Suppose a community garden opens its greenhouse when a volunteer is present and either the weather is dry or the roof is closed. Naming each fact makes the rule easier to express. A Boolean expression produces a value like any other, so the whole condition is calculated and stored in can_open, rather than appearing as the test of an if:
volunteer_present = True
weather_dry = False
roof_closed = True
can_open = volunteer_present and (weather_dry or roof_closed)
print(can_open)
Either alternative is enough, but a volunteer must still be present. Parentheses keep those alternatives together, so and combines the volunteer requirement with their result.
Compound conditions follow the same precedence rules you met for arithmetic expressions. A comparison is settled before not, and, or or applies. Among the Boolean operators, the order remains not, then and, then or. Without its parentheses, the greenhouse condition means (volunteer_present and weather_dry) or roof_closed.
Try It YourselfDelete the parentheses from the
can_openline and run the code again with the same values: the answer does not change. Now choose values for the three names that make the two versions disagree. Predict each result before you run it. Which greenhouse requirement does the version without parentheses fail to preserve?
Negation also depends on grouping. In not (weather_dry or roof_closed), Python answers the parenthesized question first and not flips that answer, so it is true only when the weather is not dry and the roof is not closed. Without the parentheses, not applies only to weather_dry, giving (not weather_dry) or roof_closed, true when the weather is not dry or the roof is closed.
18.2 Using Values in Conditions
The last chapter used if counts: to keep max() from running on an empty list, without comparing the list with True. Python’s truth value testing lets a condition use any value directly. An empty list is false, and so are numeric zero, False, and None. A non-empty value is true.
Strings follow the same rule: what matters is whether there is any content, not what the content says. A note left at a bicycle rack shows the difference:
note = ""
if note:
print(f"Note: {note}")
else:
print("No note was left.")
Try It YourselfChange
""to"0". Predict the complete output, then run it. What is the difference between a note containing a zero and no note at all?
A check like this one often sits inside a compound condition, and there Python may not need to examine every part of it.
18.3 When Python Can Stop Evaluating
Grouping tells you which conditions belong together. Evaluation determines which parts Python actually needs to examine. Python evaluates the left operand of and or or first and tests its truth value. A false value lets and stop; a true value lets or stop. In either case, Python skips the right operand. This behaviour is short-circuit evaluation.
| Expression | Left Operand | Is the Right Operand Evaluated? |
|---|---|---|
left and right |
False |
No |
left and right |
True |
Yes |
left or right |
False |
Yes |
left or right |
True |
No |
Skipping an operand means skipping the operation it describes. A function call does not run, and an indexing operation does not attempt to select an element. This can change the program’s output, or make an error appear or disappear.
Watch Your StepA call on the right of
andorormay never execute. If an action must happen every time, give it its own statement instead of depending on its position in a condition.
18.3.1 Protecting an Operation
An empty route has no stop at index 0, and the last chapter showed that indexing an empty list raises an IndexError. The comparison is only safe when a stop exists, so check for a stop first. Short-circuit evaluation lets you put both checks inside one expression:
stops = []
starts_at_harbour = len(stops) > 0 and stops[0] == "Harbour"
print(starts_at_harbour)
With an empty route, len(stops) > 0 is false, so Python skips the indexing and assigns False. With a non-empty route, the check is true and Python compares stops[0] with "Harbour". A first stop makes the comparison possible, but that stop does not have to be the harbour.
The length comparison acts as a guard, a check that must pass before the operation it protects can run.
Try It YourselfIn the route example above, reverse the operands with
stopsstill empty, and predict what happens before running, then read the error report. Next, try a non-empty route: why would testing only that route hide the problem? Restore the guard to the left afterward.
The same pattern protects max(), with the list itself as the guard:
counts = []
print(counts and max(counts) >= 8)
counts = [10, 7, 9]
print(counts and max(counts) >= 8)
and and or return one of their operands rather than always a Boolean, so the first answer is [] itself. Since [] is false, the expression still works in a condition. The route example printed False rather than [] because its guard was the comparison len(stops) > 0, which evaluates to False.
18.3.2 Observing a Skipped Call
A predicate function answers a yes-or-no question by returning True or False. A condition can ask its question by calling one. Short-circuit evaluation can skip that call just as it skipped the indexing in the route example. When it does, none of the function’s statements run. A temporary print() inside the function makes the skip visible. Here, a visitor may enter with a pass or a reservation:
def has_reservation(name):
print("Checking the reservation")
return name == "Noor"
has_pass = True
can_enter = has_pass or has_reservation("Noor")
print(can_enter)
Only one line appears: True. Checking the reservation never prints, because has_pass is True and or already has its answer. The final print() reports the answer to the whole condition.
The skip is not a trick to avoid errors. It is a way to save work. Checking has_pass costs one lookup, so that check goes first, and the reservation call runs only when the condition still needs its answer. You can use a temporary print() the same way whenever a condition leaves you unsure whether a call runs. If the message never appears, the call was skipped, not broken.
Try It YourselfChange only
has_passtoFalse. Predict both lines the example will display, then run it. Next, keephas_passfalse and change the argument to"Eli". Predict before each run. Which change affects whether the function runs, and which affects the answer it returns?
18.4 Asking About Membership
You used in to check for a stop before removing it from a list. A membership test returns a Boolean answer: in tests for presence and not in tests for absence. What counts as a match depends on the collection:
print("tea" in ["tea", "juice", "water"])
print("tea" in ["teapot", "juice", "water"])
print("tea" in "teapot")
The results are True, False, and True. The first list contains the complete element "tea". The second has "teapot" instead, so its element test fails. Searching within the string "teapot" finds the consecutive substring "tea". List membership does not search inside each element.
Use == for equality between complete values and find() for a substring’s position or -1 for absence. All of these searches are case-sensitive. Searching for "Tea" would find nothing. When case should not matter, normalize the text first with .lower().
18.5 Comparing Approximate Numbers
Choosing the right comparison also matters for calculated numbers. You might expect 0.1 + 0.2 and 0.3 to compare equal:
total = 0.1 + 0.2
print(total)
print(total == 0.3)
Python prints 0.30000000000000004, and the comparison answers False.
A floating-point number has limited precision: many decimal fractions have no exact finite binary representation, just as has no finite decimal representation, only the endless . Python stores a nearby representable value instead, and the difference from the intended value is representation error. The sum must fit the float format too, so Python rounds it to the nearest representable value. That second rounding is why the printed total carries extra digits.
Exact equality is a valid question, but for approximate calculations the useful one is whether the difference is small enough for the purpose.
18.5.1 Choosing a Tolerance
“Small enough for the purpose” needs a number. That number is a tolerance, the largest difference you will accept. Suppose a measured length must be within 0.01 metres of a two-metre target.
The math module provides math.isclose(), a predicate that reports whether two numbers are close enough:
import math
target = 2.0
measured = 2.006
print(math.isclose(measured, target, rel_tol=0.0, abs_tol=0.01))
The result is True: 2.006 is about 0.006 metres from 2.0, inside the tolerance. In the call, abs_tol=0.01 hands the function your tolerance. The rel_tol=0.0 argument switches off a second rule that allows a difference to grow with the size of the numbers. Your fixed tolerance is then the only test: an absolute tolerance, the same permitted difference whatever the target’s size.
Closer LookThe
=inabs_tol=0.01does not assign a variable. It passes a value to a named argument: the name before the=must match a parameter the function defines. The name does the matching instead of the position, so two named arguments can appear in either order.
18.6 Rounding a Value and Formatting Its Display
The failed comparison in the last section has a tempting repair: round both sides first.
print(round(0.1 + 0.2, 2) == 0.3)
This prints True: the numbers are so close that rounding each side to two decimal places produces the same result. A measured 2.008 metres is within the 0.01 metre tolerance of 2.0, but round(2.008, 2) is 2.01, so the rounded comparison rejects a measurement the requirement accepts. A tolerance states how close is close enough. Rounding to a number of digits cannot express it.
Rounding has other, real uses. round() returns a number with fewer digits: round(reading, 2) rounds to two decimal places, though the result may display fewer. The :.2f format produces text for display with exactly two digits after the decimal point:
reading = 1.204
rounded = round(reading, 2)
display = f"{reading:.2f}"
print(rounded, type(rounded))
print(display, type(display))
print(reading)
rounded prints as 1.2 and display as "1.20", but the difference is not the zero. Their types differ: rounded is a float you can use in another calculation, and display is a string meant for a reader. Neither operation changes reading, which still prints 1.204. As numbers, 1.20 and 1.2 are identical, so the zero is purely for presentation.
Rounding does not remove representation error. The rounded 1.2 is itself a decimal fraction with no exact binary representation, so it too is stored as a nearby value. Use a tolerance to express acceptable differences, rounding when a calculation needs fewer digits, and formatting when the reader needs a particular display.
18.7 Summary
Parentheses make the grouping of a compound condition clear. An empty list or string is false in a condition, and so are numeric zero, False, and None. A non-empty value is true. With and and or, short-circuit evaluation lets an earlier operand determine whether a later one is evaluated. Put each guard before the operation it protects, and remember that a skipped function call does not run at all.
Membership asks whether a substring or element is present. Equality compares complete values. Many decimal fractions have no exact binary representation, so == can report False for numbers that look equal. With approximate numbers, choose a tolerance from the requirement and compare with math.isclose(). Rounding produces a numeric result, while formatting produces text for display. Neither removes the representation error.
18.8 References and Further Reading
- Python: Truth value testing, for values used directly in conditions.
- Python: Boolean operations, including short-circuit behaviour and returned operands.
- Python: Membership test operations, for strings and collections.
- Python: Floating-point arithmetic, for representation error and its consequences.
- Python:
math.isclose(), for relative and absolute tolerances. - Python:
round(), for rounding rules.
19 Ranges, Tuples, and Sets
Quote“We shape our tools and thereafter they shape us.”
John M. Culkin, A Schoolman’s Guide to Marshall McLuhan, 1967.
Lists let you keep related values in order, preserve repeated items, and change a collection as your program runs. You can process those values with loops and use conditions to check whether an operation is appropriate. Those tools suit a changing route or a series of observations, but other problems call for a different kind of collection. A timer may mark every fifth minute, and a colour may hold its red, green, and blue values together.
Ranges, tuples, and sets express those needs in different ways. A range describes an integer progression, a tuple keeps values in fixed positions, and a set keeps distinct members without positional order. Loops and membership checks still work with all three, but each preserves different information and allows different changes. To choose among them, ask what the values mean and what your program will do with them.
19.1 Describing a Sequence with range()
Counting with a while loop requires both a condition and an update to the count. When you know the integer pattern in advance, a range can supply the values. It is an immutable sequence of integers described by a start, a stop value it does not include, and a step.
Compare these two ways to display the numbers from one through five:
count = 1
while count <= 5:
print(count)
count += 1
for count in range(1, 6):
print(count)
Both loops print 1 through 5. In the for loop, Python assigns each range value to count, so the body needs no separate update. The stop is 6 because, like a slice, a range excludes its stop value.
A counting pattern fits for with range(). A condition such as waiting for valid input fits while, because the number of passes depends on what happens during execution. The choice follows how the program knows whether more work remains.
19.1.1 Start, Stop, and Step
You can supply just a stop, both bounds, or both bounds and a step:
| Form | Meaning | Example Values |
|---|---|---|
range(stop) |
Start at 0, advance by 1 |
range(4): 0, 1, 2, 3 |
range(start, stop) |
Use the supplied start, advance by 1 |
range(2, 6): 2, 3, 4, 5 |
range(start, stop, step) |
Advance by the supplied step | range(2, 9, 3): 2, 5, 8 |
For example, a timer can mark every fifth minute, including minute fifteen:
for minute in range(0, 16, 5):
print(f"Minute {minute}")
Here, minute 15 is included because it falls before the stop of 16. The stop need not be a value in the progression. range(0, 20, 5) supplies the same markers because the next value after 15 would be 20, which the range excludes. A stop of 15 leaves out the final one.
A negative step counts downward:
for remaining in range(8, 0, -2):
print(remaining)
The values are 8, 6, 4, and 2. Zero is excluded by the stop; including it would require a stop below zero. The step supplies the direction, so writing the larger bound first does not by itself make a range count downward.
Calling list() on a range creates a list of its values, making the progression easy to inspect in the REPL:
>>> list(range(8, 0, -2))
[8, 6, 4, 2]
Try It YourselfPredict the values from
range(3, 3),range(3, 0), andrange(3, 0, -1), then uselist()in the REPL to check. Which ranges are empty, and how do the start, stop, and direction explain the results?
An empty range is valid and gives a for loop no values to visit. A zero step raises ValueError. The arguments must be integers, so range(0, 1, 0.1) raises TypeError.
19.1.2 Working with a Range
A range describes a progression without storing every integer as a separate element. That is why displaying the range shows its description, while displaying a list made from it shows all the values:
markers = range(0, 16, 5)
print(markers)
print(list(markers))
print(len(markers))
print(markers[-1])
print(10 in markers)
The first two outputs are range(0, 16, 5) and [0, 5, 10, 15]. The remaining results, 4, 15, and True, show that a range supports length, indexing, and membership. Its values are fixed by the progression; append() and indexed assignment are unavailable.
A for loop can use the range directly. Converting it to a list is useful when you need to inspect all its values or create a collection you can change. For an existing list or string, you can usually visit its elements directly, as you did when processing lists.
19.2 Tuples for Values with Fixed Roles
A list often holds a changing number of similar items. Other groups have a fixed shape, with an agreed meaning for each position. A tuple is an immutable sequence, making it useful when those positions should stay fixed.
For example, an RGB colour has red, green, and blue components in that order:
colour = (32, 96, 160)
print(colour[0])
print(len(colour))
red, green, blue = colour
print(f"Red: {red}; green: {green}; blue: {blue}")
In red, green, blue = colour, unpacking assigns each element to its corresponding name. The first component becomes red, the second green, and the third blue. Those names express the roles more clearly than repeated indexes.
This form of unpacking requires the number of names to match the number of elements; two names here would raise ValueError. Unpacking works with lists too. Choosing a tuple for this colour is a design decision: it prevents the three component positions from being edited in place.
19.2.1 Immutability and Reassignment
Tuples support len(), indexing, membership, and slicing. Reading colour[0] is allowed, but assigning colour[0] = 64 raises TypeError. A tuple also has no append() method.
As with immutable strings, the name can still be assigned a new value:
colour = (32, 96, 160)
print(colour)
colour = (64, 96, 160)
print(colour)
The second assignment makes colour refer to a new tuple without modifying the first tuple’s contents.
19.2.2 Commas and Tuple Literals
The colour has three elements because that fits its purpose; Python permits tuples of other lengths too. Commas form a non-empty tuple, while parentheses make its boundaries clear. A single-element tuple therefore needs a trailing comma:
one_component = (32,)
one_number = (32)
print(type(one_component))
print(type(one_number))
The trailing comma makes (32,) a tuple. Without a comma, the parentheses only group the value, so (32) is just the integer 32. To create an empty tuple, write ().
19.2.3 Returning Related Results
A function returns one value, but that value can hold several related results. Here, a tuple keeps the smallest and largest readings together:
def measurement_limits(readings):
"""
Find the smallest and largest readings.
:param readings: a non-empty list of numeric readings
:return: a tuple containing the minimum and maximum
"""
return (min(readings), max(readings))
lowest, highest = measurement_limits([14, 11, 18, 13])
print(f"Lowest: {lowest}; highest: {highest}")
The caller receives (11, 18) and unpacks it into lowest and highest, producing Lowest: 11; highest: 18. The tuple’s order gives those values their meaning. Returning the minimum first is part of the function’s agreement with its caller, so the docstring states that order.
Try It YourselfIn the measurement example, reverse the two expressions inside the returned tuple, leaving the caller unchanged. Predict the output, then run it. Compare the original and reversed versions with a list containing just one reading. Which input reveals the wrong return order, and why? Restore the original return order afterward.
19.3 Sets for Unique Values
Lists and tuples preserve positions and repeated values. An inventory that needs only the kinds of plants available does not need either piece of information. A set is an unordered collection of distinct values. It can gather the plant names from several seed packets:
packets = ["basil", "mint", "basil", "thyme", "mint"]
plants = set(packets)
print(len(packets))
print(len(plants))
print("thyme" in plants)
The results are 5, 3, and True: five packets supply three distinct plant names, including thyme. Keeping each distinct value once is deduplication. Creating plants leaves the packets list unchanged, but the new set omits its repetitions and positions. From the set alone, you cannot recover how many packets of each plant you own.
Without positions, a set has no element at index 0 and cannot be sliced. You can iterate over its members, but your program should not depend on their display or iteration order. A set literal uses curly braces, as in {"basil", "mint", "thyme"}. To create an empty set, use set(). Writing {} creates an empty dictionary rather than a set.
Set members must be immutable. Numbers, strings, and tuples can be members, but a list is mutable, so {[1, 2]} raises TypeError.
19.3.1 Adding and Removing Members
A set can gain or lose members after creation. add() inserts a member if it is absent; adding an existing member leaves the set unchanged:
plants = {"basil", "mint"}
plants.add("thyme")
plants.add("mint")
plants.remove("basil")
print(len(plants))
print("basil" in plants)
Only "mint" and "thyme" remain, so the output is 2 and False. Like the list methods you have used, these calls change the collection in place and return None.
For a set, remove() raises KeyError when the member is absent. discard() leaves the set unchanged in that case. The choice expresses an expectation: remove() makes a missing member visible as an error, while discard() accepts that it may already be absent. clear() removes every member.
Try It YourselfIn the example above, change the removal to
plants.remove("sage"). Predict what happens, then run it and inspect the report. Replace onlyremovewithdiscardand predict the two output lines before running again. What does each version assume about whether the member must be present?
A set uses the values Python receives, so "Mint" and "mint" remain different strings. If those spellings should describe the same plant, apply lower() to the names before creating the set.
19.3.2 Combining Sets with Union
Suppose a second garden brings its own plant names. The union of two sets holds every distinct value from both:
window = {"basil", "sage"}
garden = {"mint", "thyme", "basil"}
combined = window | garden
print(len(combined))
print("thyme" in combined)
print("thyme" in window)
The union holds four names: basil, mint, sage, and thyme. basil grows in both gardens but counts once. The second line prints True because garden contributes thyme to the union. The third prints False because | builds a new set instead of changing window.
Calling window.union(garden) gives the same result as window | garden. The two forms differ when one input is a list: union() accepts a list or tuple, but | requires a set on each side. So window.union(["rosemary"]) returns a new set that includes rosemary, while window | ["rosemary"] raises TypeError.
Try It YourselfChange
gardento{"mint", "thyme"}and predictlen(window) + len(garden)andlen(combined). Run the code, then restore"basil"and compare the counts again. Which version makes the union smaller than the total, and which member explains why?
19.4 Choosing a Representation
There is rarely one right collection for a group of values. Counting, membership, and a for loop work with every collection you have met, so the same values could be represented by several of them. A program can keep multiple collections when it needs to answer different questions: the seed example asks how many packets there are and which distinct plants they contain, so it keeps both the list and the set.
When you need only one, choose the collection whose meaning is clearest and whose operations fit the task:
| Requirement | Representation | What it adds |
|---|---|---|
| Keep an editable route | List | Ordered values that can change |
| Count in regular steps | Range | A described progression, not stored values |
| Pair a minimum and maximum | Tuple | Fixed positions that cannot change |
| Keep distinct plant names | Set | Each value held once, with no positions |
19.5 Summary
Ranges describe integer progressions using a start, exclusive stop, and non-zero step. A for loop can visit their values directly, and the stop follows the same exclusion rule as slicing.
Tuples keep elements in fixed positions and let you unpack related values into meaningful names. A function can return one tuple containing several results, with their order forming part of its agreement with the caller. Sets keep distinct members without positional order, supporting membership checks, deduplication, and union. Every collection you have met can be counted with len(), checked with in, and visited one value at a time with a for loop. A sequence is a collection whose values also have positions. Choose a representation according to the information and operations your program needs.
19.6 References and Further Reading
- Python: Ranges, for progression rules and supported sequence operations.
- Python Tutorial: Tuples and Sequences, for construction and unpacking.
- Python: Set Types, for membership, mutation, and union.
20 Dictionaries
Quote“What’s in a name? That which we call a rose By any other name would smell as sweet;”
William Shakespeare, Romeo and Juliet, Act 2, Scene 2.
Every collection you have met so far can be counted, checked for membership, and visited one value at a time. A dictionary answers a question none of them can: what value goes with this name? It stores each value under a key you choose, and the key retrieves the value.
For a seed order, the plant name "kale" is a key and the packet count 12 its value. Each entry keeps a plant name connected to the right count, and a question such as “How many packets of kale are on order?” becomes a direct lookup.
In this chapter, you will create dictionaries, look up and change entries, choose valid keys, and loop over keys and values, then count how often each plant appears in a planting log.
20.1 From Positions to Keys
Each entry in a dictionary is a key-value pair: a key that identifies the entry and the value it holds. A dictionary literal encloses the pairs in curly braces, writing each pair as key: value with commas between pairs:
packets = {"kale": 12, "basil": 7, "peas": 4}
print(packets)
print(len(packets))
The display shows each pair joined by a colon, and len(packets) reports 3 because the dictionary holds three pairs. The key does the work a position did for a list: kale’s count is stored under "kale", so recovering it does not depend on remembering an arrangement.
{} is the empty dictionary, a dictionary with no pairs. Like the other empty collections you have met, it is false in a condition.
20.2 Accessing Values by Key
Reading an entry uses its key inside square brackets, in the same syntax you used for indexes:
packets = {"kale": 12, "basil": 7, "peas": 4}
print(packets["basil"])
packets["basil"] selects the value 7. Keys are not positions and do not need to be numbers, so any valid key can go between the brackets. A later section describes what makes a key valid.
Every key you read must exist in the dictionary. If you ask for a plant the order does not list, such as packets["mint"], Python raises KeyError and reports KeyError: 'mint'. This KeyError is the dictionary’s version of IndexError, which a list raises for a position past either end. The message names the key Python could not find. Reading that name is often enough to spot a misspelled key or a plant that was never added.
Before accessing a key that might be absent, test for it. Membership in a dictionary checks the keys:
packets = {"kale": 12, "basil": 7, "peas": 4}
if "mint" in packets:
print(f"Mint: {packets['mint']} packets")
else:
print("No mint this year.")
The condition "mint" in packets is false, so the else branch runs. Membership does not search the values: 12 in packets is False because 12 is a value, not a key. To ask whether any pair holds a given value, search the values with packets.values(), which you will meet shortly.
When you want a missing key to produce a value instead of an exception, get() returns the paired value, or a default you choose when the key is absent:
packets = {"kale": 12, "basil": 7, "peas": 4}
print(packets.get("mint"))
print(packets.get("mint", 0))
With no default, get() returns None. With 0, the same lookup returns 0 instead. Either way the dictionary is unchanged, because get() only reads and "mint" never becomes a key. A 0 default makes a missing plant and a plant stored with a count of zero look the same. If your program must tell them apart, leave the default out.
The two tools cover different cases: use in when a missing key needs its own handling, and get() when a sensible default can stand in.
Try It YourselfIn the first example of this section, change
packets["basil"]topackets[12]. Predict the result, then run the example and read the report. Which key does the message report? The number12appears in the dictionary, so why does it fail as a key? Restore the original key afterward.
20.3 Changing a Dictionary
A dictionary is mutable, like a list: its contents can change after creation. Assignment to a key either adds a pair or replaces a value, depending on whether the key is already present:
packets = {"kale": 12, "basil": 7, "peas": 4}
packets["chard"] = 9
print(packets)
packets["basil"] = 5
print(packets)
The first assignment adds the pair "chard": 9, growing the dictionary to four pairs. The second finds "basil" already present and replaces its value with 5, leaving the pair count at four. Both assignments share the same syntax, and the key’s presence determines whether a pair is added or a value is replaced.
Two forms remove a pair. del removes by key, and pop() removes and returns the value, just as it removed and returned a list element by position:
packets = {"kale": 12, "basil": 7, "peas": 4}
del packets["peas"]
print(packets)
print(packets.pop("basil"))
print(packets)
After del, the display no longer shows the peas pair. pop("basil") removes the basil pair and hands back 7, which you can store in a variable. Removal needs an existing key, just as reading does: del packets["mint"] raises the same KeyError. When a missing key should not stop the program, pop() also accepts a default: packets.pop("mint", None) returns None and changes nothing.
20.4 What Can Be a Key
Each key appears at most once. A literal that repeats a key keeps only the last value:
packets = {"kale": 12, "kale": 9}
print(packets)
The display shows {'kale': 9}. An assignment to an existing key has the same effect, as you saw in the previous section: it replaces the value rather than creating a second pair.
Each key must be something that cannot change after creation. Strings, numbers, and tuples all work, and a tuple of coordinates is a natural key for a planting grid, where each bed is identified by its row and column:
planted = {(0, 2): "kale", (1, 0): "basil", (1, 3): "peas"}
print(planted[(1, 0)])
planted[(0, 2)] = "chard"
print(planted)
Bed (1, 0) holds basil, and replanting bed (0, 2) replaces its value with "chard". A tuple works as a key because its contents stay fixed.
Because a list can change after creation, Python refuses it as a key and raises TypeError.
Values have no such restriction and can be any type, including a list or another dictionary. Combining dictionaries with the collections you already know is the subject of the next chapter.
20.5 Iterating over Keys, Values, and Pairs
A for loop over a dictionary visits its keys:
packets = {"kale": 12, "basil": 7, "peas": 4}
for plant in packets:
print(plant)
Each pass assigns a key to plant, and the keys arrive in insertion order, the order they were first added. Replacing a value does not move a key that is already there. If you are interested in the keys themselves, packets.keys() supplies them, so for plant in packets.keys(): visits the same keys. The shorter loop above is the usual choice.
When only the values matter, values() supplies them, and the keys stay out of the way:
packets = {"kale": 12, "basil": 7, "peas": 4}
print(sum(packets.values()))
The total 23 needs no plant names. packets.values() also answers the earlier value-search question: 12 in packets.values() is True.
To walk pairs together, items() supplies each pair as a tuple containing the key and the value, and unpacking assigns both to names in the loop header:
packets = {"kale": 12, "basil": 7, "peas": 4}
for plant, count in packets.items():
print(f"{plant}: {count} packets")
Each line pairs a plant with its count. The names in the header follow the pair’s order: the key arrives first, the value second.
Try It YourselfIn the loop above, swap the loop variables to
for count, plant in packets.items():and leave theprint()line unchanged. Predict every line of output before running it. Which name received the key, and where did the key end up in the display? Restore the original order afterward.
20.6 Counting Occurrences into a Dictionary
The loops you wrote over lists counted matching observations into a single number. A planting log needs one tally per plant, all growing together as the loop passes. The function below holds every tally in a dictionary and counts any log you pass it:
def count_plantings(log):
"""
Count how many times each plant appears in a planting log.
:param log: a list of plant names
:return: a dictionary holding each plant and its count
"""
tallies = {}
for plant in log:
tallies[plant] = tallies.get(plant, 0) + 1
return tallies
print(count_plantings(["kale", "basil", "kale", "peas", "kale", "basil"]))
print(count_plantings([]))
Each pass reads the plant’s tally so far, 0 on a first visit, and stores it one higher. Plants absent from the log never get a pair, and those that do appear are listed in first-visit order.
Try It YourselfIn
count_plantings, change the default intallies.get(plant, 0)from0to1. Predict both lines of output before running, then run the example and check. Restore the default afterward.
The second call returns the initial {}: with an empty log, the loop makes no passes. Because an empty dictionary is false in a condition, a report that needs at least one entry can guard itself with if tallies:.
Python also has a compact one-line form that builds collections from a loop. You will meet it later in the term. Until then, the loop you can read pass by pass is the dependable tool.
20.7 Summary
A dictionary stores key-value pairs and retrieves each value by its key rather than by position. Reading a missing key raises KeyError. Membership checks whether a key exists, and get() returns a default instead of an exception. Assignment adds a new pair or replaces an existing value, and del and pop() remove pairs.
Keys are unique, and a key must be something that cannot change: strings, numbers, and tuples work, and lists do not. Values can be any type. A loop visits keys in insertion order. values() supplies the values, and items() supplies whole pairs for unpacking. A dictionary can count how often each item appears. Each pass reads the item’s count so far with get() and stores it one higher.
20.8 References and Further Reading
- Python Tutorial: Dictionaries, for a first tour of dictionaries and their methods.
- Python: Mapping Types, for the full set of dictionary operations, including
get()anditems(). - Python: KeyError, for the exception raised by missing keys.
21 Nested Data and Collection Choice
QuoteTo see a World in a Grain of Sand
And a Heaven in a Wild Flower
Hold Infinity in the palm of your hand
And Eternity in an hourWilliam Blake, The Pickering Manuscript.
Every dictionary you have written so far stored a single number or string under each key. Planning a garden asks for more, an entire collection of facts inside a single entry. A plant carries several facts, such as a packet count and a sprout time. The order holds many plants, and the planting assigns them to beds. No new collection is needed. The values inside a list or a dictionary can themselves be lists and dictionaries.
21.1 A Dictionary as a Record
A record is a dictionary that describes a single entity, where each fact held under a key is known as a field. The term comes from paper records, much like a plant tag at a nursery that gives every plant the same lines to fill in. The record below fills in those lines for kale.
kale = {
"name": "Kale",
"packets": 12,
"sprout": 5,
"sunny": True,
}
print(kale)
print(kale["sprout"])
kale["packets"] = 11
print(kale["packets"])
Everything you already know about dictionaries applies. Reading kale["sprout"] selects one field, and assigning to kale["packets"] replaces it, recording a packet as planted. The display shows the fields in the order they were added.
You could instead keep four separate lists, one for names, one for packet counts, one for sprout days, and one for sun exposure. The facts belonging to one plant would then be linked only by agreed positions, an arrangement you would have to keep straight in your head. A record keeps one plant’s facts together, and its keys say what each fact means.
A tuple also holds related values together, as when a function returns a minimum and maximum in one value. A tuple suits a small group with agreed positions, while a record suits a thing with many facts, or facts your program selects by name.
Try It YourselfAdd a field
"supplier"tokale, with a string value such as"West Coast Seeds", and print the record again. Where in the display does the new pair appear?
21.2 The List of Dictionaries
A catalogue holds many things. Build it as a list of dictionaries, one record per plant, and the list keeps them in order:
catalogue = [
{"name": "Kale", "packets": 12, "sprout": 5},
{"name": "Basil", "packets": 7, "sprout": 4},
{"name": "Peas", "packets": 4, "sprout": 7},
]
print(catalogue[0]["sprout"])
for plant in catalogue:
print(plant["name"])
Data structured this way, with collections holding other collections, is nested data. Read the accesses from left to right. catalogue[0] is the first record, and ["sprout"] selects a field within it. In the loop, each pass through catalogue assigns a whole record to plant, and plant["name"] reaches inside that record.
Each record in a well-kept list of dictionaries carries the same fields, so any code that works with one record works with them all. A loop can combine a field across the records, in the same accumulator pattern you used over lists:
catalogue = [
{"name": "Kale", "packets": 12, "sprout": 5},
{"name": "Basil", "packets": 7, "sprout": 4},
{"name": "Peas", "packets": 4, "sprout": 7},
]
total = 0
for plant in catalogue:
total += plant["packets"]
print(total)
The total is 23, the same total the previous chapter found with sum(packets.values()). The information is unchanged, but its shape differs.
Changes reach into the structure as directly as reads do:
catalogue = [
{"name": "Kale", "packets": 12, "sprout": 5},
{"name": "Basil", "packets": 7, "sprout": 4},
{"name": "Peas", "packets": 4, "sprout": 7},
]
catalogue[1]["packets"] = 6
print(catalogue[1])
One assignment selects the record at position 1, then replaces one of its fields. The other records are untouched, and the list still holds three of them.
In the total example, add a fourth record for "Mint" with 3 packets and a sprout time of 6 days, then predict the new total before running it.
Try It YourselfIn the total example, change
plant["packets"]toplant["packet"]inside the loop. Predict the message you will see, then run the example. The message names a key but no plant. Which record was the loop visiting when the program stopped? Restore the field name afterward.
21.3 Grouping with a Dictionary of Lists
The catalogue and its records answer questions about plants. A dictionary of lists turns the shape around, grouping values under keys, which suits questions about the groups:
beds = {
"front": ["kale", "basil"],
"back": ["peas", "kale", "mint"],
}
print(len(beds["back"]))
beds["front"].append("thyme")
print(beds)
Navigation works exactly as in the catalogue: beds["front"] selects a list, and .append() changes that list in place. After the call, the front bed grows to three plants while the dictionary itself gains no new key.
A loop over the pairs can now deliver a whole group at once, with items() supplying each key beside its list:
beds = {
"front": ["kale", "basil", "thyme"],
"back": ["peas", "kale", "mint"],
}
for bed, plants in beds.items():
print(f"{bed}: {len(plants)} plants")
A group sometimes needs to start before its first value is known. Assign an empty list to the new key, then grow it:
beds = {
"front": ["kale", "basil", "thyme"],
"back": ["peas", "kale", "mint"],
}
beds["side"] = []
beds["side"].append("mint")
print(beds)
The new pair appears with its one-plant group, in the same way an empty dictionary began the tallies of the previous chapter. Start the structure empty, then let the program fill it.
A bed where only membership matters can hold its plants in a set instead of a list.
Try It YourselfIn the first bed example, give one bed a repeated plant, then change each bed’s list to a set literal by replacing the square brackets with curly braces, and change the
.append("thyme")call to.add("thyme"). Predictlen(beds["front"])andlen(beds["back"]), then run the example and check. What did the set do with the repeated plant?
21.4 Choosing a Representation
The garden has now appeared in three shapes: a record for one plant, a catalogue of records for the order, and grouped beds for the planting. None of the shapes is best. The right question is what your program asks most often, because the collection that answers it directly saves the most work.
Ask what each entry means. Values in order, possibly repeated, belong in a list. Distinct values, where membership is the question, belong in a set. A small group with fixed roles, like a minimum and maximum, belongs in a tuple. A value your program finds by name belongs in a dictionary, keyed by that name.
For the shapes in this chapter:
| Requirement | Representation | Reason |
|---|---|---|
| Keep one thing’s named facts together | Dictionary as a record | Field names carry each fact’s meaning |
| Keep many records in order | List of dictionaries | The list preserves order, and each record keeps named fields |
| Collect many values under one name | Dictionary of lists | One key owns a whole group |
Shapes also combine freely: a list of records where one field is itself a list simply applies these ideas twice.
Return to the seed order. Which shape answers “which bed is each plant in?” most directly: one of this chapter’s three, or a fourth dictionary of your own design? Say what each of the others would force you to do.
21.5 Summary
A dictionary can act as a record: its keys name the fields that describe one thing, keeping related facts together. Put records in a list to make an ordered catalogue. You can loop through the records, access a field by name, as in catalogue[1]["packets"], and update that field inside one record. Each record should use the same fields, so the same code works for all of them.
A dictionary of lists lets each key select a group, which you can inspect, extend, or create by starting with an empty list. Use a set instead of a list when a group represents distinct members and order or repetition does not matter.
Choose a collection by the questions your program needs to answer: lists preserve order and repetitions, sets track distinct membership, tuples suit small groups with fixed roles, and dictionaries provide lookup by key. These shapes can be nested or combined (a list of records or a dictionary of lists, for example), so choose the structure that keeps the important information and common operations within easy reach.
21.6 References and Further Reading
- Python Tutorial: Dictionaries, for dictionary records and iteration.
- Python Tutorial: Looping Techniques, for loops over keys and values.
- Python: Mapping Types, for the full set of dictionary operations.
22 References and Aliasing
QuoteYou cannot step twice into the same rivers; for fresh waters are ever flowing in upon you.
Heraclitus, Fragments of Heraclitus, trans. John Burnet.
Suppose you are editing a playlist, and before you start you decide to make a backup. You store the same songs under a second name, expecting the original to stay as it is. A backup is useful only if it stays independent of the original. Assignment looks like it should make a backup, but it does not. Assignment gives an object another name rather than a copy. If the object is mutable, a change through one name is visible through the other. That is useful when you want two names for the same data, and dangerous when you thought you had two separate copies.
22.1 Names Are Labels, Not Boxes
Before you run this program, predict what print(backup) will show:
playlist = ["Song A", "Song B"]
backup = playlist
playlist.append("Song C")
print(backup)
Output:
['Song A', 'Song B', 'Song C']
You changed playlist, but backup changed too. Why?
The answer is in what a name is. backup = playlist attached the name backup to the same list that playlist already named. The two names refer to one list, so a change through either name is visible through the other.
In Python, every value your program works with is an object, including lists, dictionaries, strings, numbers, and None. A name is a label attached to an object, so playlist and backup are two labels on one list. The connection between a name and an object is a reference. Assignment attaches a name to an object, so the name refers to that object. When two names refer to the same object, they are aliases of each other. This sharing is called aliasing. An alias is not a copy. It is another way to reach the same object.
22.2 Aliasing: One List, Two Names
Changing an object and giving a name a new object can look alike in code, but only the first changes what the other names see:
playlist = ["Song A", "Song B"]
backup = playlist
backup = backup + ["Song C"]
print(playlist)
print(backup)
backup + ["Song C"] builds a new list. The + operator joins two lists into a new one, and the assignment attaches backup to that new list. The original list was never changed, so playlist still refers to it.
| What you do | What happens |
|---|---|
backup.append("Song C") |
Changes the shared list, so every alias sees the change |
backup = backup + ["Song C"] |
Creates a new list and attaches backup to it, so playlist is unchanged |
Aliasing reaches into nested data too. When a list is stored in a dictionary, the dictionary holds the list itself, not a copy. If you append a song to the list through a variable, the new song is there when you read the list back out of the dictionary.
22.3 Same Object or Just Equal?
Every object has a value: the data it holds, such as the songs in a list. Its type is what kind of object it is, such as list or str. Its identity is which particular object it is. Two separate lists can have the same value but different identities.
The playlist example gave one object two names. Python can tell whether two names refer to the same object, and it can also compare the values those names hold.
Python gives you two ways to compare objects:
| Operator | Question it asks |
|---|---|
== |
Do these objects have the same value? |
is |
Are these the same object? |
playlist = ["Song A", "Song B"]
backup = playlist
another_playlist = ["Song A", "Song B"]
print(playlist == backup)
print(playlist is backup)
print(playlist == another_playlist)
print(playlist is another_playlist)
playlist and backup are two names for one list, so the first two comparisons print True. another_playlist is a separate list holding the same songs. The values match, so playlist == another_playlist prints True. The objects differ, so playlist is another_playlist prints False.
Drawn as names and objects, the comparison example looks like this:
Try It YourselfIn the playlist comparison example, change
another_playlist = ["Song A", "Song B"]toanother_playlist = playlist. Predict all four results before running the program. Which comparisons change, and why do they now match the first two?
Most comparisons care about values, so == is your everyday operator. The identity question you will meet most often is whether a value is None:
scores = {"Ana": 90}
result = scores.get("Ben")
print(result is None)
The get() call returns None because there is no "Ben" key. Python creates just one None and hands you that same object every time.
Testing for None therefore asks which object you have, not what value you hold. result == None would give the same answer here, but is says what you mean.
Watch Your Step
iscan look like==when Python reuses an object. Small integers and some strings are often shared, so two names with equal values can also have the same identity. That sharing is a behind-the-scenes choice, not a promise, so compare values with==and leaveisfor identity questions.
22.4 Making a Real Copy
Sometimes you want a separate backup. The comparison example wrote one out by hand. In practice, you copy the existing list. The lists chapter showed that a full slice like playlist[:] copies a flat list. .copy() does the same job and reads more clearly:
playlist = ["Song A", "Song B"]
backup = playlist.copy()
backup.append("Song C")
print(playlist)
print(backup)
The two lists are now independent. Adding a song to backup leaves playlist unchanged.
22.4.1 Shallow Copies
What if the list contains other lists? In albums, one element is a whole list of songs and one is just the string "Song C". .copy() makes a shallow copy. It creates a new outer list, but the elements inside are the same objects, so the copy and the original share every element.
albums = [["Song A", "Song B"], "Song C"]
backup = albums.copy()
backup[1] = "Song D"
backup[0].append("Song Z")
print(albums)
print(backup)
print(albums[0] is backup[0])
Replacing backup[1] changed only the copy, because each outer list has its own positions. The append() shows up in both displays, because it changes the inner list that both outer lists share. The check confirms that sharing: albums[0] is backup[0] prints True.
Try It YourselfIn the shallow-copy example, change
backup[0].append("Song Z")tobackup.append(["Song Z"]). Predict all three output lines before running. Which lists are shared after this change, and which are independent?
22.4.2 Deep Copies
When the nested objects must be independent too, use copy.deepcopy(). A deep copy copies the outer list and the nested objects inside it:
import copy
albums = [["Song A", "Song B"], "Song C"]
backup = copy.deepcopy(albums)
backup[0].append("Song Z")
print(albums)
print(backup)
Now albums is untouched, and backup carries the appended song.
Choose the shallowest option that gives you the independence you need:
| Approach | Use it when |
|---|---|
backup = playlist |
You want another name for the same object, not a copy |
backup = playlist.copy() or backup = playlist[:] |
You need a new outer list, and the elements can stay shared |
backup = copy.deepcopy(playlist) |
A change to the copy must not reach the original at any level |
A Useful HabitWhen you must change a nested structure without affecting its caller, make the independent copy before the first change. A copy taken afterward cannot undo the change the caller already sees.
22.5 What a Function Receives
Calling a function works like assignment. Each parameter name is bound to the object the caller supplied as the argument, so the parameter is another name for that object. Nothing is copied.
def add_song(songs):
songs.append("Song C")
playlist = ["Song A", "Song B"]
add_song(playlist)
print(playlist)
Inside the function, songs and playlist are two names for one list. The function changes that list, so the caller sees the new song. The function does not return a new list, but the caller still sees the change.
Not every function changes the list it receives. This one builds a new list instead:
def add_song_rebound(songs):
songs = songs + ["Song C"]
playlist = ["Song A", "Song B"]
add_song_rebound(playlist)
print(playlist)
add_song_rebound() builds a new list and binds the parameter songs to it. The caller’s playlist still refers to the original list, so the display is unchanged. A function that builds a new list has to return it, and the caller has to store the result:
def add_song_returned(songs):
return songs + ["Song C"]
playlist = ["Song A", "Song B"]
playlist = add_song_returned(playlist)
print(playlist)
The call returns a new list, and the caller assigns it to playlist. The original list was never changed.
Whether the caller sees a change depends on the object the function received. Lists and dictionaries are mutable, so .append() and similar calls change the shared object. Strings, numbers, and tuples are immutable, so a function that builds a new value has to return it.
22.6 Summary
Names refer to objects. Assignment gives an object another name instead of copying it. Two names for one object are aliases. If the object is mutable, a change through either name is visible through the other. == compares values, and is compares identity. Compare with == whenever the value is what you care about, whatever the type. The everyday use of is is testing for None.
A shallow copy, such as .copy() or a full slice, gives a new outer container but shares the objects inside. copy.deepcopy() copies the nested objects too, so a change to the copy stays in the copy. Choose the shallowest copy that gives you the independence you need.
A function parameter is another name for the caller’s object, not a copy. If the function changes that object, the caller sees the change. If the function builds a new object instead, the caller’s variable still refers to the original, so the function has to return the new object.
22.7 References and Further Reading
- Python Data Model: Objects, Values and Types, for value, type, and identity.
- Python Language Reference: Identity Comparisons, for
isandis not. - PEP 8: Programming Recommendations, for the advice to test for
Nonewithis. - Python Library Reference:
copy, for shallow and deep copies. - Python Tutorial: Defining Functions, for how arguments bind to parameters.
23 Error Handling and Exceptions
QuoteEver tried. Ever failed. No matter. Try again. Fail again. Fail better.
Samuel Beckett, Worstward Ho, 1983.
Asking a user for a number requires a leap of faith. Your program cannot make the user type a number, so it can only ask, wait, and trust. You expect digits, but if they type the word two instead, the int() function instantly raises a ValueError.
An abrupt ending is not the only option. This chapter looks at the exception itself: how to read its type and message, how to catch the failures you expect, and how to raise your own when a value breaks the rules. The goal is not to catch every single error. A program that silently hides every failure is much harder to trust than one that simply stops. By the end, you can decide which failures to handle gracefully, which to report, and which should rightly end the program.
23.1 When a Program Stops
Suppose you are writing a program that sells tickets, saved as order.py. Its first line asks the customer how many tickets they want, then converts the answer to an int:
count = int(input("Tickets: "))
The customer types two, and the program stops by raising an exception whose report ends:
ValueError: invalid literal for int() with base 10: 'two'
An exception is the value Python raises the moment something fails, carrying a type and a message. Nothing caught this one, so Python printed the report and ended the program.
ValueError names the exception type, the category of the failure. The text after the colon is the message, and here it quotes 'two', the exact answer that broke the conversion. The full report also names the file and the line of the failure, as you saw earlier in the course.
23.2 Handling an Exception with try and except
A typo should not stop the whole program, because the customer can just retype the answer. Python lets you catch the failure you expect and keep going:
typed_answer = "two"
try:
count = int(typed_answer)
except ValueError:
print("Please enter a whole number of tickets.")
print("The order continues.")
The try statement marks the block Python watches. When int() raises ValueError, Python does not print a traceback and does not stop the program. Instead it jumps to the except clause whose type matches. The block under except is the handler. Here it prints a message that tells the customer what a valid answer looks like. After the handler finishes, execution continues after the whole try statement, so The order continues. appears no matter what.
Catching the failure kept the program running, but the assignment never finished, so count does not exist, and code that needs it belongs where the conversion is guaranteed to succeed. The handler can also receive the exception object itself, with as:
typed_answer = "two"
try:
count = int(typed_answer)
except ValueError as error:
print("Could not use that answer:", error)
Here error is the exception object, and printing it shows its message. Use that message, or write a clearer one of your own.
One try handles one answer. The while True loop you met earlier in the course asks again after every failure, and break leaves the loop once the conversion succeeds:
while True:
try:
count = int(input("Tickets: "))
break
except ValueError:
print("Please enter a whole number of tickets.")
print("Ordering", count, "tickets.")
The break sits on the success path, after the assignment, so count exists by the time the loop ends. Any code after the loop can rely on that.
A Useful HabitWrap only the lines whose failure you are prepared to handle. Name the exception you expect, then find the smallest block of code that can raise it. A wide
tryblock also covers lines unrelated to that failure, so the handler answers for problems it cannot fix.
Notice what the handler let you skip: checking the answer before using it. Python programmers call that habit LBYL, short for Look Before You Leap. It is harder than it sounds: "12." looks like a number, but int() refuses it, and a hand-written check has to know that.
The style you just practised is EAFP, short for Easier to Ask Forgiveness than Permission. You attempt the conversion, and the handler asks forgiveness when it fails. That works because int() already knows exactly what a number looks like, so the check you skipped was never yours to write.
23.3 What a Broad except Hides
Every failure you have caught in this chapter was one you expected, and naming ValueError was enough. But this program can fail in more than one place: it keeps the show’s details in a dictionary, and a mistyped key there would stop it too. With two different failures in one block, you may be tempted to catch everything at once:
typed_answer = "two"
show = {"title": "Interstellar", "seats": 40}
try:
count = int(typed_answer)
title = show["titles"]
except Exception:
print("Could not read the order.")
The handler caught the ValueError from the conversion and printed a message that could describe any failure. Set typed_answer to "12" and run the block again: the conversion succeeds, and the next line fails instead, at the mistyped field "titles". The same sentence swallows the KeyError from that lookup too. The first failure is invalid customer input, which the program can recover from. The second is a bug in your own code, and this handler cannot repair it.
The reason one handler catches so much is the exception hierarchy, a family tree of exception types with Exception near the top:
Exception
├── ValueError
└── LookupError
└── KeyError
Everything on the tree sits below Exception, so a handler that names it catches all of it. A raised ValueError therefore matches both except ValueError and except Exception. When several handlers could match, Python checks them from top to bottom and runs the first match, which is why specific handlers come first: a broad except Exception listed first would catch the ValueError before its specific handler saw it.
Catch the exception you can handle, and let the others stop the program. An unhandled exception ends the program with a truthful report. A broad handler prints a message and hides that report, which is the worst trade in the chapter.
23.4 else and finally: What They Guarantee
try can do more than catch. Two further clauses give the statement its guarantees: else runs only when the block succeeds, and finally runs last no matter what:
typed_answer = "12"
try:
count = int(typed_answer)
except ValueError:
print("Please enter a whole number of tickets.")
else:
print(f"Ordering {count} tickets.")
finally:
print("Thank you for visiting.")
With typed_answer set to "12", the conversion succeeds, so the else block prints the order confirmation and the finally line comes last. Change the answer back to "two" and the handler prints its message instead, while finally still runs.
The confirmation print sits in else for the same reason the try block stays small: the handler guards only the conversion. Success-path code belongs outside the try, in else, where its failures tell the truth.
Code after the whole try statement also runs, whether the block succeeded or the handler caught the ValueError. The finally clause is for cleanup that must happen no matter how the statement exits, even when a handler itself raises a new exception. Closing a file is the classic finally job, and the next chapter shows the form Python programmers actually use for it.
23.5 Raising Your Own Errors
Every exception in this chapter came from Python. Your own functions can raise them too, with raise. A ticket count of zero or less is a mistake in the order, so the function that checks the count refuses it:
def ticket_count(value):
if value <= 0:
raise ValueError(f"Tickets must be positive, not {value}.")
return value
print(ticket_count(3))
The check sits at the top of the function, before any work that assumes a valid count. When the value breaks the rule, the raise statement creates a ValueError with a message that names the bad value. Nobody handles the error here, so a call with 0 would stop the program.
Raising beats printing when the caller needs to act. A function that prints Tickets must be positive and returns anyway leaves the caller holding a value it cannot use, and the caller may not check. A raised error reaches the caller directly, and it cannot be ignored by accident. It stops the program unless the caller chooses to handle it:
def ticket_count(value):
if value <= 0:
raise ValueError(f"Tickets must be positive, not {value}.")
return value
for requested in [4, 0, -2]:
try:
print(ticket_count(requested), "tickets ordered")
except ValueError as error:
print("Rejected:", error)
The first call prints 4 tickets ordered. The other two are rejected, each with a message that names the value. The function rejects the impossible values, and the caller decides what to do about each rejection. One failure, two audiences, and each gets what it needs.
A good message says what went wrong and what would be accepted, so whoever sees it can fix the input without opening the code. A function that cannot fix the failure should let it out, because a caller further up may know what to do with it.
Try It YourselfPredict what
ticket_count("two")does, then compare it withticket_count(-3). Which failure is this function’s to reject, and which is the caller’s to prevent?
23.6 Summary
An exception has a type and a message. The type names the category of the failure, and the message gives the details.
A try block watches code for a named exception type, and the handler under except runs when the type matches. else runs only after a successful try block, and finally runs no matter how the statement exits, including when a handler raises. Name specific types before general ones, because the first matching handler runs. except Exception catches almost every error and hides the report, so catch only the exception you can handle.
Your own functions raise errors with raise, at the top, before the work that assumes a valid count. A raised error reaches the caller and cannot be ignored by accident. The caller decides whether to handle it or let it stop the program.
23.7 References and Further Reading
- Python Tutorial: Errors and Exceptions, for handling and raising exceptions.
- The
tryStatement, for the order ofexcept,else, andfinally. - The
raiseStatement, for raising errors. - Beej’s Guide to Python Programming, chapter 14, for error handling, catching and raising exceptions, and the
finallyandelseclauses.
24 Files and JSON
QuoteWrite programs that do one thing and do it well. Write programs to work together. Write programs to handle text streams, because that is a universal interface.
Doug McIlroy, Unix philosophy.
Every value your programs have held so far lived in memory and vanished the moment the program ended. This chapter gives that data somewhere to stay: text on disk in files, and whole structures inside the text as JSON. You will find a file by its path, open and close it safely, and read and write it one line at a time. Two failures are worth naming up front, a missing file and one that is not valid JSON, and the last chapter taught you when to catch a failure and when to let one stop the program. By the end, your program will keep its data between runs: save a book list to a file, and the next run loads it back whole. And when that data holds lists inside records, a loop inside a loop reaches them.
24.1 What a File Is and Where It Lives
A file is a named sequence of bytes on disk, and it stays there after the program stops. When Python opens a file, it asks the operating system for access and receives a file object, which is your program’s handle for reading and writing that file.
To find the file, Python needs a path. "books.txt" is a relative path. Python looks for it in the working directory, the folder your terminal was in when the program started. That is why the same program can work one day and fail the next:
file = open("books.txt", encoding="utf-8")
Run from the folder that holds the file, this line succeeds. Run from anywhere else, the program stops with FileNotFoundError, another member of the Exception family. The message names the file it could not find, so compare that name with the folder your terminal is in.
The encoding="utf-8" argument tells Python how the bytes on disk map to characters, so names with accents read correctly. This chapter works with text files only.
24.2 Opening Files: Why with Beats close()
Opening a file asks the operating system for a handle, and closing it flushes any buffered data to disk and releases that handle. The examples need a file to read, so save one named books.txt in the working directory, with one title per line. Use the three titles below, because the next section displays this file’s text exactly. The straightforward first version reads the whole file in three lines:
# books.txt: Anne of Green Gables, Matilda, The Little Prince
file = open("books.txt", encoding="utf-8")
text = file.read()
file.close()
These three lines work for this file. They are not safe in general, because a failure anywhere between the open and the close skips the close and leaves the file open. Last chapter you met finally, which guarantees that code runs even when an exception happens. The with statement gives file handling the same guarantee:
with open("books.txt", encoding="utf-8") as file:
text = file.read()
print(text)
The with statement closes the file on the way out of the block, whether it ended normally or with an exception. You never write the finally, and you never write the close. Use the file inside the block, and after the block it is closed. A .read() after the block raises ValueError, because the file object no longer has an open file behind it.
24.3 Reading a File: Whole, Then Line by Line
The file holds the list of books you have read, one title per line. Before starting a new book, you want the program to answer one question: have you already read it?
The whole list fits in one string, so the first version of the checker reads the file once and asks the question with in:
with open("books.txt", encoding="utf-8") as file:
text = file.read()
title = "Matilda"
if title in text:
print("You have already read", title)
else:
print(title, "is still waiting")
Run the program once, then look at the string the question was asked of:
>>> text
'Anne of Green Gables\nMatilda\nThe Little Prince\n'
A file that looks like three short lines arrives as one string, newline characters and all, including the one after The Little Prince. The in test searches this whole text, and Matilda is in it, so the if branch runs. Change title to a book the string does not name, and the same test finds nothing, so the else branch runs instead.
One run answers one title. Before a trip to the bookstore you want the whole picture: how many the list holds, and which titles. The loop you already know from lists walks those lines the same way, because a file hands you one line per pass:
count = 0
with open("books.txt", encoding="utf-8") as file:
for line in file:
count = count + 1
print(line.strip())
print("You have read", count, "books")
Each line arrives with its newline character on the end, so .strip() removes it, along with any surrounding spaces. The count comes out to 3.
Try It YourselfRemove
.strip()from the loop body and predict what the output will look like. Run the code to check your prediction, then restore the strip.
Which read should you use? The have-I-read-it check treats the file as one value, and the loop treats it one title at a time. A list of book titles is naturally line-shaped, so the loop fits. The loop has one more advantage. in on a string matches inside a title as well as a whole one, so a search for Prince would match The Little Prince. Comparing each stripped line with == accepts only a complete title. The JSON records in this chapter are the other kind: one structure, not one record per line, and they get their own section.
24.3.1 Skipping Blank and Comment Lines
Real data files have blank lines, and people leave notes in them. The counting loop above would count both as books. A line starting with # is a comment for humans, the file’s convention rather than Python’s, and the program should pass over it. The startswith() method reports whether a string opens with the characters you give it, so line.startswith("#") catches the comment lines. Python’s continue statement skips the rest of the current pass and starts the next one:
lines = ["Anne of Green Gables", "", "# finished last month", "Matilda"]
for line in lines:
line = line.strip()
if line == "" or line.startswith("#"):
continue
print(line)
Only Anne of Green Gables and Matilda print. The same loop works for a file: write for line in file: instead. The empty line and the comment line each reach the condition, match it, and jump straight to the next pass. break would end the loop entirely, which is not what you want, and Matilda would never print. You could also wrap the print() in an else, but then every useful line gains a level of indentation. continue states the intent at the top: skip this line, keep going.
24.4 Writing and Appending
Writing needs a mode. open() assumes "r" when you leave the mode out, and every open() so far has left it out. The modes differ in what they do to a file that is already there:
| Mode | What it does |
|---|---|
"r" |
Reads. This is the default when open() gets no mode. |
"w" |
Writes, erasing what is there first. |
"a" |
Appends after what is there. |
"x" |
Creates a new file, and raises FileExistsError if the name is taken. |
The writing examples use a separate file, shelf.txt, so the "w" mode cannot erase your book list:
with open("shelf.txt", "w", encoding="utf-8") as file:
file.write("Anne of Green Gables\n")
file.write("Matilda\n")
with open("shelf.txt", "a", encoding="utf-8") as file:
file.write("The Little Prince\n")
The file now holds all three titles in order, with The Little Prince last, because the append mode added it after the two writes.
.write() adds no newline, so each line needs its own \n. A missing newline is the usual surprise here, because the two writes join into Anne of Green GablesMatilda on one line. print("The Little Prince", file=file) is the alternative, and it adds the newline for you.
Stop And CheckOpening an existing file with
"w"erases its contents before the first write, and no later exception can bring them back. Check that the path is the file you mean to replace, and keep a copy until the new file is correct.
with guarantees a closed file, not a complete one. If the program stops after the first write(), shelf.txt still closes, holding only that first title. That matters more in the next section, where half a JSON file is not JSON at all.
24.5 JSON: Saving a Structure, Not Lines
A book record is a dictionary with three fields, and writing it as a line means inventing punctuation, such as Matilda,240, with no obvious place for the tags list at all. Reading the line back means splitting it yourself and trusting the field order. Records need a format that already solves delimiting and quoting, and the standard one is JSON, short for JavaScript Object Notation. It is text, it nests, and Python and most other languages read it.
The json module maps between Python and the format: dictionaries become JSON objects, and lists become arrays. json.dumps() returns the text as a string:
import json
books = [
{"title": "Anne of Green Gables", "pages": 429, "tags": ["Canadian"]},
{"title": "Matilda", "pages": 240, "tags": ["magic", "children's"]},
{"title": "The Little Prince", "pages": 96, "tags": ["philosophy"]},
]
print(json.dumps(books, indent=2))
The indented text shows each book’s tags as an array inside its object. Without indent=2 the same text comes out as one long line. Both forms load back identically, and indent is for the person who might open the file.
json.dump() writes the same structure to an open file. The name drops the s because no string comes back:
with open("books.json", "w", encoding="utf-8") as file:
json.dump(books, file, indent=2)
Watch Your StepJSON objects always have string keys. A dictionary with integer keys is written with string keys, which surprises programs that load it back.
A set has no JSON spelling at all. Saving one raises
TypeError, and the message says a set is not JSON serializable. Convert a set to a sorted list before saving it.
24.6 Loading It Back into Dictionaries and Lists
The point of saving was loading the data back later. The books.json from the last section outlives the program that wrote it, so the next run can pick up where the last one stopped. json.loads() (the s in loads is for string) parses JSON text into Python objects:
import json
text = """[
{"title": "Anne of Green Gables", "pages": 429, "tags": ["Canadian"]},
{"title": "Matilda", "pages": 240, "tags": ["magic", "children's"]},
{"title": "The Little Prince", "pages": 96, "tags": ["philosophy"]}
]"""
books = json.loads(text)
print(books)
The printout is the list of dictionaries again: page counts are numbers, and each book’s tags are a list. JSON spells lists, dictionaries, numbers, and strings, and each one loads back as the same Python type.
Reading from a file pulls the chapter together: the with block, the file, and the two failures a load can have. A missing file is recoverable: the first run has no book list yet, and an empty list is the honest start. Python raises the same exception when the terminal was in the wrong folder, so check the working directory before accepting the diagnosis. Invalid JSON is different. It is not safe to fall back to an empty list, because a later save would overwrite records the program never read. So the handler catches only the missing file:
import json
try:
with open("books.json", encoding="utf-8") as file:
books = json.load(file)
except FileNotFoundError:
print("No book list found at this path. Starting empty.")
books = []
json.load() reads the file and parses it in one call. Text that is not valid JSON raises json.JSONDecodeError, and nothing catches it, so the program ends with a report naming the line and column where the parsing stopped. A missing comma or quotation mark gives you a place to start, instead of an evening of hunting.
A Useful HabitCheck the shape of loaded data before using it. Confirm the result is a list of dictionaries with the fields you expect, because a file can hold valid JSON in a different shape than your program assumes. A later chapter gives that check a name, a precondition, and a place in the function.
The book list now survives between runs, and the records sit in a list of dictionaries, exactly the shape they had before. Each record carries its tags in a list, and the next section reaches them with a loop inside a loop.
24.7 Nested Loops
A shelf of books is a list of records, and printing every title with its tags beneath it asks a loop to work through two levels, because the tags live inside each book. These examples use only the two fields, the title and the tags. A nested loop is a loop written inside another loop’s block:
books = [
{"title": "Anne of Green Gables", "tags": ["Canadian"]},
{"title": "Matilda", "tags": ["magic", "children's"]},
{"title": "The Little Prince", "tags": ["philosophy"]},
]
for book in books:
print(book["title"])
for tag in book["tags"]:
print(f" {tag}")
The outer loop selects one book per pass, and the inner loop, indented inside the outer loop’s block, runs to completion over that book’s tags before the next pass begins. Because the inner collection is book["tags"], the same inner loop walks a different list on every pass. The report comes out shaped like the data, each title with its tags indented beneath it.
Here is the same report, one outer pass at a time:
| Pass | Title printed | Inner loop prints |
|---|---|---|
| 1 | "Anne of Green Gables" |
"Canadian" |
| 2 | "Matilda" |
"magic", then "children's" |
| 3 | "The Little Prince" |
"philosophy" |
Pass 3 does not begin until Matilda’s tags are used up.
Try It YourselfChange The Little Prince’s tags to an empty list,
"tags": []. Predict the output before running, then run it and check. Which loop ran zero times for The Little Prince, the outer one or the inner one?
A report is one job for a nested loop. Finding is another: which books on the shelf are Canadian? The title lives on the record and the answer lives in its tags, so the outer loop holds the title while the inner loop checks each tag:
books = [
{"title": "Anne of Green Gables", "tags": ["Canadian"]},
{"title": "Matilda", "tags": ["magic", "children's"]},
{"title": "The Little Prince", "tags": ["philosophy"]},
]
for book in books:
for tag in book["tags"]:
if tag == "Canadian":
print(book["title"])
Only Anne of Green Gables prints. The outer loop holds the value worth reporting, and the inner loop holds the value worth testing. Nested loops often split their work in exactly that way.
A dictionary whose values are lists, like the bed groups from earlier chapters, nests the same way, with the inner loop following the items() loop you already know.
Two nested loops reach two levels. A third loop would reach a third, and any fixed number of loops stops at that many levels, so a structure whose depth you cannot predict needs more than loops. The next chapter is about exactly that.
24.8 Summary
A file is data outside the program, named by a path. A relative path is resolved against the working directory, which is the folder the terminal was in when the program started. The with statement closes the file on the way out of its block, even when an exception escapes. Use the file inside the block only.
.read() returns the whole file as one string, and a for loop over the file visits one line at a time. .strip() removes each line’s newline, and continue skips the rest of the current pass, so the loop passes over blank and comment lines. open() reads by default, and of the writing modes only "w" erases what is already there. .write() adds no newline.
JSON saves whole structures as text. json.dump() writes a structure to a file, and json.load() reads it back with its Python types. Start from an empty list when FileNotFoundError reports a missing file, and let json.JSONDecodeError end the program when the text is not valid JSON. Check the loaded shape before using it. A nested loop sits inside another loop’s block and runs to completion on every outer pass. One loop per level reaches a structure exactly that many levels deep.
24.9 References and Further Reading
- Think Python, Chapter 14: Files, for a second treatment of the same ground.
- Python Tutorial: Reading and Writing Files, for opening, reading, and writing files.
- Built-in Functions:
open(), for modes, encoding, and the file object. json: JSON Encoder and Decoder, for saving and loading structures.- Python Tutorial: Methods of File Objects, for
read,readline, andwrite.
25 Recursion
QuoteThe ‘Strange Loop’ phenomenon occurs whenever, by moving upwards (or downwards) through the levels of some hierarchical system, we unexpectedly find ourselves right back where we started.
Douglas Hofstadter, Gödel, Escher, Bach, 1979.
Suppose you are writing a program that lists every file in a folder. A folder holds files, and it can hold other folders, which hold files of their own. How deep the nesting goes depends on what the user saved, so your program cannot know the depth in advance.
A loop reaches one level, because it repeats over one collection. The tags report in the last chapter wrote a loop for each of its two levels. A folder tree does not tell you how many levels there are, so no fixed number of loops is enough. A function that calls itself can follow the structure down as far as it goes and return every value it finds. That idea is recursion: solve a small piece of the problem, then ask the same function to solve the rest. The chapter starts with a countdown, where the mechanism is easy to watch, and works up to the nested folders.
25.1 A Function That Calls Itself
Here is a launch sequence, three seconds long:
def countdown(n):
if n <= 0:
print("Liftoff")
return
print(n)
countdown(n - 1)
countdown(3)
Read the last two lines of the function together. The call countdown(n - 1) is the recursive call: the function calls itself with a smaller number. Python allows it because each call gets its own n and runs its own body.
While the inner call runs, the outer call is paused, waiting for it to return. The whole launch looks like this:
Python has a name for this pile of waiting calls: the call stack. A new call joins the top of the stack, and a call leaves when it returns, so the stack always holds exactly the calls that have started but not finished. You have read this structure before without its name. When a traceback earlier in the course showed several call lines, those lines were the stack, printed from the oldest call down to the one where the error happened.
The point is only the pattern: do one step, then hand the rest of the problem to another run of the same function. Something in the function decides when to stop descending, and it deserves its own section.
Try It YourselfSwap the last two lines of
countdown, so the recursive call comes first andprint(n)runs after it. Predict the output, then run it. Where doesLiftoffappear, and why there? Then trycountdown(0). What prints, and which lines never run at all?
25.2 The Base Case
The if n <= 0 test at the top of countdown is the base case: the input the function can answer without calling itself. For the launch, zero or fewer seconds means the waiting is over, so the answer is Liftoff and nothing else. Every recursive function needs one, and you can see why by removing it:
def countdown(n):
print(n)
countdown(n - 1)
countdown(3)
The stack keeps growing, countdown(3), countdown(2), on and on past zero. Every call on it is still waiting for the next one to return. Python puts a ceiling on how tall the stack may grow, about a thousand calls by default. When the pile passes the ceiling, Python raises RecursionError. The real problem is a descent that never ends.
The traceback does not fill the screen with a thousand lines. Python prints the recursive call’s line once, then reports that the same line repeated hundreds of times. The collapsed repeat is the clue: every call still waiting is running the same line of code.
Closer LookEach waiting call keeps its frame in memory until the call it made returns. Without a ceiling, runaway recursion could keep adding frames until Python crashed outright. The ceiling turns that crash into a
RecursionErrorwith a readable traceback. Thesysmodule reports the ceiling withsys.getrecursionlimit(), usually 1000, and changes it withsys.setrecursionlimit(). A higher limit only delays the error for runaway recursion, and setting it too high can bring back the crash. Check the base case before reaching for more room.
A missing base case and a call that never reaches the one you wrote fail the same way. Every recursive call must move toward the base case. countdown(n - 1) heads for zero, so it eventually arrives. countdown(n + 1) runs away from it, so the stack grows until the error.
25.3 One Step, and a Smaller Problem
The base case ends the descent. The rest of the function is the productive half, and it has a fixed shape: handle one step here, and pass a genuinely smaller problem to the recursive call.
Adding up the sizes of a list of files shows the shape with numbers instead of countdowns. One step is the first file’s size. The smaller problem is the rest of the list:
def total_size(items):
if items == []:
return 0
return items[0]["size"] + total_size(items[1:])
files = [
{"name": "report.txt", "size": 12},
{"name": "photo.jpg", "size": 7},
{"name": "notes.txt", "size": 4},
]
print(total_size(files))
The base case is the empty list, which answers 0 without any call: no files means no size. The recursive line reads like the problem statement: the total of a list is the first file’s size plus the total of the remaining files.
The slice has a quiet cost. Because a slice always builds a new list, every call copies the rest of the items one more time. That is nothing for three files, and worth remembering when the list is long.
Trusting the recursive call is the skill to practise. When you write the recursive line, assume the recursive call already returns the right answer for the smaller list, the same way you assumed math.sqrt() was correct the first time you called it. Then check that your one step combines that answer correctly.
The return is what carries the answer back. A recursive call made without return computes its value and then discards it.
Try It YourselfIn
total_size, changeitems[1:]toitems[2:]. Predict which files the function will count and what it will print, then run it. Which file never contributed, and why does the function still stop?
25.4 When One Answer Needs Two
Everything so far makes one recursive call, so the calls form a single chain. A function whose answer needs two smaller answers makes two calls instead. The classic example is fib, which computes the Fibonacci numbers: each one is the sum of the previous two, and the first two are 0 and 1:
def fib(n):
if n <= 1:
return n
return fib(n - 1) + fib(n - 2)
print(fib(4))
Run it and 3 comes back. The picture shows every call Python made to get there. Each rectangle is one call, and each pill is a call that hit the base case, so it answered without calling itself:
The calls return in reverse order: the last call made is the first call to finish. The pills answer on the spot: fib(1) returns 1 and fib(0) returns 0. Every rectangle adds the two answers it receives, so fib(2) returns 1, fib(3) returns 2, and fib(4) returns the 3 that print() received.
The picture is a map of the questions, not a schedule. Python asks fib(n - 1) first and waits for everything beneath it to finish before even starting fib(n - 2).
Closer LookThe
fib(2)box and the box labelledfib(2) againeach compute the same answer from scratch, because one call cannot see another call’s result. The repeats grow fast:fib(4)makes nine calls in this picture, andfib(5)would make fifteen.
The recursion ceiling counts calls waiting at once, not calls made in total, so a wide tree of calls can be enormous without ever reaching it.
fib earns its place as a picture of branching, and it is not how anyone computes these numbers. A loop that keeps only the last two values produces fib(30) instantly, where this version makes over two million calls.
The branching comes from the rule: each call needs two smaller answers, so it makes two calls. In the next section the branching comes from the data instead, as the function decides which items are folders.
25.5 Flattening a Nested List
Now to the data that motivated the chapter. A folder can hold files, and it can hold folders that hold more folders, with no fixed depth. A nested loop reaches one level deeper:
folders = [
["report.txt", "photo.jpg"],
["notes.txt", ["old.txt", "older.txt"]],
["backup.txt"],
]
names = []
for folder in folders:
for item in folder:
names.append(item)
print(names)
Most of the display is file names, and one element is still a folder. The inner loop appended the inner folder whole, because a list is a value like any other. A third loop would reach the files inside it, and a folder deeper still would arrive whole again. To reach files at any depth, the function has to decide, for each item, whether the item is a file name or another folder, and descend when it is. The built-in isinstance() asks whether item is a list, and that test decides whether the function descends into the item:
def all_files(items):
names = []
for item in items:
if isinstance(item, list):
names.extend(all_files(item))
else:
names.append(item)
return names
folders = [
["report.txt", "photo.jpg"],
["notes.txt", ["old.txt", "older.txt"]],
["backup.txt"],
]
print(all_files(folders))
The result is every file name in order, flat. The base case is here, in two forms. A file name is not a list, so the else branch appends it and never descends into it. An empty folder returns [], because the loop runs zero times and no call is made.
Try It YourselfAdd one more layer, for example
["backup.txt", ["temp", ["scratch.txt"]]]in place of the last folder. Predict the output, then run it. Doesall_filesneed a single change? What does your answer say about the depth it can handle?
The loop and the recursion split the work. The loop steps through one list’s items, and the recursive calls supply the depth: whenever an item is itself a list, a call descends into it.
Follow the second folder to see the pattern once. It holds "notes.txt" and an inner list. "notes.txt" is appended. The inner list passes the isinstance test, so the function calls itself on ["old.txt", "older.txt"], and that call returns ['old.txt', 'older.txt'], a flat list. extend splices those two names into the result, where append would nest the returned list and rebuild the very problem the function exists to remove. The function returns a new list, and folders itself is never changed.
Which tool, then, for which data? Flat data means a loop, and the accumulator you have written all term does the same work with less machinery. Unknown depth means recursion.
Try It YourselfWrite
count_files(items), a recursive function that returns how many file names are in a structure likefolders. Decide the base case first: which inputs can the function answer without calling itself? Then check your answer againstlen(all_files(folders)).
25.6 Summary
Recursion is a function calling itself with a smaller version of the same problem. Each call gets its own variables and waits on the call stack. Every recursive function needs a base case, and every call must move toward it. Lose the base case, or call in a way that never reaches it, and Python raises RecursionError.
Handle one step, pass a genuinely smaller problem to the recursive call, and trust it to return the right answer. The calls return in reverse order. When the answer needs two smaller ones, the call makes both requests and combines them.
You descend a nested list by testing each item with isinstance(item, list) and recursing when the test passes. A file name or an empty folder ends the descent. extend splices a returned flat list into the result, where append would nest it. Use loops for flat data and recursion for unknown depth, but remember that only about a thousand calls can be pending at once.
25.7 References and Further Reading
RecursionError, for the exception and when Python raises it.sys.getrecursionlimit(), for the limit that producesRecursionError.- Built-in Functions:
isinstance(), for the type test that decides whether to descend. - Think Python: Conditionals and Recursion, for a longer introductory treatment.
Image Credits
The images in this book are either public domain or used under Creative Commons licences. Source links go to the image’s page on Wikimedia Commons, where the full attribution information lives.
- Ada Lovelace portrait: public domain. Source
- Charles Babbage portrait: public domain. Source
- Bacon alphabet scan: public domain. Source
- Jacquard loom photograph: by Stephencdickson, CC BY-SA 4.0. Source
- Vacuum tubes photograph: by Stefan Riepl, CC BY-SA 2.0 DE. Source
Index
- __name__ 14.8.1
- absolute path 4.2
- absolute tolerance 18.5.1
- Abstraction 2, 9.2, 9.3
- accumulator 13.4
- add() 19.3.1
- algorithm 9.2, 9.3
- Algorithm design 9.2
- alias 22.1
- aliasing 22.1
- Analog 1.4
- append() 17.3
- argument 7.4, 14.1
- Arithmetic operators 6.2
- Arrow 9.4
- assembler 2.1.2
- Assembly language 2.1.2
- assertion 16.3
- assignment statement 5.3
- base case 25.2
- binary 1.5
- binary infix operator 6.1
- bits 1.5
- block 10.2
- bool 7.3
- Boolean expression 10.1
- Boolean operators 6.4, 10.4, 18.1
- boundary case 16.4
- boundary cases 10.6
- branch 10.3
- break 13.2.2
- built-in function 7.7
- bytecode 2.1.4
- call stack 25.1
- caller 14.2
- case sensitive 5.4
- character 11.3
- code 5.1
- coercion 7.5
- collection 17.1
- command line 4
- commit 8.3
- Comparison operators 6.3, 10.1
- compiler 2.1.4
- compound operator 6.7
- Computational thinking 9.2
- concatenation 11.5
- constant 15.2
- constants 5.6
- continue 24.3.1
- control flow 9.4
- copy 17.7.2
- copy a list 17.7.2
- CPU 1.7, 2
- current working directory 4.2
- Debugging 10.6, 16.2
- Decomposition 9.2, 9.3, 14.7
- deduplication 19.3
- deep copy 22.4.2
- del 20.3
- design 9.1, 9.5
- Diamond 9.4
- dictionary 20
- dictionary literal 20.1
- dictionary of lists 21.3
- Digital 1.4
- discard() 19.3.1
- docstring 14.4
- documentation string 14.4
- dot notation 12.1
- EAFP 23.2
- element 17.1
- elif 10.3
- else 10.2
- else if 10.3
- empty dictionary 20.1
- empty list 17.1
- empty string 11.1
- escape sequence 11.2
- except 23.2
- exception 16.1.2, 23.1
- exception hierarchy 23.3
- exception type 23.1
- expression 5.1, 6.5
- extend() 17.3
- f-string 11.7
- field 21.1
- file object 24.1
- FileExistsError 24.4
- FileNotFoundError 24.1
- filtering 17.5
- float 7.3
- floating-point number 18.5
- floor division 6.2
- flowchart 9.4
- for loop 13.3
- format specification 11.7
- function 14.1
- function call 14.1
- function definition 14.1
- Functional programming 2.2.3
- get() 20.2
- git 8.3
- GitHub 8.4
- global name 15.2
- global scope 15.2
- global variable 15.2
- guard 18.3.1
- handler 23.2
- help() 14.4.1
- helper function 14.7
- high-level language 2.1.3
- home directory 4.1
- identifier 5.4
- identity 22.3
- if statement 10.2
- immutable 11.4
- implementation 9.1
- implicit conversion 7.5
- import 12.1
- Indentation 10.2
- independent decisions 10.5
- index 11.3
- Indirection 9.1
- infinite loop 13.2.1
- inner loop 24.7
- insert() 17.3
- insertion order 20.5
- int 7.3
- integer division 6.2
- integrated circuit 1.8
- integrated development environment 8.1
- intermediate variable 12.2.2
- interpreter 2.1.4, 3.3
- isinstance() 25.5
- items() 20.5
- iterable 17.3
- iteration 13.1
- join() 17.8
- JSON 24.5
- key 20.1
- key-value pair 20.1
- KeyError 20.2
- keyword 3.4
- keyword argument 18.5.1
- LBYL 23.2
- list 17.1
- list literal 17.1
- list of dictionaries 21.2
- literal 5.2
- local name 15.1
- local scope 15.1
- local variable 15.1
- logic error 16.2
- Logic programming 2.2.4
- logical operators 6.4
- loop 13
- loop variable 13.3
- low-level language 2.1.3
- machine language 2.1.1
- main function 14.8.1
- main guard 14.8.1
- math.isclose() 18.5.1
- membership 17.1, 18.4
- membership test 17.1, 18.4
- memory 1.7
- method 11.4
- microprocessor 1.8
- minimizing scope 15.4
- module 12, 14.8
- module docstring 14.4.1
- modulo 6.2
- mutable 17.2
- named argument 18.5.1
- nested data 21.2
- nested decision 10.5
- nested loop 24.7
- newline 11.2
- None 14.3
- object 2.2.2, 22.1
- Object-oriented programming 2.2.2
- off-by-one error 13.2
- operand 6.1
- operator 6.1
- outer loop 24.7
- paradigm 2.2
- Parallelogram 9.4
- parameter 14.1
- path 4.1
- Pattern recognition 9.2, 9.3
- pop() 17.3.1
- precedence rules 6.6
- predicate 14.5
- predicate function 14.5
- Procedural programming 2.2.1
- programming lifecycle 9.1
- project 8.2
- prompt 7.1
- pseudocode 9.4
- Pull 8.4
- pure function 2.2.3
- push 8.4
- pytest 16.5
- raise 23.1, 23.5
- raises 23.1
- raising 23.1
- range 19.1
- read-eval-print loop 2.1.4
- record 21.1
- Rectangle 9.4
- recursion 25
- RecursionError 25.2
- recursive call 25.1
- reference 22.1
- register 2.1.2
- relative path 4.2
- remainder 6.2
- remove() 17.3.1, 19.3.1
- repetition 9.1, 9.4
- REPL 2.1.4, 3.3.2
- repository 8.3
- representation error 18.5
- return value 14.2
- root directory 4.1
- round() 18.6
- runtime error 16.1.2
- scope 15.1
- selection 9.1, 9.4, 10.2
- semantic error 16.2
- sequence 9.1, 9.4, 17.1
- set 19.3
- shadowing 15.3
- shallow copy 22.4.1
- short-circuit evaluation 18.3
- short-circuiting 18.3
- side effect 2.2.3
- slice 17.7
- slicing 17.7
- sort() 17.6
- source file 3.3.6
- split() 17.8
- stack 25.1
- Stage 8.3
- standard library 12
- startswith() 24.3.1
- state 2.2.3
- statement 5.1
- step 17.7
- stored-program computer 1.6
- str 7.3
- string 11
- string literal 11.1
- Structured programming 9.1
- syntax error 16.1.1
- syntax highlighting 8.1
- tab 11.2
- Termini 9.4
- test case 16.4
- test suite 16.5
- Testing 10.6
- tolerance 18.5.1
- trace 9.5, 10.6, 13.5
- traceback 16.1.2
- transistor 1.8
- truth value testing 18.2
- truthiness 18.2
- try 23.2
- tuple 19.2
- type 7.3
- type conversion 7.4
- UnboundLocalError 15.1.1
- union 19.3.2
- universal Turing machine 1.6
- unpacking 19.2
- vacuum tube 1.3
- value 20.1
- values() 20.5
- variable 5.2
- virtual environment 8.2
- while loop 13.2
- whitespace 11.6
- with 24.2
- Zen of Python 3.5