💻 Computer Science · Undergraduate · CS 101

Introduction to Programming with Python

A complete first course in programming that assumes you have never written a line of code. Using Python 3, you will learn to think like a programmer, from your first print() to writing your own classes and algorithms. Every week pairs a written lesson and runnable examples with free full-text readings, so you can learn the whole subject at your own pace for free.

Start the interactive course (quizzes, progress, videos) →

Free forever. No sign-up, no ads. 16 lessons. The full lesson text is below so you can read it right here.

Week 1 - How Computers Run Code, Setup & print()

Install Python and run your first program

  • Explain what a program and an interpreter are.
  • Set up Python 3 and run a script.
  • Use print() to display output.

Welcome to your first real programming lesson. By the end of this session you will understand what a computer program actually is, how the machine turns your typing into action, and you will have written and run genuine Python code. We are going to move slowly and carefully, because the ideas in this first week are the foundation that everything else in the course stands on. There is no prior experience assumed. If you have never written a line of code before, you are exactly the person this lesson was written for.

What is a computer program, really?

A computer program is a list of instructions that a computer carries out, one after another, exactly as written. That word "exactly" is the most important idea in this entire lesson. A computer is astonishingly fast, able to perform billions of operations per second, and it is perfectly obedient. But it is not clever, and it does not guess what you meant. It does precisely what you tell it, in precisely the order you tell it. This is very different from talking to another person. If you ask a friend to "grab me a coffee," they fill in a hundred unstated details: which mug, how much milk, where the kitchen is. A computer fills in nothing. Every step must be spelled out.

Because of this, most of the skill of programming is not about memorizing commands. It is about learning to think clearly and to break a task down into steps so small and unambiguous that a literal-minded machine can follow them. This is sometimes called computational thinking, and it is a skill that will improve the way you reason about problems even outside of a computer. Consider a simple everyday task like making a peanut butter sandwich. A human recipe might say "spread peanut butter on the bread." A program-style recipe would have to say: pick up the knife by the handle, lower it into the jar, rotate it to collect peanut butter, lift it out, move it above the left slice, lower it until it touches, drag it side to side to spread. Programming trains you to see the hidden steps.

We will write our instructions in Python 3, a programming language created by Guido van Rossum and first released in 1991. Python was designed from the start to be readable and friendly to newcomers, favoring clear English-like words over cryptic symbols. That readability is not just a beginner convenience. Python is one of the most widely used languages in the professional world, powering data analysis at banks, the recommendation systems behind streaming services, scientific research, web applications, and automation scripts that quietly do the boring parts of people's jobs. Learning Python is both an excellent on-ramp for a beginner and a genuinely marketable skill.

Hardware, software, and where code fits

Before we write anything, it helps to have a rough mental map of what is inside the machine. The physical parts of a computer, the chips and circuits you could touch, are called hardware. The two pieces of hardware that matter most to a programmer are the CPU (central processing unit), which is the part that actually performs calculations and follows instructions, and memory (often called RAM), which is the fast, temporary workspace where the computer holds the data and instructions it is currently using. There is also long-term storage, your hard drive or solid-state drive, where files are kept even when the power is off.

Software is the collective name for all the programs that run on that hardware. The instructions you write are software. So is the web browser you use, the operating system such as Windows, macOS, or Linux that manages everything, and Python itself. A useful way to picture it: the CPU is a worker who can only understand a very primitive set of commands, memory is the desk where the worker keeps what it is working on right now, and your program is a to-do list handed to that worker.

Here is a subtlety that surprises many beginners. The CPU does not actually understand Python. Deep down, a CPU only understands machine code, which is patterns of ones and zeros representing extremely basic operations like "add these two numbers" or "move this value from here to there." Nobody wants to write software in ones and zeros. So we write in a human-friendly language like Python, and a special program does the work of translating.

Source code and the interpreter

The text you write in a programming language is called source code, or just "code." It is ordinary text, the same kind of characters you would type in any document, saved in a plain text file. What makes it special is that it follows the precise rules, the syntax, of a programming language, so that a translating program can make sense of it.

Python is an interpreted language. This means that when you run your program, a piece of software called the interpreter reads your source code and carries out its instructions, working through them essentially line by line. You can think of the interpreter as a live translator standing between you and the CPU, reading each Python instruction, figuring out what machine-level actions it requires, and making them happen. Some other languages, such as C or Rust, are compiled instead: a compiler translates the entire program into machine code ahead of time, producing a standalone executable file, and only then does it run. The interpreted style that Python uses makes it wonderfully convenient for learning, because you can write a line, run it, and see the result immediately, without a separate build step.

There are two main ways to run Python code, and you will use both throughout this course.

  • The REPL, which stands for read-eval-print loop. This is an interactive session where you type one line of Python, press Enter, and the interpreter immediately reads it, evaluates it, prints the result, and loops back to wait for your next line. The REPL is like a conversation with Python. It is perfect for quick experiments, checking what a piece of code does, or using Python as a calculator. You start it by typing python (or on some systems python3) at your terminal and pressing Enter. You will see a prompt made of three greater-than signs.
  • A script, which is your code saved in a file whose name ends in .py. When you have more than a few lines, or you want to save and reuse your program, you write it in a file. You then run the whole file at once. This is how real programs are delivered.

Installing Python and setting up your tools

To follow along you need Python 3 installed on your computer. Go to the official website, python.org, and download the latest version of Python 3 for your operating system. Run the installer. On Windows there is one small but important detail: the first installer screen has a checkbox that says something like "Add Python to PATH." Check that box before clicking Install. It lets you run Python by simply typing python from any folder in your terminal. If you forget, you can always re-run the installer and check it later.

The terminal (also called the command line, the shell, or the console) is a text-based way to give commands to your computer. On Windows it is the program called Command Prompt or PowerShell; on macOS and Linux it is called Terminal. Do not be intimidated by it. For now you only need to know two things: you type a command and press Enter to run it, and you can check that Python is installed by typing this and pressing Enter.

python --version

If Python is installed correctly you will see something like "Python 3.12.4" printed back to you. The exact numbers do not matter as long as the first number is 3. If you instead see an error saying the command is not found, Python is either not installed or not on your PATH, and re-running the installer with the PATH box checked usually fixes it.

You will also want a good place to write code. A plain text editor works, but a purpose-built code editor makes life much easier by coloring your code, catching typos, and letting you run files with a click. A popular free choice is Visual Studio Code. Python also ships with a simple editor called IDLE that is perfectly fine for this course. Any of these is a fine starting point; the language you learn is the same regardless of the editor.

Your first program: print()

By long-standing tradition, the very first program in almost every language displays a short greeting on the screen, usually "Hello, world!" Let us write it. In Python, showing text on the screen is done with print(). This is a function, which is one of the central concepts of the whole course. A function is a named, reusable action. You use, or call, a function by writing its name followed by a pair of parentheses. Whatever you put inside those parentheses is the argument, the piece of information you are handing to the function to work with.

The text we want to print is written inside quotation marks. A piece of text in quotes is called a string, because it is a string of characters strung together. Both double quotes and single quotes work in Python; we will mostly use double quotes in this course for consistency. Here is the program.

print("Hello, world!")

Read it slowly. The word print is the name of the function. The parentheses say "call this function now." Inside the parentheses, the string "Hello, world!" is the argument, the thing we want printed. Save this single line in a file called hello.py, open your terminal in the folder where you saved it, and run it with this command.

python hello.py

The interpreter reads your file, sees the call to print, and displays the text. You will see this appear in your terminal:

Hello, world!

Take a moment to appreciate what just happened. You wrote an instruction in a human-readable language, and a real computer carried it out. You are now, by any reasonable definition, a programmer.

Printing more, and printing several things at once

A program is usually more than one line. When the interpreter runs a script, it executes the lines from top to bottom, in order. Let us print three lines. Notice that print() automatically moves down to a new line after it finishes, so each call to print produces its own line of output.

print("Hello, world!")
print("I am learning Python.")
print("This is fun.")

Running that produces three separate lines. The order is exactly the order the lines appear in your file, because the interpreter never jumps around unless you specifically tell it to (which is what conditionals and loops, later in the course, are for).

You can also hand print() more than one argument by separating them with commas. When you do, print displays them all on the same line with a single space between each. The arguments do not all have to be strings. Python can print numbers too, and it can even perform arithmetic first and print the result.

print("Numbers work too:", 2 + 2)

This prints the line Numbers work too: 4. Look closely at what happened. The first argument, the string "Numbers work too:", was printed literally, character for character, because it is in quotes. But the second argument, 2 + 2, has no quotes around it, so Python treats it as a calculation to perform. It works out that two plus two is four, and prints the number 4. The space between the colon and the 4 comes from print's automatic spacing between comma-separated arguments. This distinction, between text in quotes that is printed as-is and code without quotes that is evaluated, is a distinction you will use constantly, so it is worth fixing firmly in your mind now.

Comments: notes to human readers

Code is read by people far more often than it is written, including by your future self who has forgotten what a piece of code was for. To leave notes inside your program that the interpreter ignores, you use a comment. In Python, anything on a line after a hash mark, the # character, is a comment. Python skips it entirely; it exists purely to explain things to human readers.

# This program greets the user.
print("Hello, world!")   # this line does the greeting

Both the full-line comment and the note at the end of the second line are ignored by Python when it runs the program. Good comments explain why code does something, not just what it does. As a beginner, err on the side of commenting generously; it reinforces your own understanding.

Understanding errors: your first bugs

You will make mistakes. Every programmer does, all day, every day. The difference between a frustrated beginner and a confident one is simply comfort with errors. When Python cannot understand or cannot carry out your code, it stops and prints an error message describing the problem. These messages look scary at first but are genuinely trying to help you. Let us deliberately trigger a couple so they lose their power to frighten.

Suppose you forget the closing quotation mark:

print("Hello)

Python will report a SyntaxError, meaning your code broke the grammatical rules of the language. A syntax error is like a sentence missing a closing bracket; the interpreter cannot even parse it. The fix here is to add the missing quote. Now suppose you misspell the function name:

prnt("Hello")

Python will report a NameError saying that the name prnt is not defined. It does not know any function called prnt; you meant print. Reading the last line of an error message, which names the type of error and often points at the line number, is a skill you will build quickly. Errors are not failures. They are the interpreter telling you exactly where it got confused so you can fix it. Welcome them.

A slightly bigger example

Let us put the pieces together into a short program that a person might actually recognize as doing something. It prints a small decorative banner. Notice how comments explain the sections, and how some print calls use commas and some print calculations.

# A simple welcome banner
print("*******************************")
print("*   Welcome to Python 101!    *")
print("*******************************")
print("Today we learned to print.")
print("2 plus 3 is", 2 + 3)
print("And Python counts fast: 100 times 100 is", 100 * 100)

Running this displays the banner, a friendly message, and two lines that mix text with computed numbers. The asterisk, *, means multiply when used between two numbers, so 100 * 100 becomes 10000. Every technique in this program is something you learned in this single lesson, and yet together they already feel like a real program.

Common misconceptions to clear up now

  • "The computer understood what I meant." It did not. It followed exactly what you wrote. If the output is wrong, the instructions were wrong, not the machine.
  • "Quotes are optional." They are not. print("Hello") prints the word Hello, but print(Hello) without quotes makes Python look for a thing named Hello, and since none exists, it errors. Quotes are what turn characters into a string.
  • "Errors mean I am bad at this." Errors mean you are programming. Professionals see errors constantly. The goal is not to avoid them but to read and fix them calmly.
  • "Capitalization does not matter." It matters a great deal. Print with a capital P is not the same as print, and Python will not recognize the capitalized version. Programming languages are case-sensitive.

Recap

In this first session you learned that a program is a precise, ordered list of instructions, and that a computer follows them literally rather than intelligently. You saw the difference between hardware and software, and learned that the CPU only understands machine code, which is why we write in a friendly language like Python and rely on the interpreter to translate and execute our code line by line. You installed Python 3, checked the version from the terminal, and learned the two ways to run code: the interactive REPL for quick experiments and saved .py script files for real programs. Most importantly, you wrote your first program using the print() function, learned that it is a function you call with parentheses, that the text inside quotes is a string argument, and that print can display several comma-separated items and even evaluate calculations before printing them. You learned to leave comments with the hash mark, and you met your first error messages and saw that they are helpful guides rather than punishments. Next week we will give our programs a memory by storing values in variables.

Key terms
Program
A sequence of instructions that a computer carries out in order.
Source code
The human-readable text you write in a programming language.
Interpreter
The program that reads Python code and executes it line by line.
REPL
The interactive read-eval-print loop where you type code and see results at once.
Function
A named, reusable action you invoke by writing its name and parentheses.
Argument
A value you pass into a function inside its parentheses.

Week 2 - Variables, Data Types & Numbers

Store values and do arithmetic

  • Create variables with assignment.
  • Identify int, float, str, and bool types.
  • Use arithmetic operators correctly.

Last week your programs could only print fixed text. This week they gain a memory. Everything interesting a program does, from adding up a shopping cart to remembering a player's score, depends on the ability to store values, give them names, and work with them. That is what variables are for. By the end of this session you will be able to create variables, understand the different kinds of values Python can hold, and use Python as a powerful calculator. These are among the most fundamental skills in all of programming, and we will take them one careful step at a time.

What is a variable?

A variable is a name that refers to a value. The classic beginner analogy is a labeled box: you put a value into the box, write a name on the label, and later you can retrieve the value just by naming the box. This picture is good enough to start with, and we will refine it shortly. The point is that a variable lets you store a piece of information now and use it, or change it, later.

You create a variable with an assignment statement, which uses the equals sign. The rule is simple and never changes: the name goes on the left of the =, and the value goes on the right. Python evaluates whatever is on the right, then makes the name on the left refer to that value.

age = 20
price = 4.99
name = "Ada"

After these three lines, Python knows three names. The name age refers to the whole number 20, the name price refers to the decimal number 4.99, and the name name refers to the text "Ada". To use a variable, you simply write its name. Wherever a name appears in your code, Python substitutes the value it currently refers to.

age = 20
name = "Ada"
print(name, "is", age, "years old")

This prints Ada is 20 years old. Notice that name and age were replaced by their values, while the strings "is" and "years old" were printed literally because they are in quotes. That contrast, between a bare name that gets looked up and quoted text that is taken literally, is the same distinction you met last week with print, and it runs through the entire language.

A subtle but important refinement of the box picture

The labeled-box image is helpful, but Python actually works a little differently, and knowing the real story will save you confusion later. In Python, values live in memory, and a variable is more like a name tag that points at a value than a box that contains one. When you write x = 5, Python creates the value 5 somewhere in memory and makes the name x point to it. If you then write y = x, you do not copy the value into a second box; instead you make y point at the very same value that x points at. For simple values like numbers this distinction rarely matters, but it becomes important later when we reach lists, so plant the seed now: variables are names that refer to values, not containers that hold them.

Reassignment: variables can change

The whole reason variables are called "variable" is that what they refer to can change over time. You reassign a variable simply by assigning to it again. The new value replaces the old one; the old value is forgotten (unless another name still points to it).

score = 0
print(score)     # 0
score = 10
print(score)     # 10
score = score + 5
print(score)     # 15

That third assignment deserves a slow reading because it confuses almost every beginner at first. The line score = score + 5 is not a mathematical equation claiming that score equals score plus five, which would be impossible. It is an instruction. Python evaluates the right-hand side first, using the current value of score, which is 10, so the right side becomes 10 + 5, which is 15. Only then does it assign that result back to score. The old value 10 is gone, replaced by 15. Reading assignment as "compute the right side, then make the left name refer to it" will keep you out of trouble every time.

This pattern of updating a variable based on its own current value is so common that Python offers shorthand called augmented assignment. Writing score += 5 means exactly the same as score = score + 5. There are matching forms for the other operators too: -=, *=, /=, and so on. They are pure convenience, and you will see them everywhere in real code.

total = 100
total += 20      # now 120
total -= 5       # now 115
total *= 2       # now 230
print(total)     # 230

Naming variables well

You have a lot of freedom in choosing names, but a few rules and many conventions apply. The rules, which Python enforces, are: a name may contain letters, digits, and the underscore character; it may not start with a digit; and it may not be one of Python's reserved keywords such as if, for, or print is technically usable but a very bad idea because it shadows the built-in function. Names are case-sensitive, so age, Age, and AGE are three different variables.

Beyond the rules are the conventions that separate readable code from a mess. Choose names that describe what the value means. A variable called total_price tells a reader far more than one called tp or x. The standard Python style, described in a widely followed guide called PEP 8, is to write variable names in lowercase with underscores between words, a style often called snake_case. So you would write student_count and average_score, not studentCount or AverageScore. Good naming is not decoration; it is one of the most effective things you can do to make your programs understandable, including to yourself a week from now.

# Hard to read
x = 19.99
y = 3
z = x * y

# Easy to read
unit_price = 19.99
quantity = 3
total_cost = unit_price * quantity

Data types: the kinds of values

Every value in Python has a type, which is the category the value belongs to. The type determines what you can do with the value. You can add numbers and multiply them, but "adding" two pieces of text does something different, and "multiplying" text by a number does something different again. This week we focus on four core types that you will use constantly.

TypeName in PythonWhat it holdsExamples
IntegerintWhole numbers, positive, negative, or zero, with no decimal point20, 0, -7, 1000000
Floating-pointfloatNumbers with a decimal point4.99, 3.0, -0.5
StringstrText, any characters, written in quotes"Ada", "hello world", "42"
BooleanboolA truth value, only two possibleTrue, False

You can ask Python the type of any value with the built-in type() function. This is a handy debugging tool when you are unsure what you are working with.

print(type(20))        # class int
print(type(4.99))      # class float
print(type("Ada"))     # class str
print(type(True))      # class bool

One of Python's conveniences is that you never have to declare a variable's type. Python figures out the type automatically from the value you assign. This is called dynamic typing. In some other languages you must announce "this variable will hold an integer" before using it; in Python you just assign a value and Python infers the type. A consequence is that a variable can even refer to a value of one type now and a different type later, though doing so casually tends to make code confusing, so it is best avoided.

The difference between int and float, and why "42" is not 42

The distinction between an integer and a float matters more than it may seem. The value 3 is an int, a whole number. The value 3.0 is a float, because it is written with a decimal point, even though its fractional part is zero. They may look almost the same to you, but Python considers them different types, and certain operations produce one or the other. In particular, ordinary division always produces a float, as you will see below.

Even more important is the difference between a number and a string that looks like a number. The value 42 is an integer you can do arithmetic with. The value "42", with quotes, is a string, a piece of text that happens to consist of two digit characters. You cannot do arithmetic on it in the usual sense. This distinction is the source of countless beginner bugs, especially next week when we read input from the user, because input always arrives as a string. Keep it sharp in your mind: quotes make a string, no quotes (for a numeric literal) makes a number.

a = 42
b = "42"
print(a + 8)     # 50, arithmetic on an int
print(b + "8")   # "428", joining two strings
# print(b + 8)   # this would be an ERROR: cannot add a string and an int

Converting between types

Because these types are distinct, you often need to convert a value from one type to another. Python provides conversion functions named after the target type. int() turns a value into an integer, float() into a float, and str() into a string. Converting the string "42" into the number 42 with int("42") is something you will do constantly. Converting a number into a string with str() is useful when you want to join it with other text.

text = "100"
number = int(text)       # the integer 100
print(number + 1)        # 101

pi = 3.14159
whole = int(pi)          # 3, the fractional part is dropped, not rounded
print(whole)

count = 7
label = "You have " + str(count) + " messages"
print(label)             # You have 7 messages

Two things to note. First, converting a float to an int with int() simply chops off everything after the decimal point; it does not round, so int(3.99) is 3, not 4. If you want proper rounding, use the separate round() function. Second, conversion only works when it makes sense. Calling int("hello") raises an error, because "hello" does not describe a number. We will learn to handle such errors gracefully in a later week; for now, just know that conversions can fail if the input is not appropriate.

Arithmetic: Python as a calculator

Python is an excellent calculator, and arithmetic is where numeric types come alive. The basic operators are the ones you would expect: + for addition, - for subtraction, * for multiplication, and / for division. Note that multiplication uses an asterisk, not the letter x, and division uses a forward slash.

print(7 + 3)     # 10
print(7 - 3)     # 4
print(7 * 3)     # 21
print(7 / 3)     # 2.3333333333333335

That last result reveals something important: division with / always produces a float, even when the numbers divide evenly. So 10 / 2 gives 5.0, a float, not 5, an int. This trips people up, so remember it: the single slash always yields a float.

Python also gives you three more numeric operators that are extremely useful and less familiar to newcomers.

  • Floor division, written with two slashes //, divides and then throws away the fractional part, giving you the whole number of times one value fits into another. So 17 // 5 is 3, because 5 fits into 17 three whole times.
  • Modulo, written with a percent sign %, gives the remainder after division. So 17 % 5 is 2, because after taking out three fives from 17, two are left over. Modulo is surprisingly powerful. It is how you test whether a number is even (a number is even exactly when n % 2 equals 0), how you wrap values around a range, and how you extract digits.
  • Exponentiation, written with two asterisks **, raises a number to a power. So 2 ** 10 is 2 to the tenth power, which is 1024.
total = 17
people = 5
print(total // people)   # 3  (whole groups of 5 in 17)
print(total % people)    # 2  (2 left over)
print(2 ** 10)           # 1024
print(10 % 2)            # 0, so 10 is even
print(7 % 2)             # 1, so 7 is odd

A concrete use of floor division and modulo together: converting a total number of minutes into hours and minutes. If you have 137 minutes, then 137 // 60 gives 2 hours, and 137 % 60 gives 17 leftover minutes, for 2 hours and 17 minutes. This pairing of // and % to split a quantity into whole units plus a remainder is a pattern you will reach for again and again.

minutes = 137
hours = minutes // 60
leftover = minutes % 60
print(hours, "hours and", leftover, "minutes")   # 2 hours and 17 minutes

Order of operations

When an expression combines several operators, Python follows the standard mathematical order of operations, sometimes remembered as PEMDAS: parentheses first, then exponents, then multiplication and division (including floor division and modulo), and finally addition and subtraction, working left to right among operators of the same level. This means 2 + 3 * 4 is 14, not 20, because the multiplication happens before the addition. When in doubt, or simply to make your intent unmistakable to a human reader, add parentheses. There is no penalty for extra parentheses, and they often make code clearer.

print(2 + 3 * 4)       # 14, multiplication first
print((2 + 3) * 4)     # 20, parentheses force addition first
print(2 ** 3 * 2)      # 16, exponent first (8), then times 2
print(10 - 2 - 3)      # 5, left to right

Booleans, briefly

The fourth type this week is the boolean, which has only two possible values, True and False, written with a capital first letter. Booleans represent truth values, and they are the answer Python gives when you ask a yes-or-no question, such as whether one number is bigger than another. We will build a whole week around booleans and decision-making soon, but you should meet them now because they are a core type. Comparisons produce booleans.

print(5 > 3)      # True
print(5 == 5)     # True
print(2 > 10)     # False
is_adult = age >= 18
print(is_adult)   # True or False depending on age

A worked example bringing it together

Let us write a small program that models a simple purchase. It stores prices and quantities in well-named variables, computes a subtotal, adds tax, and prints a clear summary. Every technique here comes from this lesson.

# Shopping calculation
unit_price = 3.50
quantity = 4
tax_rate = 0.08          # 8 percent, written as a decimal

subtotal = unit_price * quantity
tax = subtotal * tax_rate
total = subtotal + tax

print("Items:", quantity)
print("Subtotal:", subtotal)
print("Tax:", tax)
print("Total:", total)
print("Rounded total:", round(total, 2))

Running this computes a subtotal of 14.0, tax of 1.12, and a total of 15.12. The round(total, 2) call rounds the total to two decimal places, which is exactly what you want for money. Notice how the descriptive variable names make the logic read almost like plain English, and how each line builds on the ones before it.

A note on floating-point surprises

Here is something that startles nearly every new programmer. Try printing 0.1 + 0.2 and you will not get the clean 0.3 you expect; you will get 0.30000000000000004. This is not a bug in Python. Computers store floats in binary, and just as one third cannot be written exactly as a finite decimal (0.3333...), many decimals like 0.1 cannot be stored exactly in binary. Tiny rounding errors result. For everyday programs this is harmless, and you handle it by rounding when you display values, as we did above with round(). The lesson to carry forward is simply that floats are approximations, and you should not rely on two floats being exactly equal after arithmetic. It is a real property of essentially all programming languages, not a Python quirk.

print(0.1 + 0.2)              # 0.30000000000000004
print(round(0.1 + 0.2, 2))   # 0.3, fixed for display

Common mistakes and how to fix them

  • Confusing = and ==. A single equals sign assigns a value; a double equals sign compares two values. Using one where you mean the other is a classic error. We will return to this in the conditionals week, but start the habit now: assignment uses one sign, comparison uses two.
  • Forgetting that division gives a float. If you expected 10 / 2 to be the integer 5 and it printed 5.0, that is why. Use floor division // if you truly want a whole number.
  • Adding a string and a number. Writing "Age: " + 20 raises a TypeError, because you cannot join text and a number directly. Convert the number first with str(20), or better, let print handle it with a comma: print("Age:", 20).
  • Using a variable before assigning it. If you reference a name that has never been assigned, Python raises a NameError. Make sure every variable is given a value before you use it.
  • Poor names. Single-letter names like a and b are fine for tiny throwaway math, but for anything you will read later, use descriptive names. Your future self will thank you.

Recap

This week you gave your programs a memory. You learned that a variable is a name that refers to a value, that you create and update variables with the assignment operator by computing the right side and binding it to the left name, and that the shorthand augmented assignment operators like += make updating concise. You saw that every value has a type, met the four core types int, float, str, and bool, and learned to inspect a value's type with type() and to convert between types with int(), float(), and str(). You practiced Python's arithmetic operators, including the important trio of floor division, modulo, and exponentiation, and you saw how floor division and modulo work together to split a quantity into whole units and a remainder. You learned the order of operations and the value of parentheses, got a first look at booleans and comparisons, and even confronted the reality that floats are approximations. With variables and arithmetic in hand, you are ready for next week, where your programs will start talking with the user by reading input and working with text.

Key terms
Variable
A name that refers to a stored value.
Assignment
Binding a value to a variable name using the = sign.
int
The integer type, for whole numbers.
float
The type for numbers with a decimal point.
Modulo (%)
An operator that returns the remainder of a division.
Floor division (//)
Division that discards the remainder and returns a whole number.

Week 3 - Strings & Input/Output

Work with text and read user input

  • Concatenate and index strings.
  • Read input with the input() function.
  • Convert between strings and numbers.

So far your programs have been monologues: they run and print, but they cannot listen. This week they learn to have a conversation. You will read what the user types, work with text in detail, and build polished output that mixes words and computed values. Text is one of the most common kinds of data in the world, and Python treats text as a first-class citizen with a rich set of tools. We will start by looking closely at what a string really is, then learn to read input, convert between text and numbers, and format output beautifully.

What a string is, precisely

A string is an ordered sequence of characters. Each character can be a letter, a digit, a space, a punctuation mark, or almost any symbol. You write a string by surrounding the characters with quotation marks. Python accepts single quotes and double quotes interchangeably, so 'hello' and "hello" are the same string. Having both is handy: if your text contains an apostrophe, wrap it in double quotes, and if it contains a double quote, wrap it in single quotes, so you do not have to fight with the quote characters.

greeting = "Hello there"
name = 'Ada Lovelace'
sentence = "She said, 'good morning' cheerfully."
apostrophe = 'It is a fine day.'
print(sentence)
print(apostrophe)

The word "ordered" in "ordered sequence" is meaningful. The characters have a definite left-to-right order, and Python lets you refer to any character by its position. An empty string, written as "" with nothing between the quotes, is perfectly valid and represents text with zero characters; it comes up more often than you might guess.

Length, concatenation, and repetition

Three basic operations get you started with strings. The built-in len() function tells you how many characters a string contains. The + operator, when both operands are strings, performs concatenation, joining them end to end into a new, longer string. And the * operator, used between a string and a number, performs repetition, producing the string repeated that many times.

word = "Python"
print(len(word))        # 6
print(word + "!")       # Python!
print(word + " " + word)  # Python Python
print(word * 3)         # PythonPythonPython
print("-" * 20)         # a line of 20 dashes

Concatenation only works between strings. If you try to concatenate a string and a number, such as "Age: " + 30, Python raises a TypeError, because it refuses to guess whether you meant to join text or add numbers. The fix is to convert the number to a string first with str(30), giving "Age: " + str(30). Repetition with a line of dashes, shown above, is a small trick that is genuinely useful for drawing separators in text output.

Indexing: reaching a single character

Because a string is an ordered sequence, each character sits at a numbered position called an index. Here is the single most important thing to internalize: indexing starts at zero, not one. The first character is at index 0, the second at index 1, and so on. You retrieve a character by writing the string (or a variable holding it) followed by square brackets containing the index.

word = "Python"
print(word[0])     # P   (the first character)
print(word[1])     # y
print(word[5])     # n   (the sixth and last character)

The word "Python" has six characters, so its valid indexes run from 0 through 5. Asking for word[6] raises an IndexError, because there is no seventh character. This off-by-one relationship between length and highest index (the last valid index is always length minus one) is worth remembering, because index errors are among the most common bugs in all of programming.

Python also supports negative indexing, which counts from the end. Index -1 is the last character, -2 is the second to last, and so on. This is a genuine convenience, because you can grab the final character of a string without knowing its length.

word = "Python"
print(word[-1])    # n   (last character)
print(word[-2])    # o   (second to last)

Slicing: reaching a range of characters

Grabbing a single character is useful, but often you want a chunk. A slice extracts a substring using two positions inside the square brackets, separated by a colon: a start index and a stop index, written as text[start:stop]. The slice includes the character at the start index but stops before the stop index. This "up to but not including the stop" rule is the same convention you will meet again with the range function, so learning it here pays off twice.

word = "Python"
print(word[0:3])    # Pyt   (characters 0, 1, 2)
print(word[2:5])    # tho   (characters 2, 3, 4)
print(word[0:6])    # Python (the whole word)

Slicing has convenient shortcuts. If you leave out the start, it defaults to the beginning; if you leave out the stop, it defaults to the end. So word[:3] means the first three characters, and word[3:] means everything from index 3 onward. Leaving out both, word[:], gives a copy of the whole string. These shortcuts read cleanly once you are used to them.

word = "Python"
print(word[:3])     # Pyt   (start defaults to 0)
print(word[3:])     # hon   (stop defaults to the end)
print(word[-3:])    # hon   (last three characters)

Strings are immutable

There is one rule about strings that surprises beginners and prevents a whole class of bugs once you understand it: strings are immutable, meaning that once a string exists, its characters cannot be changed in place. You cannot do word[0] = "J" to turn "Python" into "Jython"; Python raises an error. Instead, you build a new string. For example, to change the first letter you might write "J" + word[1:], which concatenates a "J" with everything after the first character, producing a brand new string. The original is untouched. This is why all the string methods you will meet, such as the ones that change case, return a new string rather than modifying the one you started with. Keep this in mind: string operations produce new strings.

word = "Python"
new_word = "J" + word[1:]
print(new_word)     # Jython
print(word)         # Python  (unchanged)

Talking with the user: the input function

Now for the star of the week. The built-in input() function lets your program read text typed by the user. When Python reaches an input() call, it pauses the whole program, optionally displays a prompt message you provide as an argument, and waits. The user types a line and presses Enter. The function then returns whatever they typed, and your program continues, usually storing that returned text in a variable.

name = input("What is your name? ")
print("Nice to meet you, " + name + "!")

When you run this, the program prints "What is your name? " and waits. If you type Ada and press Enter, it continues and prints "Nice to meet you, Ada!" The prompt string is just there to tell the user what to type; a trailing space in the prompt, as shown, keeps the user's typing from being jammed against the question mark, which looks nicer.

The single most important fact about input()

Here is a fact so important it causes more beginner bugs than almost anything else, so read it twice: input() always returns a string. Always. Even if the user types the digits 42, what comes back is the string "42", not the number 42. This makes sense, because the user could type anything, and text is the most general form. But it means that if you want to do arithmetic with the input, you must convert it to a number first, using int() for whole numbers or float() for decimals. Forget this, and you will get either wrong answers or an outright error.

years = input("How old are you? ")
print(type(years))          # class str, even though it looks like a number

# To do math, convert first:
years_number = int(years)
print("Next year you will be", years_number + 1)

A common and clean pattern is to wrap the input call directly inside the conversion, so the value is a number the moment it lands in your variable. This is worth memorizing as a standard shape.

years = int(input("How old are you? "))
print("Next year you will be", years + 1)

To see why the conversion matters, look at what goes wrong without it. If years holds the string "20", then years + 1 tries to add a string and a number, which errors. And if you accidentally concatenate, years + "1" gives the string "201", not 21. Both mistakes vanish once you convert with int(). Converting the other direction, from a number to text, uses str(), which is what lets you concatenate a number into a message, as in "You are " + str(20) + " years old".

A complete interactive example

Let us combine input, conversion, and arithmetic into a small program that feels genuinely interactive. It asks for two numbers and reports their sum, difference, and product.

print("Simple Calculator")
first = float(input("Enter the first number: "))
second = float(input("Enter the second number: "))

print("Sum:", first + second)
print("Difference:", first - second)
print("Product:", first * second)

We used float() rather than int() here so the program accepts decimals like 3.5. If the user types something that is not a number, such as "hello", the conversion will raise a ValueError and the program will stop. Handling that gracefully is a topic for a later week on exceptions; for now, we assume cooperative input.

Formatting output with f-strings

Concatenating strings with plus signs and str() calls works, but it gets clumsy fast, with quotes and plus signs everywhere. Python offers a much cleaner tool called the f-string, short for formatted string. You create one by putting the letter f immediately before the opening quote. Inside the string, anything you place in curly braces is treated as Python code, evaluated, and its result dropped into the text at that spot. This lets you write output that reads almost like a sentence with blanks filled in.

name = "Ada"
age = 36
print(f"{name} is {age} years old.")   # Ada is 36 years old.

Compare that to the concatenation version, print(name + " is " + str(age) + " years old."), and you can see why f-strings are the preferred style in modern Python. They are shorter, easier to read, and you do not need to call str() because the f-string converts values to text for you automatically. The curly braces can hold not just a variable but any expression, including arithmetic, so you can compute right inside the string.

price = 4.5
qty = 3
print(f"{qty} items cost {price * qty} dollars.")   # 3 items cost 13.5 dollars.

f-strings also let you control formatting of numbers with a format specifier after a colon inside the braces. The most useful one for beginners rounds a float to a fixed number of decimal places, which is perfect for money. Writing a value followed by a colon and a format like point-two-f shows two digits after the decimal point.

total = 13.5
print(f"Total: {total:.2f} dollars")     # Total: 13.50 dollars
pi = 3.14159265
print(f"Pi is about {pi:.3f}")           # Pi is about 3.142

Here the specifier .2f means "format as a float with two digits after the decimal point," and .3f means three digits. This rounds for display without changing the underlying value, exactly what you want when presenting numbers to a person. Because f-strings are so central to clean output, they are the formatting style we will use for the rest of the course.

A few more handy string methods and escape characters

You will learn many string methods in a dedicated week later, but two ideas are useful right now. First, escape sequences: inside a string, a backslash followed by certain letters produces special characters. The most common is the newline, written as backslash-n, which moves output to a new line, and the tab, written as backslash-t. This lets a single print produce multiple lines.

print("Line one
Line two
Line three")
print("Name:	Ada")     # a tab between Name: and Ada

Second, you can ask whether one piece of text appears inside another using the in operator, which gives a boolean answer. This is a quick way to check for a substring.

email = "ada@example.com"
print("@" in email)     # True
print("xyz" in email)   # False

Common mistakes and how to fix them

  • Forgetting to convert input to a number. The number one beginner bug this week. If arithmetic on user input gives strange results or an error, check that you wrapped the input in int() or float().
  • Off-by-one index errors. Remember that valid indexes go from 0 to length minus one. Asking for the character at index equal to the length raises an IndexError.
  • Trying to change a character in place. Strings are immutable. Build a new string instead of assigning to word[0].
  • Mismatched or missing quotes. Every string needs matching opening and closing quotes of the same kind. A stray or missing quote produces a SyntaxError.
  • Forgetting the f before an f-string. If your curly braces print literally as {name} instead of the value, you left off the leading f.
  • Concatenating a string and a number. Use an f-string, a comma in print, or an explicit str() conversion. Plain + between text and a number errors.

Recap

This week your programs became interactive and gained real command of text. You learned that a string is an ordered, immutable sequence of characters written in quotes, and that you can measure it with len(), join strings with +, and repeat them with *. You learned to reach individual characters by index, starting from zero and even counting backward with negative indexes, and to extract ranges with slicing, remembering that a slice includes its start but stops before its stop. You met the input() function and burned into memory the crucial fact that it always returns a string, so numeric input must be converted with int() or float(), while str() converts the other way. Finally, you learned the modern, readable way to build output with f-strings, dropping variables and expressions into text with curly braces and even formatting numbers to a fixed number of decimal places. With input, text handling, and formatted output in your toolkit, you are ready to make your programs think, which begins next week with booleans and conditionals.

Key terms
String
An ordered sequence of characters, written in quotes.
Concatenation
Joining two strings end to end with the + operator.
Index
The position of a character in a string, starting at 0.
Slice
A substring extracted by a start and stop position.
input()
A function that reads a line of text typed by the user and returns it as a string.
f-string
A string prefixed with f that inserts variable values inside curly braces.

Week 4 - Booleans & Conditionals

Make decisions with if, elif, else

  • Write boolean expressions with comparisons.
  • Branch code using if/elif/else.
  • Combine conditions with and, or, not.

Until now your programs have run straight down the page, every line executed no matter what. Real programs need to make choices: charge a discount only if the customer is a member, print "pass" only if the score is high enough, warn the user only if the input is invalid. This week you give your programs the power of decision. The tools are booleans, comparisons, and the if statement, and together they let a program take different paths depending on the situation. This is where code starts to feel genuinely intelligent, and it is one of the most important weeks in the whole course.

Booleans: the yes-or-no values

You met booleans briefly a couple of weeks ago. Now they take center stage. A boolean is a value that is either True or False, and nothing else. Note the capital first letters; that is how Python writes them, and true or false in lowercase will cause an error. Booleans are the currency of decision-making: every decision a program makes comes down to evaluating a boolean and acting on whether it is True or False.

is_raining = True
has_umbrella = False
print(is_raining)        # True
print(type(is_raining))  # class bool

You rarely type True and False directly, though. Far more often, booleans are produced for you when you ask Python a question, and the way you ask questions is with comparison operators.

Comparison operators

A comparison operator compares two values and yields a boolean answer. Python has six of them, and they mirror ordinary mathematics, with one crucial twist for equality.

OperatorMeaningExampleResult
==equal to5 == 5True
!=not equal to5 != 3True
>greater than5 > 3True
<less than5 < 3False
>=greater than or equal to5 >= 5True
<=less than or equal to3 <= 5True
print(10 > 3)      # True
print(10 < 3)      # False
print(7 == 7)      # True
print(7 != 7)      # False
print(4 >= 4)      # True
print(5 <= 4)      # False

Comparisons are not limited to numbers. You can compare strings too, where == checks whether two strings are exactly the same, character for character, and the less-than and greater-than operators compare alphabetically (more precisely, by character codes, so uppercase letters sort before lowercase). This is how you check whether a user typed a particular command.

answer = "yes"
print(answer == "yes")   # True
print(answer == "Yes")   # False, capitalization differs
print("apple" < "banana")  # True, apple comes first alphabetically

The single equals versus double equals trap

This deserves its own section because it is the most common mistake beginners make, and the compiler will not always catch it. A single equals sign = is assignment: it stores a value in a variable. A double equals sign == is comparison: it asks whether two values are equal and produces a boolean. They are completely different operations that happen to look similar.

x = 5        # assignment: put 5 into x
x == 5       # comparison: is x equal to 5? (produces True)

If you accidentally write if x = 5: intending to compare, Python gives you a SyntaxError, which is annoying but at least tells you something is wrong. The mnemonic that saves people: one equals sign "makes it so," two equals signs "asks if so." Whenever you are inside an if statement testing equality, you want two.

The if statement

Now we can make decisions. An if statement runs a block of code only when its condition is True. The structure is the keyword if, then a boolean condition, then a colon, and then on the following lines an indented block of code called the body. If the condition is True, Python runs the body; if it is False, Python skips the body entirely and moves on.

temperature = 35
if temperature > 30:
    print("It is hot today.")
    print("Remember to drink water.")
print("Have a nice day.")

Trace this carefully. The condition temperature > 30 is True because 35 is greater than 30, so both indented lines run, printing the hot-day warning. The final line, "Have a nice day," is not indented, so it is outside the if and always runs regardless. If temperature were 20, the condition would be False, the two indented lines would be skipped, and only "Have a nice day" would print.

Indentation is not optional

Here is where Python differs from many other languages, and where beginners must pay close attention. In Python, indentation, the leading whitespace at the start of a line, is part of the language's grammar. It is how Python knows which lines belong to the if statement's body. Other languages use curly braces for this; Python uses indentation. The convention is four spaces per level, and you must be consistent.

This means the following two programs behave differently even though they look almost identical.

# Version A: both prints are inside the if
if score > 90:
    print("Excellent!")
    print("You passed.")

# Version B: only the first print is inside the if
if score > 90:
    print("Excellent!")
print("You passed.")

In Version A, both lines are indented, so both run only when the score is above 90. In Version B, the second print is not indented, so it is outside the if and runs every time, whatever the score. Getting indentation wrong is a leading cause of confusing bugs for beginners, and inconsistent indentation (mixing tabs and spaces, for instance) produces an IndentationError. Pick four spaces, use them everywhere, and let your editor help you stay consistent.

Adding an else

Often you want to do one thing if a condition is True and a different thing if it is False. The else clause provides the alternative. It attaches to an if, has no condition of its own, and its indented body runs precisely when the if's condition was False. Exactly one of the two branches runs, never both, never neither.

age = int(input("Enter your age: "))
if age >= 18:
    print("You are an adult.")
else:
    print("You are a minor.")

This is an either-or decision. If the age is 18 or more, the first message prints; otherwise the second one does. The else guarantees that the program always says something about the age.

Testing several conditions with elif

Sometimes there are more than two possibilities. You could nest if statements inside each other, but Python offers a cleaner tool: elif, short for "else if." You can chain as many elif branches as you like between the opening if and the closing else. Python checks each condition in order from top to bottom, runs the body of the first one that is True, and then skips all the rest. If none of the conditions is True, the else runs, if there is one.

score = int(input("Enter your score: "))
if score >= 90:
    print("Grade: A")
elif score >= 80:
    print("Grade: B")
elif score >= 70:
    print("Grade: C")
elif score >= 60:
    print("Grade: D")
else:
    print("Grade: F")

The order matters enormously here, and understanding why is a real insight. Suppose the score is 95. Python checks score >= 90 first, finds it True, prints "Grade: A", and stops, never even looking at the other conditions. This is why we test from highest to lowest. If we had written the conditions from lowest to highest, a score of 95 would match score >= 60 first and wrongly print "Grade: D". The takeaway: in an if-elif chain, only the first matching branch runs, so arrange conditions so the first match is the correct one.

Combining conditions with logical operators

Real decisions often depend on more than one thing at once. The logical operators and, or, and not let you combine or invert boolean values to build richer tests.

  • and gives True only when both sides are True. Think of it as a strict requirement: everything must hold.
  • or gives True when at least one side is True. Think of it as a lenient requirement: any one is enough.
  • not flips a boolean: not True is False, and not False is True.

Here is a truth table that captures how and and or behave for every combination.

ABA and BA or B
TrueTrueTrueTrue
TrueFalseFalseTrue
FalseTrueFalseTrue
FalseFalseFalseFalse
age = 25
is_member = True

if age >= 18 and is_member:
    print("Access granted.")

if age < 13 or age > 65:
    print("You qualify for a discount.")

if not is_member:
    print("Please sign up.")
else:
    print("Welcome back, member.")

In the first test, access is granted only because both the age requirement and membership are satisfied; if either were false, access would be denied. In the second, the discount applies if the person is either quite young or a senior, so a single true condition suffices. In the third, not is_member is False because they are a member, so the else branch runs.

A common beginner error is to write a compound comparison the way you would in math or English. To test whether a number is between 1 and 10, you might be tempted to write if 1 < x and < 10:, which is invalid. You must repeat the variable: if x > 1 and x < 10:. Interestingly, Python does allow the mathematical chained form if 1 < x < 10: as a special convenience, and it reads nicely, but understand that under the hood it is equivalent to the two-comparison version joined by and.

Truthiness: values that act like booleans

Python has a useful concept called truthiness. When a non-boolean value is used where a boolean is expected, such as directly in an if condition, Python decides whether it counts as True or False. The rules are intuitive once you know them: the number zero, the empty string "", an empty list, and the special value None are all treated as False, and essentially everything else is treated as True. This lets you write clean checks like testing whether a string is non-empty.

name = input("Enter your name: ")
if name:
    print("Hello, " + name)
else:
    print("You did not enter a name.")

Here, if the user types nothing and just presses Enter, name is the empty string, which is falsy, so the else runs. If they type anything at all, the string is truthy and the greeting prints. This is more idiomatic than writing if name != "":, though that version works too and is perfectly clear for a beginner.

Nesting: decisions inside decisions

The body of an if can itself contain another if. This is called nesting, and each level of nesting adds another level of indentation. Nesting lets you make a decision only after an earlier condition has been established. Use it when a second question only makes sense given the answer to the first.

logged_in = True
is_admin = False

if logged_in:
    print("Welcome.")
    if is_admin:
        print("You have admin powers.")
    else:
        print("You have standard access.")
else:
    print("Please log in first.")

The inner if, checking admin status, only runs when the outer if established that the user is logged in. Deep nesting can become hard to read, and often a compound condition with and can flatten it, but a level or two of nesting is common and clear.

A complete worked example

Let us build a small program that decides ticket pricing based on age and a discount code, exercising comparisons, elif chains, and logical operators together.

print("Cinema Ticket Pricing")
age = int(input("Enter your age: "))
code = input("Discount code (or leave blank): ")

if age < 5:
    price = 0
elif age < 18:
    price = 8
elif age >= 65:
    price = 9
else:
    price = 12

if code == "STUDENT" and price > 0:
    price = price - 2

print(f"Your ticket costs {price} dollars.")

Walk through it. The first if-elif-else chain sets a base price by age bracket: free for very young children, a reduced price for minors, a senior rate, and a full adult price otherwise. The second if then applies a two-dollar student discount, but only if the code matches exactly and the price is not already free. Notice how the two decisions are independent and stack: first determine the base price, then adjust it. This layered approach, making one decision and then refining the result with another, is a pattern you will use in real programs constantly.

Common mistakes and how to fix them

  • Using = instead of == in a condition. Assignment in a condition is a SyntaxError. When testing equality, always use two equals signs.
  • Forgetting the colon. Every if, elif, and else line ends with a colon. Leaving it off produces a SyntaxError.
  • Wrong or inconsistent indentation. The body of a branch must be indented, consistently, usually four spaces. Mixing tabs and spaces, or forgetting to indent, causes IndentationError or logic bugs.
  • Ordering an elif chain badly. Because only the first true branch runs, a broad condition placed early can swallow cases meant for a later branch. Order from most specific or most extreme to least.
  • Writing compound comparisons incompletely. Use x > 1 and x < 10, repeating the variable, not the incomplete x > 1 and < 10.
  • Confusing and with or. Use and when every condition must hold, or when any one is enough. Swapping them silently produces wrong decisions, which are the hardest bugs to spot because there is no error message.

Recap

This week your programs learned to think. You saw that a boolean is a True or False value, and that comparison operators, the six of them from equals to less-than-or-equal, produce booleans by asking questions about values, including string comparisons. You firmly separated assignment with a single equals from comparison with a double equals. You learned the if statement and, crucially, that indentation is how Python defines which lines form a branch's body. You extended if with else for two-way choices and with chains of elif for many-way choices, understanding that only the first matching branch runs, which makes the order of conditions meaningful. You learned to combine conditions with the logical operators and, or, and not, backed by a truth table, and you met the idea of truthiness, where values like zero and the empty string act as False. Finally you saw how to nest decisions and how to layer several independent decisions to build realistic logic. Next week we add the other half of programming's power: repetition, with the while loop.

Key terms
Boolean
A value that is either True or False.
Comparison operator
A symbol like == or < that compares two values and yields a boolean.
if statement
A structure that runs a block of code only when a condition is True.
elif
An else-if branch that tests another condition when earlier ones were False.
Indentation
Leading spaces that group statements into a block in Python.
Logical operator
and, or, or not, used to combine or invert boolean values.

Week 5 - Loops I: while

Repeat code until a condition changes

  • Write a while loop with a proper condition.
  • Avoid infinite loops by updating variables.
  • Use break and continue.

Everything you have written so far runs once, top to bottom, and then stops. But some of the most useful things a computer can do involve doing the same work over and over: counting, summing a long list of numbers, retrying until the user gives valid input, or keeping a game going until someone wins. Writing the same lines out by hand a hundred times would be absurd, and impossible if you do not know in advance how many repetitions you need. This week you learn the first of Python's two looping tools, the while loop, which repeats a block of code for as long as a condition remains true. This is where your programs stop being short scripts and start being able to run indefinitely, react to the user, and process large amounts of data. Take your time with this lesson, because looping is one of the two or three most important ideas in all of programming, and the mistakes it invites are worth learning to avoid early.

What a loop is, and why we need one

A loop is a control structure that repeats a block of code. Each single pass through the block is called an iteration. If a loop runs its body five times, we say it performed five iterations. The whole reason loops exist is that computers are tireless at repetition in a way humans are not, and repetition is everywhere in real problems. Consider adding up the numbers from 1 to 1000. You would never write a thousand addition statements, and even if you did, changing it to sum up to 2000 would mean writing a thousand more. A loop lets you express "do this repeatedly" once, compactly, and adjust how many times with a single change.

There are two kinds of loop in Python. The while loop, which is this week's topic, repeats as long as a condition is true. It is the right tool when you do not know ahead of time exactly how many repetitions you will need, only that you should keep going until something changes. Next week you will meet the for loop, which is the right tool when you already know the collection or the count you want to iterate over. Both are essential; this week we focus entirely on while.

The anatomy of a while loop

A while loop is built from four parts, and understanding each one is the key to writing loops that work. The parts are: the keyword while, a boolean condition, a colon, and then an indented block of code called the body. The condition is exactly the kind of true-or-false expression you learned to write with comparison operators in the conditionals week. Here is the simplest possible shape.

count = 1
while count <= 5:
    print("Count is", count)
    count = count + 1
print("Done")

Let us trace this line by line, because tracing loops by hand is the single best way to understand them. Before the loop, count is set to 1. Python reaches the while line and checks the condition count <= 5. Since 1 is less than or equal to 5, the condition is True, so Python runs the body: it prints "Count is 1", then adds one to count, making it 2. When the body finishes, control jumps back up to the while line and checks the condition again. Now count is 2, still less than or equal to 5, so the body runs again, printing "Count is 2" and making count 3. This repeats. The loop prints the counts 1, 2, 3, 4, and 5. After printing 5, count becomes 6. Control returns to the top, the condition 6 <= 5 is now False, so Python skips the body entirely and moves on to the line after the loop, printing "Done". The full output is six lines: the five counts and then "Done".

Notice the rhythm: check the condition, and if it is true run the body, then check again. The condition is checked before every iteration, including the very first. This has an important consequence. If the condition is False the very first time it is checked, the body never runs at all, not even once. A while loop can run zero times.

count = 100
while count < 5:
    print("This never prints")
    count = count + 1
print("The loop body ran zero times")

Here count starts at 100, so count < 5 is False immediately, the body is skipped completely, and only the final line prints. This zero-times behavior is a feature, not a bug, and it is exactly what you want when the work might already be done before you start.

The three ingredients of a correct loop

Almost every well-behaved while loop has three ingredients working together, and when a loop misbehaves it is usually because one of them is missing. First, there is initialization: before the loop, you set up the variable the condition depends on, such as count = 1. Second, there is the condition: the test that decides whether to keep going. Third, and most easily forgotten, there is the update: inside the body, you change the variable so that the condition will eventually become False. In the counting example those three are count = 1 (initialize), count <= 5 (condition), and count = count + 1 (update). Keep this trio in mind and most of your loops will be correct.

The infinite loop: the classic mistake

What happens if you forget the update, the third ingredient? The condition never changes, so it never becomes False, and the loop runs forever. This is called an infinite loop, and it is the most common loop bug there is. Every programmer, including experts, writes one occasionally. Look at what happens if we drop the line that increases count.

# WARNING: this is an infinite loop, do not run it without knowing how to stop it
count = 1
while count <= 5:
    print("Count is", count)
# count never changes, so count <= 5 is always True

Because count stays 1 forever, the condition 1 <= 5 is always True, and the program prints "Count is 1" endlessly, filling your screen until you force it to stop. If you ever run a program and it seems stuck, printing the same thing over and over or simply hanging, you have most likely written an infinite loop. To stop a runaway program in the terminal, press the Control key and the letter C at the same time. That key combination, written Ctrl+C, interrupts the running program and returns you to the prompt. Make a mental note of it now; you will need it.

The cure for an infinite loop is always the same: make sure something inside the body moves the condition toward becoming False. Usually that means changing the variable the condition tests, as with the missing count = count + 1. Whenever you write a while loop, pause and ask yourself: what inside this body will eventually make the condition False? If you cannot answer, you probably have a bug.

Augmented assignment inside loops

The update step, changing a variable based on its own current value, is so common in loops that Python's shorthand augmented assignment operators, which you met earlier, appear constantly here. Writing count += 1 means the same as count = count + 1, and it is the idiomatic way to advance a counter. You will see it everywhere, so get comfortable reading it as "increase count by one."

n = 10
while n > 0:
    print(n)
    n -= 1        # same as n = n - 1
print("Blast off!")

This counts down from 10 to 1 and then prints "Blast off!". The update n -= 1 decreases n each time, so the condition n > 0 eventually fails when n reaches 0, ending the loop cleanly.

The accumulator pattern with while

One of the most valuable things loops do is build up a result across iterations. This is called the accumulator pattern: you create a variable before the loop to hold the running result, then update it a little each iteration. To add up the numbers from 1 to 100, you start a total at 0 and keep adding the current number.

total = 0
number = 1
while number <= 100:
    total = total + number
    number = number + 1
print("The sum from 1 to 100 is", total)   # 5050

Trace the first few iterations. total begins at 0 and number at 1. First iteration: total becomes 0 + 1 = 1, then number becomes 2. Second iteration: total becomes 1 + 2 = 3, then number becomes 3. Third: total becomes 3 + 3 = 6, number becomes 4. The pattern continues, and by the time number passes 100 the total holds the sum of every integer from 1 through 100, which is 5050. The accumulator does not have to be a sum; you can accumulate a product, a count, the largest value seen so far, or a growing string. The shape is always the same: initialize a result before the loop, refine it inside.

# Accumulating a count of how many numbers are even
count_even = 0
n = 1
while n <= 20:
    if n % 2 == 0:
        count_even = count_even + 1
    n = n + 1
print("There are", count_even, "even numbers from 1 to 20")   # 10

This combines a loop with a conditional inside its body, which is extremely common. The loop walks n from 1 to 20, and the if statement inside decides whether to bump the counter, using the modulo test n % 2 == 0 to detect even numbers that you learned earlier.

Looping until the user decides to stop

A while loop truly comes into its own when you do not know how many iterations you need, which is exactly the situation when you are reading input from a person. You cannot know in advance how many numbers the user wants to enter, so you loop until they signal they are done. A value the user types to mean "stop" is often called a sentinel value.

total = 0
entry = input("Enter a number, or type 'done' to finish: ")
while entry != "done":
    total = total + float(entry)
    entry = input("Enter a number, or type 'done' to finish: ")
print("Your total is", total)

Read the structure carefully, because it has a shape worth learning. We read the first entry before the loop so the condition has something to test. Inside the loop we process the entry and then read the next one at the bottom. When the user finally types "done", the condition entry != "done" becomes False at the top of the next check, and the loop ends without trying to convert "done" to a number. This pattern of "read one, then loop while it is not the sentinel, processing and reading again" is a classic, and it avoids the bug of trying to do arithmetic on the sentinel word itself.

Breaking out early with break

Sometimes you want to leave a loop immediately, from the middle of the body, without waiting to return to the top and re-check the condition. The break statement does exactly that: when Python runs a break, it exits the enclosing loop at once and continues with the code after it. break is especially useful when you discover, partway through an iteration, that you are finished.

while True:
    answer = input("Type 'quit' to stop: ")
    if answer == "quit":
        break
    print("You typed:", answer)
print("Goodbye")

Look at the condition on the while line: it is simply True, a literal that is always true, so on its own this loop would run forever. That is intentional. Loops written as while True rely on a break somewhere inside to end them. Here, each iteration reads a line; if the line is "quit" we break out, otherwise we echo it and loop again. This shape, an intentionally endless loop with a break in the middle, is sometimes called a loop-and-a-half, because the exit test sits in the middle of the body rather than at the very top or bottom. It is a clean and readable way to handle "keep going until a special thing happens," and you will use it often.

Skipping an iteration with continue

Where break leaves the loop entirely, continue abandons only the current iteration and jumps straight back to the top to check the condition again. Everything below the continue in the body is skipped for that one pass. It is handy when, for certain values, you want to skip the rest of the work but keep looping.

number = 0
while number < 10:
    number = number + 1
    if number % 2 == 0:
        continue          # skip the print for even numbers
    print(number)          # only odd numbers reach this line
# prints 1, 3, 5, 7, 9

Trace it: we increase number, and if it is even we continue, which skips the print and goes back to the top. Only odd numbers get past the continue to be printed. Note that we placed the update number = number + 1 before the continue on purpose. If we had put it after, then when continue fired on an even number, number would never change, the condition would stay true, and we would have an infinite loop. This is a subtle but important trap: when using continue, make sure the loop still makes progress toward ending on every path, or you risk looping forever.

Validating input with a loop

Combining these ideas gives you one of the most practical patterns in everyday programming: refusing to move on until the user provides acceptable input. You loop, asking again, until the value passes your check. This is how real programs handle typos and bad data at the keyboard.

age = int(input("Enter your age (0 to 120): "))
while age < 0 or age > 120:
    print("That is not a valid age. Please try again.")
    age = int(input("Enter your age (0 to 120): "))
print("Thank you. Your age is", age)

If the user enters a number outside the sensible range, the condition is true and the loop body runs, scolding them gently and asking again. As soon as they enter something in range, the condition is false and the program proceeds. The user could get it wrong ten times in a row; the loop patiently handles all of them. This is far friendlier than a program that crashes or silently accepts nonsense.

Nested loops: a loop inside a loop

The body of a loop can itself contain another loop. This is called nesting, and it is how you handle two-dimensional patterns such as rows and columns. For each pass of the outer loop, the entire inner loop runs to completion. Here is a small multiplication grid built from two nested while loops.

row = 1
while row <= 3:
    col = 1
    while col <= 3:
        print(row * col, end="\t")   # tab, stay on same line
        col = col + 1
    print()                          # move to the next line
    row = row + 1

The outer loop advances the row from 1 to 3. Each time, the inner loop runs the column from 1 to 3, printing the product with a tab after it and staying on the same line thanks to the end argument to print. When the inner loop finishes a row, the bare print() moves output to a fresh line. Notice the critical detail that col = 1 is reset inside the outer loop, before the inner loop starts, so each row begins its columns from the start. Forgetting to reset an inner counter is a classic nested-loop bug. Nested loops multiply their work: a 3 by 3 grid runs the inner body 9 times, and a 100 by 100 grid would run it 10000 times, which is worth keeping in mind for performance later.

A complete worked example: a number guessing game

Let us pull everything together into a small program that genuinely feels like software. The computer holds a secret number, and the user guesses until they get it, receiving hints. This uses a while loop for the repeated guessing, a conditional for the hints, a counter as an accumulator, and a break for the win.

secret = 42
guesses = 0

while True:
    guess = int(input("Guess my number (1 to 100): "))
    guesses = guesses + 1
    if guess < secret:
        print("Too low. Try again.")
    elif guess > secret:
        print("Too high. Try again.")
    else:
        print("Correct! You got it in", guesses, "guesses.")
        break

Walk through the logic. We fix the secret and start a guess counter at 0. The loop reads a guess and increments the counter every time. The if-elif-else compares the guess to the secret: too low and too high produce hints and let the loop continue, while an exact match prints a congratulations that includes the number of tries and then breaks out of the loop. There is no way to predict how many guesses a player will take, which is exactly why while is the right loop here. Next week, when you learn to import the random module, you will be able to make the secret different each game.

Common mistakes and how to fix them

  • Forgetting the update, causing an infinite loop. The number one loop bug. Always ensure something in the body moves the condition toward False. If your program hangs or repeats forever, press Ctrl+C and add or fix the update line.
  • Off-by-one boundaries. Decide carefully whether your condition should use < or <=. Using count < 5 versus count <= 5 changes whether the loop runs four or five times. Trace the first and last iterations by hand to be sure.
  • Updating after a continue. If you use continue, make sure the loop still advances on the path that hits continue, or it will loop forever on that value.
  • Not resetting an inner counter in nested loops. The inner loop variable must be reset inside the outer loop body, before the inner loop starts, or later rows will not run correctly.
  • Confusing break and continue. break leaves the loop entirely; continue only skips the rest of the current iteration and keeps looping. Reach for the right one deliberately.
  • Testing the sentinel too late. In a read-and-process loop, read the first value before the loop and re-read at the bottom, so the sentinel word is never accidentally processed as data.

Recap

This week your programs gained the power of repetition through the while loop, which runs its indented body over and over as long as its condition stays true, checking that condition before every iteration so it can run any number of times, including zero. You learned the three ingredients of a reliable loop, initialization before it, a condition to test, and an update inside that moves the condition toward False, and you saw that omitting the update produces the classic infinite loop, which you can stop with Ctrl+C. You practiced the accumulator pattern to build sums and counts across iterations, and the sentinel pattern to loop until a user signals they are done. You learned to leave a loop early with break, including the loop-and-a-half shape built on while True, and to skip a single iteration with continue while being careful not to trap yourself in an endless loop. You nested loops to build grids, remembering to reset the inner counter, and you assembled a complete number-guessing game. Next week you meet the for loop and the range function, which give you a cleaner way to repeat a known number of times or to walk through a collection.

Sources

  1. Charles Severance, "Python for Everybody," Chapter 5: Iteration. Free full text at py4e.com/html3/05-iterations
  2. Al Sweigart, "Automate the Boring Stuff with Python," 2nd edition, Chapter 2: Flow Control (while loops, break, and continue). Free to read at automatetheboringstuff.com/2e/chapter2/
  3. Python Software Foundation, "The Python Tutorial," Section 4: More Control Flow Tools (the while statement, break and continue). docs.python.org/3/tutorial/controlflow.html
  4. Python Software Foundation, "The Python Language Reference," Section 8.2: The while statement. docs.python.org/3/reference/compound_stmts.html#the-while-statement
Key terms
while loop
A loop that repeats as long as its condition remains True.
Iteration
One single pass through the body of a loop.
Condition
The boolean expression a loop checks before each iteration.
Infinite loop
A loop whose condition never becomes False, so it never stops.
break
A keyword that exits the enclosing loop immediately.
continue
A keyword that skips the rest of the current iteration and starts the next.

Week 6 - Loops II: for & range

Iterate over sequences and number ranges

  • Write a for loop over a sequence.
  • Generate number sequences with range().
  • Accumulate a running total in a loop.

Last week you learned the while loop, which repeats as long as a condition holds. It is powerful, but it puts three separate responsibilities on you: initialize a counter, write the condition, and remember to update the counter, or risk an infinite loop. Very often, though, you already know exactly what you want to loop over, either a collection of items or a fixed number of repetitions. In those cases Python offers a second, cleaner kind of loop: the for loop. The for loop handles the bookkeeping for you, walking through a collection one item at a time with no counter to manage and no chance of forgetting to advance. This week you will master the for loop and its constant companion, the range function, and you will practice the patterns, accumulating, counting, and searching, that make loops the workhorse of real programs. By the end you will reach for a for loop by instinct whenever the number of repetitions is known.

What a for loop does

A for loop repeats its body once for each item in a sequence, and on each repetition it makes a variable, the loop variable, refer to the current item. The structure is the keyword for, then a name you choose for the loop variable, then the keyword in, then the sequence to walk through, then a colon, and finally an indented body. Read out loud, for letter in "cat": says "for each letter in the word cat, do the following."

for letter in "cat":
    print(letter)
# prints c, then a, then t on separate lines

Here the sequence is the string "cat". Because a string is an ordered sequence of characters, the for loop visits each character in turn. On the first iteration letter refers to "c" and the body prints it; on the second, letter is "a"; on the third, letter is "t". After the last character, the loop ends automatically. Notice what you did not have to write: no counter starting at zero, no condition, no update, and no risk of an infinite loop. The for loop knows the sequence is finite and stops when it runs out of items. That safety and simplicity is why, whenever you know what you are looping over, a for loop is preferred over a while loop.

The loop variable is an ordinary variable, and you pick its name. Choose something descriptive: for letter in word reads better than for x in word. A very common convention is to name the loop variable i (short for index) when it is a plain counting number, and to use a meaningful singular noun when it is an item, such as for student in students. After the loop finishes, the loop variable still exists and holds the last value it took, though relying on that is usually poor style.

Iterables: the things you can loop over

Anything a for loop can walk through is called an iterable. Strings are iterable, and you visit their characters. Next week you will meet lists, which are iterable and yield their elements. Dictionaries are iterable too. For now, the two iterables you will use most are strings and the sequences of numbers produced by the range function, which we turn to next. The mental model to carry is simple: a for loop asks an iterable for its items, one at a time, and runs the body once per item.

The range function: looping a set number of times

Often you do not have a collection to walk through; you simply want to do something a fixed number of times, or count through a span of numbers. For that, Python gives you the built-in range() function, which produces a sequence of integers on demand. Pairing for with range() is the standard way to repeat a known number of times.

In its simplest form, range(n) produces the integers from 0 up to but not including n. This "up to but not including the end" rule is the same half-open convention you met with string slicing, and it is worth fixing firmly in mind because it is the source of many off-by-one mistakes. So range(5) yields 0, 1, 2, 3, 4, which is five numbers, starting at 0 and stopping before 5.

for i in range(5):
    print(i)          # prints 0, 1, 2, 3, 4 on separate lines

for i in range(3):
    print("Hello")    # prints Hello three times

The first loop prints the five numbers 0 through 4. The second ignores the loop variable entirely and just uses the range to repeat the print three times, which is a perfectly good use: sometimes you only want the repetition, not the number. Because range starts at 0 by default, range(5) gives you exactly five values but the last one is 4, not 5. Remembering that the highest value is one less than the argument saves you from countless off-by-one errors.

Range with a start and a step

The range function has two more forms that give you finer control. With two arguments, range(start, stop) begins at start and stops before stop, so range(1, 6) yields 1, 2, 3, 4, 5. This is how you count from 1 rather than 0, which is often what you want when presenting numbers to a person. With three arguments, range(start, stop, step) also lets you set the step, the amount to jump between values. So range(2, 11, 2) yields 2, 4, 6, 8, 10, counting by twos.

for n in range(1, 6):
    print(n)          # 1, 2, 3, 4, 5

for even in range(2, 11, 2):
    print(even)       # 2, 4, 6, 8, 10

for countdown in range(10, 0, -1):
    print(countdown)  # 10, 9, 8, ... down to 1

The third example shows that the step can be negative, which makes range count downward. range(10, 0, -1) starts at 10 and steps down by one each time, stopping before it reaches 0, so it produces 10 down to 1. Reading these takes a little practice, so trace a couple by hand: for range(2, 11, 2), start at 2, is 2 less than 11? yes, use it; add 2 to get 4, less than 11? yes; and so on, until you reach 10, then 12 which is not less than 11, so stop. The last value produced is 10.

A useful pairing is range with the length of a sequence, written range(len(word)), which gives you every valid index of that sequence. This lets you loop over positions rather than items, which you need when you want to know where you are, not just what is there.

word = "Python"
for i in range(len(word)):
    print(i, word[i])
# prints 0 P, 1 y, 2 t, 3 h, 4 o, 5 n

Here len(word) is 6, so range(6) yields 0 through 5, exactly the valid indexes of the six-character string, and word[i] fetches the character at each position. When you need both the position and the item, this index-based style works, though you will later learn an even cleaner tool called enumerate for the same job.

The accumulator pattern, revisited with for

The accumulator pattern you met last week, initialize a result before the loop and refine it inside, is just as central with for loops, and the for loop makes it especially tidy because the counting is automatic. To sum the numbers from 1 to 100, start a total at zero and add each number.

total = 0
for n in range(1, 101):
    total = total + n
print("The sum from 1 to 100 is", total)   # 5050

Note the range is range(1, 101), not range(1, 100), because the stop value is excluded, so to include 100 you must go one past it to 101. This is the off-by-one rule biting again, and getting the upper bound right is one of the most important habits to build this week. Trace the accumulation: total starts at 0, then becomes 1, then 3, then 6, then 10, and so on, ending at 5050. The same shape counts things when you add 1 conditionally, or builds a product when you multiply instead of add.

# Count multiples of 3 between 1 and 30
count = 0
for n in range(1, 31):
    if n % 3 == 0:
        count = count + 1
print("Multiples of 3:", count)   # 10

# Build a factorial by accumulating a product
product = 1
for n in range(1, 6):
    product = product * n
print("5 factorial is", product)   # 120

The first loop shows an accumulator combined with a conditional, adding to the count only when the modulo test says n is a multiple of 3. The second builds a running product, and note that its accumulator starts at 1, not 0, because multiplying by zero would wipe out the result. Choosing the right starting value for an accumulator, 0 for sums and counts, 1 for products, matters.

Looping over a string to analyze text

Because strings are iterable, for loops are a natural fit for examining text. Here is a program that counts how many vowels a word contains, combining a for loop, a conditional, the membership operator in, and an accumulator.

word = input("Enter a word: ")
vowel_count = 0
for letter in word:
    if letter.lower() in "aeiou":
        vowel_count = vowel_count + 1
print("That word has", vowel_count, "vowels.")

The loop visits each letter. The condition letter.lower() in "aeiou" lowercases the letter so that both "A" and "a" count, then checks whether it appears in the string of vowels, which is a compact way to test membership against several options at once. Each vowel bumps the accumulator. This little program already does something genuinely useful, and every piece of it comes from what you have learned.

break and continue work in for loops too

The break and continue statements you learned with while loops behave identically inside for loops. break exits the loop immediately, which is perfect for searching: walk through items until you find what you want, then stop. continue skips the rest of the current iteration and moves to the next item.

# Search for the first number divisible by 7
for n in range(1, 100):
    if n % 7 == 0:
        print("First multiple of 7 is", n)
        break

# Print only the odd numbers, skipping evens
for n in range(1, 11):
    if n % 2 == 0:
        continue
    print(n)          # 1, 3, 5, 7, 9

In the search loop, as soon as we find the first multiple of 7, we print it and break, so we do not waste time checking the rest. This "loop until found, then break" shape is one of the most useful applications of break. In the second loop, continue skips the print for even numbers, so only odd numbers are shown. Because a for loop always advances to the next item on its own, continue is safer here than in a while loop: there is no danger of an infinite loop, since the for loop cannot fail to move forward.

The loop else clause, briefly

Python has an unusual feature: a for loop may have an else clause, which runs only if the loop finished normally without hitting a break. It is most useful in search loops to detect the "not found" case. You will not use it often, but it is worth recognizing.

target = 8
for n in [2, 4, 6, 10]:
    if n == target:
        print("Found it!")
        break
else:
    print("Not found in the list.")
# prints "Not found in the list." because 8 is not present

Because the break never fires (there is no 8 in the list), the loop finishes normally and the else runs, reporting that the target was absent. If the list had contained 8, the break would have run and the else would have been skipped. Do not confuse this else with the else of an if statement; here it pairs with the loop and means "the loop ended without breaking."

Nested for loops

Just like while loops, for loops can be nested, with one loop inside another, and for each pass of the outer loop the entire inner loop runs. This is the natural way to produce grids, tables, and all combinations of two things. Here is the complete multiplication table from 1 to 5, which is far cleaner with for than it would be with while because the counters manage themselves.

for row in range(1, 6):
    for col in range(1, 6):
        product = row * col
        print(product, end="\t")   # tab, stay on the same line
    print()                         # newline after each row

The outer loop sets the row from 1 to 5. For each row, the inner loop runs the column from 1 to 5, printing each product followed by a tab and staying on the same line thanks to the end argument. After the inner loop finishes a row, the bare print() ends the line so the next row starts fresh. Because the inner range is created anew each time the outer loop iterates, there is no counter to reset by hand, which is one more way for loops are less error-prone than while loops for this kind of work. Remember that nested loops multiply their iterations: this 5 by 5 table runs the inner body 25 times.

A complete worked example: a multiplication table for any number

Let us build a small, friendly program that prints the times table for a number the user chooses, from 1 through 10. It uses input and conversion, a for loop over range, an f-string for clean output, and clear labeling.

number = int(input("Which multiplication table? "))
print("The", number, "times table:")
for multiplier in range(1, 11):
    result = number * multiplier
    print(f"{number} x {multiplier} = {result}")

If the user enters 7, the loop runs the multiplier from 1 to 10, computing and printing each line from "7 x 1 = 7" through "7 x 10 = 70". The range is range(1, 11) so that the table includes 10, once again minding the off-by-one boundary. Notice how the f-string makes the output read naturally, and how the whole program is only a handful of lines yet produces a complete, tidy table. This is the kind of small tool for loops make effortless.

Choosing between for and while

With two kinds of loop available, which should you use? The guideline is straightforward and worth memorizing. Use a for loop when you know in advance what you are iterating over: a collection, a string, or a fixed number of repetitions via range. This is the common case, and the for loop's automatic bookkeeping makes it safer and cleaner. Use a while loop when you do not know how many iterations you need and must keep going until some condition changes, such as reading input until the user quits, or retrying until a value is valid. A helpful rule of thumb: definite repetition (known count) calls for for; indefinite repetition (unknown count) calls for while. When in doubt, if you can phrase the task as "for each of these" or "do this N times," reach for a for loop.

Common mistakes and how to fix them

  • Off-by-one range boundaries. Because range stops before its end value, to include the number 100 you must write range(1, 101). Getting the upper bound one short or one long is the most frequent for-loop bug. Trace the first and last values.
  • Forgetting that range starts at 0. range(5) gives 0 through 4, not 1 through 5. If you want to start at 1, use range(1, 6).
  • Trying to change the loop variable to control the loop. Reassigning the loop variable inside a for loop does not change which items it visits; the loop simply moves to the next item regardless. To skip or stop, use continue or break, not assignment.
  • Wrong accumulator starting value. Start a sum or count at 0, but start a product at 1. Starting a product at 0 makes the whole result 0.
  • Not resetting an inner accumulator in nested loops. If you accumulate inside a nested loop, reset the inner result at the top of the outer loop body so each outer pass starts clean.
  • Confusing the loop else with an if else. A for loop's else runs only when the loop completes without a break. It is not tied to any condition inside the body.

Recap

This week you learned the for loop, Python's clean tool for definite repetition. A for loop walks through any iterable, making a loop variable refer to each item in turn, and it manages the counting for you so there is no counter to update and no infinite-loop risk. You paired for with the range function, which generates sequences of integers, learning its three forms: range(stop) from 0, range(start, stop), and range(start, stop, step) including negative steps for counting down, always remembering that range stops before its end value. You applied the accumulator pattern to sum, count, and build products, choosing the right starting value for each, and you looped over strings to analyze text. You saw that break and continue work in for loops just as in while loops, met the loop else clause for detecting the not-found case, and built nested loops to produce a multiplication table. Finally you learned the guideline for choosing between the two loops: for known repetition, while for unknown. With both loops in hand, you are ready for the next major step, packaging code into reusable functions.

Sources

  1. Charles Severance, "Python for Everybody," Chapter 5: Iteration (definite loops with for). Free full text at py4e.com/html3/05-iterations
  2. Al Sweigart, "Automate the Boring Stuff with Python," 2nd edition, Chapter 2: Flow Control (for loops and range). Free to read at automatetheboringstuff.com/2e/chapter2/
  3. Python Software Foundation, "The Python Tutorial," Section 4.2 and 4.3: the for statement and the range function. docs.python.org/3/tutorial/controlflow.html
  4. Python Software Foundation, "Built-in Functions: range." docs.python.org/3/library/functions.html#func-range
Key terms
for loop
A loop that iterates over each item of a sequence in turn.
Loop variable
The name that takes on each successive value during a for loop.
range()
A built-in that generates a sequence of integers for looping.
Sequence
An ordered collection you can iterate over, such as a string or list.
Accumulator
A variable that builds up a result, such as a running sum, across iterations.
Iterable
Any object you can loop over with a for loop.

Week 7 - Functions & Scope

Package code into reusable functions

  • Define a function with def and parameters.
  • Return values with return.
  • Explain local versus global scope.

Your programs are getting longer, and you have probably already noticed something: you keep writing the same little chunks of logic over and over. Maybe you calculate a total the same way in three places, or print a formatted header again and again. Copying code like that is tedious, and worse, it is fragile: if you find a bug, you have to fix it in every copy, and you will inevitably miss one. This week you learn the single most important tool for organizing code and eliminating repetition: the function. A function lets you write a piece of logic once, give it a name, and then use it as many times as you like just by naming it. Functions are the building blocks of every real program. Learning to write your own, pass information into them, and get results back out is a turning point in becoming a programmer. We will go carefully, because the ideas here, parameters, return values, and scope, reward a solid understanding.

What a function is and why it matters

A function is a named block of code that performs a specific task. You have been using functions since your very first lesson: print(), len(), input(), int(), and range() are all functions. Someone wrote each of them once, and you get to use them, or call them, whenever you need them, without knowing or caring how they work inside. This week you cross over from merely using functions others wrote to writing your own.

Functions matter for three big reasons. First, reuse: write the logic once, call it many times, and never copy-paste. Second, organization: a large program becomes a set of well-named functions, each doing one clear job, which is far easier to read and reason about than one giant wall of code. Third, abstraction: once a function works, you can use it as a single mental unit without re-thinking its internals, the same way you use print() without pondering how text reaches the screen. Programmers sometimes summarize the goal as "don't repeat yourself," often shortened to DRY. Functions are how you stay DRY.

Defining and calling your first function

You create a function with the def keyword, short for "define." The syntax is: def, then the function's name, then a pair of parentheses, then a colon, and then an indented block that is the function's body. Defining a function does not run it; it only teaches Python what the function does, ready for later. To actually run the body, you call the function by writing its name followed by parentheses.

def greet():
    print("Hello there!")
    print("Welcome to the program.")

greet()      # this call runs the two prints
greet()      # calling again runs them again

Read the flow carefully, because the order surprises beginners. When Python reaches the def greet(): block, it does not print anything. It simply records that a function named greet exists and remembers its body. The two prints happen only when you call greet(), which we do twice, so the greeting appears twice. This separation between defining a function (describing what it does) and calling it (making it happen) is fundamental. A function defined but never called does nothing at all.

Function names follow the same rules and conventions as variable names: lowercase words joined by underscores, chosen to describe what the function does. A good function name is usually a verb or verb phrase, because a function performs an action: greet, calculate_total, is_even. If you find it hard to name a function, that is often a sign it is trying to do too many things and should be split.

Parameters: passing information in

A function that always does exactly the same thing is useful but limited. Most functions need to work on different data each time you call them. You make that possible with parameters. A parameter is a named placeholder, listed inside the parentheses of the definition, that stands for a value the caller will supply. When you call the function, the actual value you pass is called an argument, and Python assigns each argument to the matching parameter for the duration of that call.

def greet(name):
    print("Hello,", name)

greet("Ada")       # name refers to "Ada" during this call
greet("Grace")     # name refers to "Grace" during this call

Here name is a parameter. When you call greet("Ada"), the argument "Ada" is assigned to the parameter name, so inside the body name refers to "Ada", and the function prints "Hello, Ada". The next call passes "Grace", so the same function prints "Hello, Grace". One definition, many behaviors, driven by the argument. The distinction in vocabulary is worth keeping straight: the parameter is the name in the definition, and the argument is the concrete value you hand over at the call. People often blur the two in casual speech, but knowing the difference helps you read documentation precisely.

A function can take more than one parameter; you separate them with commas, and you must pass a matching argument for each, in the same order. This positional matching, first argument to first parameter, second to second, is the default way arguments are assigned.

def describe_pet(animal, name):
    print(name, "is a", animal)

describe_pet("dog", "Rex")     # Rex is a dog
describe_pet("cat", "Whiskers")  # Whiskers is a cat

Order matters. In describe_pet("dog", "Rex"), "dog" goes to the first parameter animal and "Rex" to the second parameter name. If you swapped the arguments, you would get the nonsensical "dog is a Rex". A common early bug is passing arguments in the wrong order, so always check that your call matches the parameter order in the definition.

Returning a value: getting information back

The functions above perform an action (printing) but do not hand anything back to the caller. Many functions, though, exist to compute a value that the rest of your program then uses. To send a value out of a function, you use the return statement. When Python reaches a return, it immediately stops the function and hands the given value back to wherever the function was called. The caller can store that value in a variable, print it, or use it in a larger expression.

def square(x):
    return x * x

result = square(5)
print(result)              # 25
print(square(3) + square(4))   # 9 + 16 = 25
print(square(square(2)))       # square(4) = 16

Trace result = square(5). Python calls square with the argument 5, so inside, x is 5, and return x * x computes 25 and hands it back. That 25 becomes the value of the expression square(5), which is then assigned to result. Because a function call that returns a value is a value, you can use it anywhere a value is allowed: inside arithmetic, as shown in square(3) + square(4), or even as the argument to another call, as in square(square(2)), where the inner call returns 4 and the outer squares it to 16. This composability is a large part of what makes functions powerful.

There is a crucial difference between returning a value and printing a value, and confusing them is one of the most common beginner mistakes with functions. Printing sends text to the screen for a human to read and produces no value your program can use. Returning hands a value back to your code so the program can keep working with it. A function that prints but does not return cannot have its result stored or combined; a function that returns can. Watch the difference.

def add_and_print(a, b):
    print(a + b)          # shows the sum, but returns nothing usable

def add_and_return(a, b):
    return a + b          # hands the sum back to the caller

add_and_print(2, 3)       # displays 5 on screen
x = add_and_print(2, 3)   # displays 5, but x is None (no value returned)

y = add_and_return(2, 3)  # nothing shown, but y is now 5
print(y * 10)             # 50, because y holds a real number

Notice that x ends up as the special value None, because add_and_print does not return anything, so there is nothing to store. Meanwhile y holds the actual number 5 and can be used in further math. As a rule, if the rest of your program needs to use a function's result, the function should return it, not just print it. Reserve printing for the parts of your program that talk to the user.

What happens when there is no return

Every function returns something, even if you never write a return statement. If a function ends without returning a value, Python automatically returns the special value None, which represents "no value" or "nothing here." That is why x above became None. You can also write a bare return with nothing after it to exit a function early, which also returns None. Recognizing None is important, because trying to use it as if it were a real value is a frequent source of confusing bugs, such as seeing "None" printed where you expected a number.

def check_positive(n):
    if n < 0:
        print("That is negative.")
        return           # exit early, returning None
    print("That is zero or positive.")

check_positive(-4)       # prints the negative message and exits
check_positive(10)       # prints the zero-or-positive message

return stops the function immediately

An important behavior: the moment a return runs, the function ends, and any lines below it in the body are skipped. This is useful for handling special cases first and returning early, but it also means code placed after a return that always runs will never execute. Understanding this lets you write clean functions that return as soon as they know the answer.

def absolute_value(n):
    if n < 0:
        return -n        # for negatives, return the positive version and stop
    return n             # otherwise return n unchanged

print(absolute_value(-7))   # 7
print(absolute_value(3))    # 3

For a negative input, the first return fires and the function ends there, never reaching the second return. For a non-negative input, the if is skipped and the second return runs. Two return statements, but only ever one runs per call. This early-return style often reads more clearly than deeply nested if-else blocks.

Scope: where variables live

Now we come to an idea that is subtle but important: scope. Scope is the region of your program where a particular variable exists and can be used. The key rule is this: variables created inside a function are local to that function. They come into existence when the function is called, they can only be seen and used within that function's body, and they vanish when the function returns. Code outside the function cannot see them at all.

def compute():
    secret = 42           # secret is local to compute
    print("Inside, secret is", secret)

compute()                 # prints: Inside, secret is 42
print(secret)             # ERROR: NameError, secret does not exist out here

The variable secret lives only inside compute. After compute returns, secret is gone, so the attempt to print it outside raises a NameError. This might feel restrictive, but it is actually a great gift. Because each function's local variables are private, you can use the name total or i inside one function without any worry that it will collide with a variable of the same name in another function. Functions are sealed workspaces, and that isolation is a big part of why breaking a program into functions makes it easier to reason about: you only have to think about one function's variables at a time.

Parameters are local variables too. The parameter name in greet exists only during a call to greet. This means a function cannot accidentally reach out and change a variable in the code that called it, unless you deliberately return a value and have the caller reassign it.

Global scope and why local is preferred

Variables defined at the top level of your file, outside any function, are global: they exist for the whole program and can be read from inside functions. However, there is an important asymmetry. A function can read a global variable, but if it assigns to a name, Python treats that name as a new local variable by default, not a change to the global one.

greeting = "Hello"        # a global variable

def show():
    print(greeting)       # reading the global works fine

show()                    # prints Hello

Reading the global greeting inside show works. But relying on globals is generally considered poor practice, because it creates hidden connections: a function that quietly depends on or changes a global is harder to understand and reuse, since its behavior depends on state defined far away. The clean approach, which you should adopt as a habit, is to pass what a function needs in through parameters and to send results back out through return values. That way each function is self-contained: give it inputs, get an output, with no invisible strings attached. There is a global keyword that lets a function modify a global variable, but you will rarely need it, and reaching for it is often a sign that a function should take a parameter and return a value instead.

Default parameter values

You can give a parameter a default value, which is used when the caller does not supply an argument for it. This makes some arguments optional and is a nice way to keep common calls short while still allowing customization. You write the default with an equals sign in the parameter list.

def greet(name, greeting="Hello"):
    print(greeting + ",", name)

greet("Ada")                 # Hello, Ada  (uses the default)
greet("Ada", "Welcome")      # Welcome, Ada  (overrides the default)

Because greeting has a default of "Hello", you may call greet with just a name, and it fills in "Hello" automatically. Or you may pass a second argument to override the default. Parameters with defaults must come after parameters without them in the definition. Defaults are a small feature that makes your functions more flexible and pleasant to call.

A complete worked example: building with several functions

Let us write a small program composed of a few cooperating functions, which is exactly how real programs are structured. We will make one function that decides whether a number is even, another that describes a number, and a main routine that uses them in a loop. Note how each function does one clear job and how the results flow through return values.

def is_even(n):
    return n % 2 == 0        # returns True or False

def describe(n):
    if is_even(n):
        return str(n) + " is even"
    return str(n) + " is odd"

def main():
    for number in range(1, 6):
        print(describe(number))

main()
# prints:
# 1 is odd
# 2 is even
# 3 is odd
# 4 is even
# 5 is odd

Study how these fit together. The is_even function returns a boolean by evaluating the modulo comparison directly, a clean idiom. The describe function calls is_even to decide which sentence to build and returns that sentence as a string. The main function loops over the numbers 1 to 5 and prints the description of each. This layered design, small functions calling smaller functions, with a main function orchestrating them, is the shape of nearly every well-organized program. Defining a main function and calling it once at the bottom is a widespread convention that keeps the top-level of your file tidy.

Docstrings: documenting a function

It is good practice to describe what a function does in a short string placed as the first line of its body, called a docstring. Python and its tools recognize docstrings, and they help anyone reading your code, including future you, understand the function without deciphering its body. Use triple-quoted strings so the description can span lines if needed.

def celsius_to_fahrenheit(celsius):
    """Convert a temperature from Celsius to Fahrenheit and return it."""
    return celsius * 9 / 5 + 32

print(celsius_to_fahrenheit(100))   # 212.0
print(celsius_to_fahrenheit(0))     # 32.0

The docstring does not change what the function does, but it makes the function self-documenting. Writing a one-line docstring for each function is a professional habit worth starting now, and it also forces you to state clearly, in words, what each function is responsible for.

Common mistakes and how to fix them

  • Defining a function but never calling it. A def block only describes the function. Nothing happens until you call it with its name and parentheses. If your function seems to do nothing, check that you actually called it.
  • Confusing print and return. Printing shows text to the user; returning gives a value back to your code. If you need to use a result later, return it. A function that only prints returns None, and trying to store that gives you None, not your value.
  • Forgetting the return, getting None. If a function that should compute a value prints "None" or gives None when used, you probably forgot to write return.
  • Passing arguments in the wrong order. Arguments match parameters by position. Check that your call lists values in the same order as the definition lists parameters.
  • Trying to use a local variable outside its function. Variables created inside a function do not exist outside it. To get a value out, return it. Referencing a local name outside raises a NameError.
  • Overusing global variables. Prefer passing data in through parameters and out through return values. Functions that quietly read or change globals are harder to understand and reuse.
  • Putting a default parameter before a non-default one. In the definition, all parameters with defaults must come after those without. Otherwise Python raises a SyntaxError.

Recap

This week you learned to write your own functions, the central tool for reuse, organization, and abstraction. You saw that def defines a function without running it, and that calling it by name with parentheses runs its body, possibly many times. You learned to pass information in through parameters, matched positionally by the arguments you supply at the call, and to send information back out with the return statement, understanding the vital difference between returning a value your program can use and merely printing one for the user to see. You learned that a function with no return gives back None, that return stops the function immediately, and that default parameter values make arguments optional. Most importantly, you met scope: variables created inside a function are local and private, which is what lets functions serve as isolated, reusable workspaces, and you learned to prefer parameters and return values over global variables. You saw how small functions compose into larger programs through a main routine, and you picked up the habit of documenting functions with docstrings. Next week you gain a powerful new way to store collections of data: the list.

Sources

  1. Charles Severance, "Python for Everybody," Chapter 4: Functions. Free full text at py4e.com/html3/04-functions
  2. Al Sweigart, "Automate the Boring Stuff with Python," 2nd edition, Chapter 3: Functions (including parameters, return values, and scope). Free to read at automatetheboringstuff.com/2e/chapter3/
  3. Python Software Foundation, "The Python Tutorial," Section 4.7: Defining Functions, and 4.8: More on Defining Functions (default arguments). docs.python.org/3/tutorial/controlflow.html#defining-functions
  4. Python Software Foundation, "The Python Tutorial," Section 9.2: Python Scopes and Namespaces. docs.python.org/3/tutorial/classes.html#python-scopes-and-namespaces
Key terms
Function definition
Creating a named, reusable block of code with the def keyword.
Parameter
A named placeholder in a function definition for an incoming value.
Argument
The actual value supplied to a function when it is called.
return
A statement that sends a value back to the code that called the function.
Local scope
The region inside a function where its local variables exist.
Global scope
The top level of a program where global variables live.

Week 8 - Lists

Store ordered collections of items

  • Create and index a list.
  • Add, remove, and change list items.
  • Loop over a list and use common methods.

Until now, every variable you have made holds a single value: one number, one string, one boolean. But real programs constantly deal with collections of things: all the scores in a class, every item in a shopping cart, the lines of a file, the players in a game. You could try to store ten scores in ten separate variables named score1 through score10, but that quickly becomes unmanageable, and it collapses entirely if you do not know in advance how many values there will be. This week you learn Python's most important and most used data structure for holding collections: the list. A list stores many values in a single variable, in order, and lets you access, change, add, and remove them freely. Lists are everywhere in Python, and once you can wield them, the range of programs you can write expands enormously. We will build up from creating a simple list to looping over it, transforming it, and using its most valuable methods.

What a list is

A list is an ordered collection of values stored together in one variable. You create a list by writing its values inside square brackets, separated by commas. The values inside a list are called its elements or items. A list can hold values of any type, and can even mix types, though in practice you will usually keep a list to one kind of thing because that is what most problems call for.

fruits = ["apple", "banana", "cherry"]
scores = [90, 85, 72, 100]
mixed = ["Ada", 36, True, 3.14]
empty = []                        # a list with no elements yet
print(fruits)                     # ['apple', 'banana', 'cherry']
print(len(scores))                # 4, the number of elements

Notice the empty list, written as a pair of square brackets with nothing inside. Empty lists are extremely common as a starting point that you then fill up, exactly like starting an accumulator at zero. The built-in len() function, which you already know from strings, works on lists too and tells you how many elements a list contains. The word "ordered" is important: a list remembers the order in which you put its elements, and that order stays fixed unless you deliberately change it.

Accessing elements by index

Because a list is ordered, each element sits at a numbered position called an index, and, exactly as with strings, indexing starts at zero. The first element is at index 0, the second at index 1, and so on. You retrieve an element by writing the list followed by square brackets containing the index.

fruits = ["apple", "banana", "cherry"]
print(fruits[0])       # apple   (the first element)
print(fruits[1])       # banana
print(fruits[2])       # cherry  (the third and last)
print(fruits[-1])      # cherry  (negative indexing counts from the end)
print(fruits[-2])      # banana

All the indexing knowledge you built with strings carries over directly. Valid indexes for a three-element list run from 0 to 2, and asking for fruits[3] raises an IndexError because there is no fourth element. Negative indexing works the same way too: fruits[-1] is the last element, which is handy when you want the end of a list without computing its length. This consistency between strings and lists is deliberate; Python reuses the same sequence ideas across its types so that learning one teaches you the others.

You can also take a slice of a list, extracting a range of elements with the same start-colon-stop syntax you learned for strings, and the result is a new list. The slice includes the start index and stops before the stop index.

numbers = [10, 20, 30, 40, 50]
print(numbers[1:4])    # [20, 30, 40]  (indexes 1, 2, 3)
print(numbers[:2])     # [10, 20]      (start defaults to 0)
print(numbers[3:])     # [40, 50]      (stop defaults to the end)

Lists are mutable: the crucial difference from strings

Here is the single most important way lists differ from strings. Strings are immutable: once created, you cannot change their characters. Lists are mutable, meaning you can change, add, and remove their elements after the list is created, and the list itself changes in place. This is what makes lists so useful for data that grows and changes over the life of a program. To change an element, you assign a new value to a particular index.

fruits = ["apple", "banana", "cherry"]
fruits[1] = "blueberry"      # replace the element at index 1
print(fruits)                # ['apple', 'blueberry', 'cherry']

This is exactly the operation that strings forbid. With a string, word[1] = "x" raises an error; with a list, fruits[1] = "blueberry" works and modifies the list. Internalize this contrast, because it drives how you use each type: reach for a list precisely when you need a collection you can modify.

A subtlety: lists and shared references

Recall from the variables lesson that a variable is a name pointing at a value, not a box holding one. With mutable lists this has a consequence that surprises many beginners. If you assign one list variable to another, both names point at the same list, so a change through one name is visible through the other.

a = [1, 2, 3]
b = a              # b points at the SAME list as a, not a copy
b.append(4)
print(a)           # [1, 2, 3, 4]  -- a changed too!
print(b)           # [1, 2, 3, 4]

Because b = a makes b refer to the same list object, appending through b also shows up through a; there is only one list, with two names. If you genuinely want an independent copy, make one explicitly, for example with a full slice b = a[:] or with b = list(a), both of which build a new list with the same elements. This shared-reference behavior is not a flaw; it is a direct result of variables being names for values, and it is worth understanding early so it does not trip you up later.

a = [1, 2, 3]
b = a[:]           # a real copy this time
b.append(4)
print(a)           # [1, 2, 3]  -- unchanged
print(b)           # [1, 2, 3, 4]

Growing and shrinking a list with methods

Lists come with many built-in methods, which are functions attached to the list and called with a dot after the list's name. The most important growing method is append(), which adds a single element to the end of the list. Because it modifies the list in place, you do not assign its result to anything; in fact append returns None, so writing scores = scores.append(5) is a classic bug that throws away your list.

scores = [90, 85, 72]
scores.append(100)           # add 100 to the end
scores.append(64)            # add 64 to the end
print(scores)                # [90, 85, 72, 100, 64]

To insert at a specific position rather than the end, use insert(index, value). To remove elements, you have several choices. remove(value) deletes the first element equal to the value you give. pop() removes and returns the last element, or, given an index, removes and returns the element at that position, which is useful when you want to both take an element out and use it. The del statement removes an element at a given index without returning it.

scores = [90, 85, 72, 100]
scores.insert(1, 88)         # put 88 at index 1
print(scores)                # [90, 88, 85, 72, 100]

scores.remove(72)            # delete the first 72
print(scores)                # [90, 88, 85, 100]

last = scores.pop()          # remove and return the last element
print(last)                  # 100
print(scores)                # [90, 88, 85]

del scores[0]                # remove the element at index 0
print(scores)                # [88, 85]

A word of caution about remove(): it deletes only the first matching element and raises a ValueError if the value is not present at all, so check with the in operator first if you are not sure the value exists. Speaking of which, you can test whether a value is in a list using the same in operator you used with strings, which gives a boolean.

names = ["Ada", "Grace", "Alan"]
print("Grace" in names)      # True
print("Bob" in names)        # False
if "Ada" in names:
    print("Ada is on the list.")

Looping over a list

One of the most common things you will ever write is a for loop over a list. Because a list is iterable, a for loop visits each element in order, assigning it to the loop variable. This is where lists and loops combine into real data processing.

scores = [90, 85, 72, 100, 64]
for score in scores:
    print("Score:", score)

Each iteration, the loop variable score takes the next element, and the body runs. Combine this with the accumulator pattern to compute totals and averages, one of the most practical things you can do with a list of numbers.

scores = [90, 85, 72, 100, 64]
total = 0
for score in scores:
    total = total + score
average = total / len(scores)
print("Total:", total)               # 411
print("Average:", average)           # 82.2

Here the accumulator total starts at zero and grows by each score, and then dividing by the count, which len() provides, gives the average. This pattern, loop and accumulate over a list, then divide by the length, is the standard way to average, and you will use it constantly. Sometimes you need the index as well as the element, for which you can loop over range(len(scores)) and index in, or use the cleaner built-in enumerate(), which hands you both position and value each iteration.

fruits = ["apple", "banana", "cherry"]
for index, fruit in enumerate(fruits):
    print(index, fruit)
# 0 apple
# 1 banana
# 2 cherry

Useful list functions and methods

Python provides built-in functions that summarize a list of numbers at a glance. sum() adds all the elements, max() finds the largest, min() the smallest, and len() counts them. These save you from writing an accumulator loop for the common cases, though it is valuable to know how to write the loop yourself, which is why an assignment this course asks you to find a maximum by hand.

scores = [90, 85, 72, 100, 64]
print(sum(scores))       # 411
print(max(scores))       # 100
print(min(scores))       # 64
print(len(scores))       # 5
print(sum(scores) / len(scores))   # 82.2, the average in one line

Two more methods are worth knowing now. sort() arranges the list in place from smallest to largest (or alphabetically for strings), permanently changing the list's order. count(value) tells you how many times a value appears. There is also the built-in sorted() function, which returns a new sorted list without changing the original, useful when you want to keep the original order too.

numbers = [5, 2, 9, 1, 7]
numbers.sort()               # sorts in place
print(numbers)               # [1, 2, 5, 7, 9]

letters = ["c", "a", "b", "a"]
print(letters.count("a"))    # 2

data = [3, 1, 2]
print(sorted(data))          # [1, 2, 3]  (a new list)
print(data)                  # [3, 1, 2]  (original unchanged)

The distinction between sort(), which changes the list in place and returns None, and sorted(), which leaves the original alone and returns a new list, mirrors the append trap: never write numbers = numbers.sort(), because sort returns None and you would lose your list. Use numbers.sort() on its own line, or use sorted() when you want a returned value.

Building a list inside a loop

A very common technique is to start with an empty list and append to it inside a loop, building up a collection as you go. This is how you gather results, filter items, or transform one list into another.

# Collect the squares of 1 through 5
squares = []
for n in range(1, 6):
    squares.append(n * n)
print(squares)               # [1, 4, 9, 16, 25]

# Keep only the even numbers from a list
numbers = [4, 7, 10, 3, 8, 15]
evens = []
for n in numbers:
    if n % 2 == 0:
        evens.append(n)
print(evens)                 # [4, 10, 8]

Both examples follow the same shape: create an empty list before the loop, then append the results you want inside. The first transforms each number into its square; the second filters, appending only elements that pass a test. This "empty list then append in a loop" pattern is one you will reach for again and again, and it is the foundation for the more compact list comprehensions you may meet later.

A complete worked example: collecting and analyzing scores

Let us combine input, a loop, a sentinel, list building, and the summary functions into a program that reads scores from the user until they type done, then reports statistics.

scores = []
entry = input("Enter a score, or 'done' to finish: ")
while entry != "done":
    scores.append(int(entry))
    entry = input("Enter a score, or 'done' to finish: ")

if len(scores) == 0:
    print("No scores were entered.")
else:
    print("You entered", len(scores), "scores.")
    print("Highest:", max(scores))
    print("Lowest:", min(scores))
    print("Average:", sum(scores) / len(scores))

Trace the structure. We start with an empty list and read entries in a sentinel-controlled while loop, appending each converted number until the user types done. Afterward, we guard against the empty case, because max() and division would fail on an empty list, and then report the count, highest, lowest, and average using the built-in functions. This program does not know in advance how many scores it will receive, which is exactly why a list, which can grow to any size, is the right tool. Every technique here comes from this lesson and the ones before it.

Common mistakes and how to fix them

  • Off-by-one index errors. Valid indexes run from 0 to len minus one. Asking for the element at index equal to the length raises an IndexError. The last element is at index len minus one, or simply at index -1.
  • Assigning the result of append or sort. Methods like append() and sort() change the list in place and return None. Never write my_list = my_list.append(x); call the method on its own line instead.
  • Expecting a copy when you made an alias. Writing b = a makes both names point at the same list, so changes through one affect the other. Use b = a[:] or b = list(a) for a real copy.
  • Calling remove on a value that is not present. remove() raises a ValueError if the value is absent. Check with in first when unsure.
  • Modifying a list while looping over it. Adding or removing elements from a list during a for loop over that same list can skip items or misbehave. Build a new list instead, or loop over a copy.
  • Running max, min, or sum on an empty list. max([]) and min([]) raise errors, and dividing by len([]) divides by zero. Guard against the empty case, as the worked example does.

Recap

This week you learned the list, Python's essential structure for holding an ordered collection of values in one variable. You create a list with square brackets, measure it with len(), and access its elements by index starting at zero, with negative indexes and slicing working just as they do for strings. The pivotal difference is that lists are mutable: you can change an element by index, and grow or shrink the list with methods like append(), insert(), remove(), and pop(), while testing membership with the in operator. You learned the important subtlety that assigning one list to another shares a reference rather than copying, and how to make a real copy when you need one. You looped over lists with for, combined them with the accumulator pattern to total and average, used enumerate() for index-and-value, and applied the built-in sum(), max(), min(), sort(), and sorted(). Above all, you practiced the build-a-list-in-a-loop pattern of starting empty and appending, which powers so much real data processing. Next week you add two more collection types, the immutable tuple and the key-based dictionary.

Sources

  1. Charles Severance, "Python for Everybody," Chapter 8: Lists. Free full text at py4e.com/html3/08-lists
  2. Al Sweigart, "Automate the Boring Stuff with Python," 2nd edition, Chapter 4: Lists. Free to read at automatetheboringstuff.com/2e/chapter4/
  3. Python Software Foundation, "The Python Tutorial," Section 5.1: More on Lists (list methods). docs.python.org/3/tutorial/datastructures.html
  4. Python Software Foundation, "The Python Tutorial," Section 3.1.3: Lists (introduction to the list type). docs.python.org/3/tutorial/introduction.html#lists
Key terms
List
An ordered, changeable collection of values written in square brackets.
Element
A single item stored in a list.
Mutable
Able to be changed in place after it is created.
append()
A list method that adds an item to the end of the list.
Method
A function that belongs to an object and is called with dot notation.
in operator
An operator that tests whether a value is present in a collection.

Week 9 - Tuples & Dictionaries

Fixed records and key-value lookups

  • Create tuples and explain immutability.
  • Store and retrieve data in a dictionary.
  • Loop over dictionary keys and values.

Last week you learned the list, a flexible ordered collection you can change at will. This week you add two more collection types that round out your data-structure toolkit, each solving a different problem. The first is the tuple, which is like a list but fixed and unchangeable, perfect for grouping a few related values that belong together and should not be altered. The second, and by far the more transformative, is the dictionary, which stores data as labeled pairs and lets you look things up by a meaningful name rather than a numeric position. Dictionaries are one of the most powerful and frequently used tools in all of Python, underlying everything from counting words to representing records of structured data. By the end of this lesson you will know when to reach for each of these three collections, list, tuple, and dictionary, and you will be able to build programs that organize information the way real applications do.

Tuples: fixed collections

A tuple is an ordered collection of values, much like a list, but with one defining difference: a tuple is immutable. Once you create a tuple, you cannot change, add, or remove its elements, ever. You write a tuple with values separated by commas, usually surrounded by parentheses. Everything you learned about indexing and slicing lists applies to tuples too, because a tuple is also an ordered sequence.

point = (3, 7)
rgb = (255, 128, 0)
person = ("Ada Lovelace", 1815, "mathematician")
print(point[0])        # 3
print(person[2])       # mathematician
print(len(rgb))        # 3

If tuples are just lists that cannot change, why would you ever want one? The immutability is precisely the point. Some groups of values are conceptually a single fixed thing: the x and y of a coordinate, the red-green-blue components of a color, the latitude and longitude of a location. Making them a tuple signals to anyone reading your code that these values belong together as a unit and are not meant to be modified piecemeal. It also protects against accidental change: try to modify a tuple and Python stops you with an error, catching a bug you might otherwise miss.

point = (3, 7)
# point[0] = 10        # this line would raise a TypeError: tuples cannot be changed
print(point)           # (3, 7), safely unchanged

One quirk to note: to write a tuple with a single element, you must include a trailing comma, as in (5,), because (5) without the comma is just the number 5 in parentheses. This catches many beginners. Also, the parentheses are technically optional in many contexts, so point = 3, 7 creates the same tuple as point = (3, 7), though including the parentheses is clearer.

Tuple unpacking

A wonderfully convenient feature of tuples (and lists) is unpacking: assigning the elements of a tuple to several variables in a single statement. You put the same number of names on the left of the equals sign as there are elements, and Python distributes them in order.

point = (3, 7)
x, y = point           # x becomes 3, y becomes 7
print(x + y)           # 10

person = ("Ada", 36, "engineer")
name, age, job = person
print(name, "is a", age, "year old", job)   # Ada is a 36 year old engineer

Unpacking makes code readable by giving each piece a clear name at the moment you pull it apart. It also enables a famous Python idiom for swapping two variables in one line, with no temporary variable needed, because the right side is packed into a tuple and then unpacked into the left side.

a = 1
b = 2
a, b = b, a            # swap in a single line
print(a, b)            # 2 1

Unpacking is also why functions can appear to return multiple values: a function returns a single tuple, and the caller unpacks it. You will see this pattern often, and it feels natural once you recognize the tuple underneath.

def min_and_max(numbers):
    return min(numbers), max(numbers)   # returns a tuple

low, high = min_and_max([4, 8, 15, 16, 23])
print("Lowest:", low, "Highest:", high)   # Lowest: 4 Highest: 23

Dictionaries: looking up by key

Now for the star of the week. A dictionary stores data as a collection of key-value pairs. Instead of finding a value by its numeric position, as you do with a list, you find it by its key, which is a meaningful label you choose. Think of a real dictionary, where you look up a word (the key) to find its definition (the value), or a phone book, where you look up a name to find a number. This lookup-by-name is what dictionaries are for, and it is enormously useful.

You write a dictionary with curly braces, listing each pair as a key, then a colon, then a value, with pairs separated by commas. To look up a value, you write the dictionary followed by the key in square brackets.

ages = {"Ada": 36, "Grace": 45, "Alan": 41}
print(ages["Ada"])       # 36
print(ages["Grace"])     # 45
print(len(ages))         # 3, the number of pairs

Here the keys are the strings "Ada", "Grace", and "Alan", and each maps to a number. Looking up ages["Ada"] returns 36 directly, with no need to know or care about any position. This is not only convenient but fast: dictionaries are designed so that lookups by key stay quick even when the dictionary holds thousands of entries, far quicker than searching a list element by element. Contrast the two mental models: a list is answered by "what is at position 2?" while a dictionary is answered by "what is stored under the label Ada?"

Adding, changing, and removing pairs

Dictionaries are mutable, like lists. You add a new pair simply by assigning to a new key, and you change an existing value by assigning to its key. If the key already exists, the assignment updates its value; if it does not, the assignment creates a new pair. To remove a pair, use the del statement or the pop() method.

ages = {"Ada": 36, "Grace": 45}
ages["Alan"] = 41            # add a new pair
ages["Ada"] = 37             # change Ada's value (same key, new value)
print(ages)                  # {'Ada': 37, 'Grace': 45, 'Alan': 41}

del ages["Grace"]            # remove the Grace pair
print(ages)                  # {'Ada': 37, 'Alan': 41}

removed = ages.pop("Alan")   # remove and return Alan's value
print(removed)               # 41

An important rule: each key in a dictionary is unique. You cannot have two pairs with the same key. Assigning to an existing key does not add a second pair; it overwrites the existing value. This uniqueness is exactly why dictionaries are perfect for things identified by a unique label, such as usernames, product codes, or, as we will see, distinct words being counted.

Checking for keys and reading safely

Trying to look up a key that does not exist raises a KeyError and stops your program, a very common beginner error. Before looking up a key you are unsure about, test for it with the in operator, which for dictionaries checks the keys.

ages = {"Ada": 36, "Grace": 45}
print("Ada" in ages)         # True
print("Bob" in ages)         # False

if "Grace" in ages:
    print("Grace is", ages["Grace"])
else:
    print("Grace is not in the dictionary.")

An even cleaner way to read a value that might be missing is the get() method. Given just a key, get() returns the value if the key exists and returns None if it does not, instead of raising an error. You can also give get() a second argument, a default to return when the key is absent, which is tidy and safe.

ages = {"Ada": 36, "Grace": 45}
print(ages.get("Ada"))          # 36
print(ages.get("Bob"))          # None, no error
print(ages.get("Bob", 0))       # 0, the default when Bob is missing

Using get() with a default is a favorite technique because it avoids KeyErrors and lets you write concise code, especially in the counting pattern you will meet shortly.

Looping over a dictionary

You will often want to visit every pair in a dictionary. Looping over a dictionary with a plain for loop gives you its keys, one at a time. From each key you can look up the value. More directly, the items() method gives you both the key and the value together on each iteration, which you unpack into two variables, exactly the tuple unpacking you learned earlier in this lesson.

ages = {"Ada": 36, "Grace": 45, "Alan": 41}

for name in ages:                 # looping gives the keys
    print(name)

for name in ages:                 # use each key to get its value
    print(name, "is", ages[name])

for name, age in ages.items():    # keys and values together
    print(name, "is", age, "years old")

The third form, looping over items() and unpacking into name, age, is the most common and readable way to walk through a whole dictionary, and it makes printing a table of data effortless. There are also the keys() and values() methods, which give you just the keys or just the values respectively, handy when you only need one or the other, for example sum(ages.values()) to total all the values.

ages = {"Ada": 36, "Grace": 45, "Alan": 41}
print(list(ages.keys()))     # ['Ada', 'Grace', 'Alan']
print(list(ages.values()))   # [36, 45, 41]
print(sum(ages.values()))    # 122

Dictionaries as records

One of the most valuable uses of a dictionary is to represent a single structured record, where each key names a field. A contact, a product, or a game character can each be one dictionary whose keys describe its attributes. This is precisely how the final Contact Book project stores each contact, and it is worth practicing now.

contact = {
    "name": "Ada Lovelace",
    "phone": "555-0100",
    "email": "ada@example.com"
}
print(contact["name"])       # Ada Lovelace
print(contact["email"])      # ada@example.com
contact["phone"] = "555-0199"   # update a field

# A list of records is a very common structure
people = [
    {"name": "Ada", "age": 36},
    {"name": "Grace", "age": 45}
]
for person in people:
    print(person["name"], "is", person["age"])

Notice the second example: a list whose elements are dictionaries. This combination, a list of records, is one of the most important data shapes in real programming, and it is how you would hold many contacts, many products, or many rows of data. You loop over the list to visit each record, and use keys to read each record's fields. Getting comfortable with lists of dictionaries prepares you directly for the capstone project and for working with real data later.

A complete worked example: counting word frequencies

The classic showcase for dictionaries is counting how many times each distinct item appears, and the textbook example is counting words in a piece of text. The dictionary's unique keys and fast lookup make this clean and efficient. Here is a program that reads a sentence and reports how often each word occurs.

sentence = input("Enter a sentence: ")
words = sentence.lower().split()   # a list of lowercase words
counts = {}                        # start with an empty dictionary

for word in words:
    if word in counts:
        counts[word] = counts[word] + 1   # seen before: add one
    else:
        counts[word] = 1                  # first time: start at one

for word, number in counts.items():
    print(word, ":", number)

Trace the logic. We lowercase the sentence and split it into a list of words. We start with an empty dictionary. For each word, we check whether it is already a key: if so, we increase its count by one; if not, we create the key with a count of one. After the loop, the dictionary maps each distinct word to how many times it appeared, and we print the pairs. This works because dictionary keys are unique, so each word gets exactly one entry that we keep bumping. The same pattern, using a dictionary to tally counts keyed by the thing being counted, applies far beyond words: counting votes, inventory, dice rolls, or visits. Using the get() method with a default of zero lets you write the update even more compactly, as counts[word] = counts.get(word, 0) + 1, collapsing the if-else into a single line.

# The same counting loop, written more concisely with get()
counts = {}
for word in words:
    counts[word] = counts.get(word, 0) + 1

Choosing among list, tuple, and dictionary

With three collection types available, a quick guide helps you choose. Use a list when you have an ordered collection of items that may change and that you access by position, such as a sequence of scores or a growing to-do list. Use a tuple when you have a fixed group of related values that should not change, such as a coordinate or a returned pair of results. Use a dictionary when you need to look values up by a meaningful key rather than a position, such as ages by name, or when you are representing a record with named fields. Many real programs combine all three, for example a list of dictionaries where some values are tuples. Learning to pick the right structure is a real programming skill, and it usually comes down to one question: do I find things here by position, or by name?

Common mistakes and how to fix them

  • Trying to change a tuple. Tuples are immutable; assigning to an element raises a TypeError. If you need to change the collection, use a list instead.
  • Forgetting the trailing comma in a one-element tuple. (5) is just the number 5; a single-element tuple must be written (5,).
  • Looking up a missing dictionary key. d["missing"] raises a KeyError. Check with in first, or use d.get(key) or d.get(key, default) to read safely.
  • Expecting duplicate keys. Keys are unique; assigning to an existing key overwrites its value rather than adding a second pair.
  • Unpacking the wrong number of variables. When unpacking a tuple, the number of names on the left must match the number of elements, or Python raises a ValueError.
  • Assuming a list when you have a dictionary. Indexing a dictionary with a number, like d[0], looks up the key 0, not the first pair. Dictionaries are accessed by key, not position.

Recap

This week you rounded out your collections with tuples and dictionaries. You learned that a tuple is an ordered but immutable collection, ideal for a fixed group of related values, and that tuple unpacking lets you assign several variables at once, enabling clean one-line swaps and multiple return values from functions. You then met the dictionary, which stores key-value pairs and lets you look up a value by a meaningful key instead of a numeric position, quickly and clearly. You learned to add, change, and remove pairs, to check for keys with in and read safely with get(), and to loop over a dictionary with items() to visit every pair. You saw dictionaries used as records with named fields, and the powerful list-of-dictionaries shape, and you built the classic word-frequency counter that showcases why dictionaries are so well suited to tallying. Finally you learned to choose among list, tuple, and dictionary by asking whether you access data by position or by name. Next week you deepen your command of text with the many string methods that make cleaning and transforming text easy.

Sources

  1. Charles Severance, "Python for Everybody," Chapter 9: Dictionaries. Free full text at py4e.com/html3/09-dictionaries
  2. Charles Severance, "Python for Everybody," Chapter 10: Tuples. Free full text at py4e.com/html3/10-tuples
  3. Al Sweigart, "Automate the Boring Stuff with Python," 2nd edition, Chapter 5: Dictionaries and Structuring Data. Free to read at automatetheboringstuff.com/2e/chapter5/
  4. Python Software Foundation, "The Python Tutorial," Section 5.3 (Tuples and Sequences) and 5.5 (Dictionaries). docs.python.org/3/tutorial/datastructures.html
Key terms
Tuple
An ordered, immutable collection written with parentheses.
Immutable
Unable to be changed after creation.
Unpacking
Assigning the items of a tuple or list to several variables at once.
Dictionary
A collection that maps keys to values for fast lookup.
Key
The unique label used to store and retrieve a value in a dictionary.
Value
The data associated with a key in a dictionary.

Week 10 - Working with Strings & Text Processing

Clean, search, and transform text

  • Use common string methods.
  • Split and join strings.
  • Search text and check patterns.

Text is everywhere in computing. Names, addresses, messages, web pages, spreadsheet cells, log files, and search queries are all text. Being able to clean, search, split, and transform text is one of the most practical skills a programmer can have, and it is the kind of task that automation excels at, turning an hour of tedious manual editing into a script that runs in a second. You met strings back in week three, where you learned indexing, slicing, and f-strings. This week you go much deeper into the toolbox Python gives you for working with text: its rich collection of string methods. You will learn to change case, trim stray whitespace, replace and search for substrings, and, most powerfully, to split text into pieces and join pieces back into text. These operations are the workhorses of real text processing, and by the end you will be able to parse and reshape text with confidence.

A reminder: strings are immutable

Before diving into methods, recall a rule from week three that governs everything this week: strings are immutable. You cannot change a character of an existing string in place. This has a direct and important consequence for string methods: none of them modify the string they are called on. Instead, every string method that transforms text returns a new string, leaving the original untouched. This trips up beginners constantly, so fix it firmly in mind now. If you want to keep the result of a string method, you must capture it, either by assigning it to a variable or by using it immediately. Simply calling a method and ignoring its return value changes nothing.

greeting = "hello"
greeting.upper()              # this returns "HELLO" but we ignored it
print(greeting)               # still "hello" -- the original is unchanged!

greeting = greeting.upper()   # capture the new string back into the variable
print(greeting)               # "HELLO"

The first greeting.upper() computes "HELLO" and then throws it away because we did not store it, so greeting is still "hello". Only when we assign the result back, as in the second version, does greeting hold the uppercase text. Remember: string methods return, they do not mutate. Whenever a string method seems to have "done nothing," this is almost always why.

What a method is

A method is a function that belongs to an object and is called using dot notation: you write the string (or a variable holding it), a dot, the method name, and parentheses. You already used methods with lists last week, like scores.append(5). String methods work the same way: text.upper() calls the upper method on the string text. The value before the dot is the string being worked on, and any information the method needs goes inside the parentheses as arguments. Because methods return new strings, you can even chain them, calling one method on the result of another, which we will see shortly.

Changing case

Several methods change the letter case of a string, which is invaluable for making comparisons that ignore capitalization. upper() returns an all-uppercase copy, lower() returns an all-lowercase copy, title() capitalizes the first letter of each word, and capitalize() capitalizes only the first letter of the whole string.

text = "hello, World"
print(text.upper())        # HELLO, WORLD
print(text.lower())        # hello, world
print(text.title())        # Hello, World
print(text.capitalize())   # Hello, world

The most common practical use of case methods is case-insensitive comparison. If a user types "YES", "yes", or "Yes", you probably want to treat them all the same. The standard trick is to lowercase (or uppercase) the input before comparing, so all variations collapse to one form.

answer = input("Continue? (yes/no): ")
if answer.lower() == "yes":
    print("Great, let us continue.")
else:
    print("Stopping.")

By comparing answer.lower() to the lowercase "yes", the check works no matter how the user capitalized their reply. This small habit prevents a whole category of frustrating "why didn't it match?" bugs.

Trimming whitespace with strip

Text from users and files is often surrounded by unwanted whitespace: spaces, tabs, and the newline characters that end lines. The strip() method returns a copy with leading and trailing whitespace removed, which is essential for cleaning input. Its cousins lstrip() and rstrip() strip only the left or only the right side. This is especially important when reading lines from a file, because each line arrives with a trailing newline that you almost always want gone.

messy = "   Ada Lovelace   "
print(messy.strip())                 # "Ada Lovelace" with no surrounding spaces
print(len(messy))                    # 18, including the spaces
print(len(messy.strip()))            # 12, spaces removed

line = "hello\n"                     # a line ending in a newline
print(line.strip())                  # "hello", newline removed

Stripping is often the very first thing you do to any piece of text you receive, because invisible trailing spaces cause comparisons to fail in maddening ways: "yes " with a trailing space is not equal to "yes". When a comparison that looks correct keeps failing, suspect stray whitespace and add a strip().

Replacing text

The replace() method returns a copy of the string with every occurrence of one substring swapped for another. You give it two arguments: the text to find and the text to put in its place. Like all string methods, it does not change the original; it returns a new string.

sentence = "I like cats. Cats are great."
print(sentence.replace("cats", "dogs"))   # I like dogs. Cats are great.
print(sentence.replace("Cats", "Dogs"))   # I like cats. Dogs are great.

phone = "555-123-4567"
print(phone.replace("-", ""))             # 5551234567, dashes removed

Note that replace is case-sensitive: replacing "cats" does not touch "Cats". Note too that it replaces all occurrences by default. Replacing a substring with the empty string, as in phone.replace("-", ""), is a neat way to delete characters entirely, which is common when cleaning phone numbers or formatting data.

Splitting a string into a list

Now we reach the most powerful text-processing tool: split(). The split method breaks a string into a list of smaller strings, cutting it at each occurrence of a separator. With no argument, it splits on any run of whitespace, which is exactly what you want to break a sentence into words. With an argument, it splits on that specific separator, which is how you parse structured text like comma-separated values.

sentence = "the quick brown fox"
words = sentence.split()          # splits on spaces
print(words)                      # ['the', 'quick', 'brown', 'fox']
print(len(words))                 # 4

line = "Ada,36,engineer"
fields = line.split(",")          # splits on commas
print(fields)                     # ['Ada', '36', 'engineer']
print(fields[1])                  # 36 (still a string!)

Splitting turns unstructured text into a list you can loop over, index, and process, which is the gateway to nearly all text parsing. A very common real task is reading a line of comma-separated data, splitting it into fields, and using each field. Remember that the pieces are always strings, so if a field represents a number, you must convert it with int() or float() before doing arithmetic, as in int(fields[1]) to get the age as a number.

Because split produces a list, and you know how to loop over lists, you can now count words, examine each word, or filter them. Here is word counting made trivial by split.

sentence = "the quick brown fox jumps over the lazy dog"
words = sentence.split()
print("Word count:", len(words))          # 9
for word in words:
    if len(word) > 4:
        print(word, "is a long word")

Joining a list back into a string

The natural partner of split is join(), which does the reverse: it glues a list of strings back together into a single string, placing a chosen separator between the pieces. The syntax reads a little backwards at first: you call join on the separator string, and pass the list as the argument. So "-".join(words) means "join the items of words with a hyphen between each."

words = ["the", "quick", "brown", "fox"]
print(" ".join(words))         # the quick brown fox  (spaces between)
print("-".join(words))         # the-quick-brown-fox  (hyphens between)
print("".join(words))          # thequickbrownfox     (nothing between)
print(", ".join(words))        # the, quick, brown, fox

The separator can be anything: a space, a hyphen, a comma and space for a readable list, or the empty string to concatenate with nothing between. One important rule: join only works on a list whose elements are all strings. If your list contains numbers, join raises a TypeError, and you must convert the numbers to strings first. Split and join together form the backbone of reformatting text: split a string apart on one separator, process the pieces, and join them back with another.

# Reformat a comma list into a nicely spaced one
raw = "apple,banana,cherry"
items = raw.split(",")             # ['apple', 'banana', 'cherry']
pretty = " | ".join(items)         # apple | banana | cherry
print(pretty)

Searching within text

You frequently need to know whether, or where, some text appears inside a larger string. The simplest test is the in operator, which gives a boolean answer to "does this substring appear here?" To ask specifically about the start or end of a string, use startswith() and endswith(), which are perfect for checking file extensions, prefixes, and the like. To find the exact position of a substring, use find(), which returns the index where the substring begins, or -1 if it is not present at all.

email = "ada@example.com"
print("@" in email)                # True
print("xyz" in email)              # False
print(email.startswith("ada"))     # True
print(email.endswith(".com"))      # True
print(email.find("@"))             # 3  (the @ is at index 3)
print(email.find("z"))             # -1 (not found)

The -1 return from find when the substring is absent is a convention worth remembering, and it is why you often see code test if text.find(x) != -1: to mean "if x is present." For a simple yes-or-no, though, the in operator reads more clearly. There is also a count() method that tells you how many times a substring occurs, useful for tallying.

text = "banana"
print(text.count("a"))     # 3
print(text.count("na"))    # 2

Checking the content of a string

A family of methods answers yes-or-no questions about what a string contains, all returning booleans. isdigit() tells you whether every character is a digit, which is a safe way to check before converting input to an int. isalpha() checks for all letters, and isspace() for all whitespace. These are handy for validating input without risking a conversion error.

value = input("Enter a whole number: ")
if value.isdigit():
    number = int(value)
    print("Twice that is", number * 2)
else:
    print("That was not a whole number.")

By checking value.isdigit() before calling int(), you avoid the crash that would happen if the user typed letters. This is a lightweight form of input validation; later, with try and except, you will learn an even more general way to handle bad conversions gracefully.

Chaining methods together

Because every string method returns a new string, you can call a method directly on the result of another, stringing several transformations together in one expression. This is called method chaining, and it reads left to right as a pipeline of operations. It is concise and, once you are used to it, very readable.

messy = "   Hello, World!   "
result = messy.strip().lower().replace(",", "")
print(result)              # "hello world!"

Read the chain step by step: start with the messy string, strip() removes the surrounding spaces, lower() makes it lowercase, and replace(",", "") deletes the comma, producing "hello world!". Each method hands its new string to the next. Chaining is common in real code for exactly this kind of cleanup, though if a chain grows too long it can become hard to read, in which case breaking it into separate steps with intermediate variables is perfectly fine.

A complete worked example: title-casing a sentence

Let us combine split, a loop, string methods, and join into a program that capitalizes the first letter of every word in a sentence, a small but genuinely useful text transformation. We will do it manually to practice the pieces, even though the built-in title() exists.

sentence = input("Enter a sentence: ")
words = sentence.split()               # break into a list of words
capitalized = []                       # start an empty result list
for word in words:
    capitalized.append(word.capitalize())   # capitalize each word
result = " ".join(capitalized)         # join back with spaces
print(result)

Trace the flow. We split the sentence into a list of words. We build a new list by looping over the words and appending each one capitalized, using the capitalize() method that uppercases a word's first letter. Finally we join the capitalized words back into a single string with spaces between them. If the user enters "hello there general", the program prints "Hello There General". This program is a perfect microcosm of text processing: split apart, transform the pieces in a loop, join back together. That three-step rhythm underlies an enormous amount of practical text work.

Common mistakes and how to fix them

  • Ignoring a method's return value. String methods do not change the original; they return a new string. Assign the result back or use it immediately. If a method seems to "do nothing," you forgot to capture its return.
  • Stray whitespace breaking comparisons. "yes " with a trailing space does not equal "yes". Strip input before comparing, and remember that lines read from files carry a trailing newline.
  • Forgetting that split gives strings. The pieces from split are always strings. Convert numeric fields with int() or float() before doing arithmetic.
  • Case-sensitive comparisons and replaces. "Cats" and "cats" are different. Lowercase both sides for case-insensitive comparison, and remember replace only affects the exact case you specify.
  • Calling join on a list of numbers. join requires all elements to be strings. Convert numbers to strings first, or it raises a TypeError.
  • Getting the join syntax backwards. You call join on the separator and pass the list, as in ", ".join(items), not items.join(", ").

Recap

This week you built real command over text through Python's string methods, always remembering the governing rule that strings are immutable, so every method returns a new string rather than changing the original. You learned to change case with upper(), lower(), title(), and capitalize(), most usefully for case-insensitive comparisons, and to clean text with strip(), which removes the surrounding whitespace that so often breaks comparisons. You swapped text with replace(), and, most importantly, you learned the powerful pair split() and join(): split breaks a string into a list on a separator, opening the door to parsing structured text, and join glues a list of strings back together with a chosen separator. You searched text with the in operator, startswith(), endswith(), find(), and count(), validated content with isdigit() and friends, and chained methods into concise cleanup pipelines. Above all you learned the split-transform-join rhythm at the heart of text processing. Next week your programs gain permanence, learning to read and write files and to handle errors gracefully.

Sources

  1. Charles Severance, "Python for Everybody," Chapter 6: Strings (including string methods). Free full text at py4e.com/html3/06-strings
  2. Al Sweigart, "Automate the Boring Stuff with Python," 2nd edition, Chapter 4: Manipulating Strings. Free to read at automatetheboringstuff.com/2e/chapter4/
  3. Python Software Foundation, "Built-in Types: Text Sequence Type (str) and String Methods." docs.python.org/3/library/stdtypes.html#string-methods
  4. Python Software Foundation, "The Python Tutorial," Section 3.1.2: Strings. docs.python.org/3/tutorial/introduction.html#text
Key terms
String method
A built-in function attached to a string, such as .lower() or .strip().
strip()
A method that removes whitespace from the start and end of a string.
split()
A method that breaks a string into a list of pieces on a separator.
join()
A method that combines a list of strings into one string with a separator.
replace()
A method that returns a new string with occurrences of text swapped out.
find()
A method that returns the index of a substring, or -1 if not found.

Week 11 - Files & Exceptions

Read and write files, handle errors

  • Open, read, and write text files.
  • Use with to manage a file safely.
  • Catch errors with try and except.

Everything your programs have produced so far vanishes the moment they end. Variables live in memory, and memory is wiped clean when the program stops, so a score, a list of contacts, or a note the user typed is gone forever once you close the program. This week you fix that limitation in two complementary ways. First, you learn to read from and write to files, giving your programs a permanent memory on disk that survives between runs, so data you save today is still there tomorrow. Second, you learn to handle exceptions, the errors that arise while a program is running, such as a missing file or bad user input, so that instead of crashing, your program can respond gracefully and keep going. These two topics pair naturally, because working with files is exactly where things go wrong in the real world, and together they are essential for building software people can actually rely on, including this course's Contact Book project.

Why files matter

A file is a named collection of data stored on your computer's disk, which, unlike memory, retains its contents when the power is off. This property is called persistence: data that persists outlives the program that created it. When a game saves your progress, when a word processor stores your document, when a program remembers your settings, it is writing to a file. This week you focus on plain text files, the simplest and most common kind, which hold ordinary readable characters, the same kind you have been printing all along. Reading and writing text files is a skill you will use in almost every substantial program you ever write.

Opening a file

To work with a file, you first open it using the built-in open() function, which takes two arguments: the file's name, and a mode that says what you intend to do. The mode is a short string. The three you will use most are "r" to read an existing file, "w" to write to a file (creating it if needed, and erasing any existing contents), and "a" to append, which adds to the end of a file without erasing what is already there. Open returns a file object that you then read from or write to.

ModeNameWhat it does
"r"readOpen an existing file for reading. Error if the file does not exist.
"w"writeOpen for writing. Creates the file, or erases it completely if it already exists.
"a"appendOpen for writing at the end. Creates the file if needed, keeps existing contents.

The most important warning here concerns "w": opening a file in write mode immediately and permanently erases whatever was in it. If you meant to add to a file but opened it with "w", you will destroy its contents. When you want to preserve existing data and add more, use "a" for append. This is a mistake that has cost people real data, so respect the difference.

The with statement: the right way to open files

Every open file must eventually be closed, which flushes any pending writes to disk and releases the file so other programs can use it. You could call close() manually, but it is easy to forget, especially if an error occurs before you reach the close. Python provides a much better tool: the with statement, which opens a file for the duration of an indented block and automatically closes it when the block ends, even if something goes wrong inside. Using with is the recommended, modern way to work with files, and you should adopt it as your default.

with open("notes.txt", "w") as f:
    f.write("First line\n")
    f.write("Second line\n")
# the file is automatically closed here, when the block ends

Read the structure: with open(...) as f: opens the file and gives you a file object named f (any name works, but f is conventional) for use inside the indented block. When the block finishes, Python closes the file for you. You never have to remember to close it, and you never leave a file dangling open. This is why with is preferred over manual open and close, it is both safer and cleaner.

Writing to a file

Once a file is open for writing or appending, you send text to it with the write() method. There is one crucial difference from print(): write does not automatically add a newline at the end. If you want each write to be on its own line, you must include the newline character yourself, written as backslash-n, at the end of your text. Forgetting this is a common beginner mistake that runs everything together on one line.

with open("shopping.txt", "w") as f:
    f.write("apples\n")
    f.write("bread\n")
    f.write("milk\n")
# creates shopping.txt with three lines

Each f.write(...) adds its text to the file, and the trailing backslash-n moves to a new line so the next item starts fresh. To write a list of items, loop over the list and write each one with a newline appended.

items = ["apples", "bread", "milk", "eggs"]
with open("shopping.txt", "w") as f:
    for item in items:
        f.write(item + "\n")

To add to a file without erasing it, open it in append mode instead. This is how a program keeps a running log, adding a new line each time it runs while preserving the earlier entries.

with open("log.txt", "a") as f:
    f.write("The program ran again.\n")
# earlier lines in log.txt are kept; this one is added at the end

Reading from a file

Reading is where files become truly useful, letting a program load data it saved before. Open the file in read mode, and you have several ways to get its contents. The read() method returns the entire file as one big string. The readlines() method returns a list where each element is one line. But the cleanest and most memory-friendly approach, and the one you should prefer, is to loop directly over the file object with a for loop, which hands you one line at a time.

with open("shopping.txt", "r") as f:
    for line in f:
        print(line.strip())
# prints each item on its own line

Here is a detail that matters enormously: each line you read from a file includes the newline character at its end, the very backslash-n you wrote. If you print the line as-is, you often get an unwanted blank line between items, because print adds its own newline on top of the one already in the text. The fix is to call strip() on each line, which removes the trailing newline (and any stray spaces), giving clean output. Always suspect a leftover newline when file output has mysterious extra blank lines. Reading lines and stripping them is such a common combination that it becomes automatic.

A frequent pattern is to read all the lines into a list so you can work with them as data, counting them, processing each, or loading them into your program's data structures.

lines = []
with open("shopping.txt", "r") as f:
    for line in f:
        lines.append(line.strip())
print("There are", len(lines), "items.")
print(lines)               # ['apples', 'bread', 'milk', 'eggs']

A round trip: write then read

Putting writing and reading together shows the full value of files. This small program saves a list to a file, then, as a separate step that could just as well be a separate run of the program, reads it back and displays it with line numbers.

# Save some notes to a file
notes = ["Buy groceries", "Call the dentist", "Finish the report"]
with open("todo.txt", "w") as f:
    for note in notes:
        f.write(note + "\n")

# Later, read them back and number them
with open("todo.txt", "r") as f:
    number = 1
    for line in f:
        print(number, line.strip())
        number = number + 1
# prints:
# 1 Buy groceries
# 2 Call the dentist
# 3 Finish the report

The key insight is that the data written in the first block genuinely persists on disk, so the second block, even if run days later in a fresh program, finds it there. This is exactly the mechanism the Contact Book project uses to remember contacts between runs: it writes them to a file on exit and reads them back on startup.

When things go wrong: exceptions

Files are where reality intrudes on your tidy programs. The file might not exist, might be misspelled, or might be unreadable. And beyond files, users type letters where numbers are expected, and programs divide by zero. When Python encounters an error while running, it raises an exception, a signal that something has gone wrong. If nothing handles the exception, the program stops immediately and prints an error, which is a crash. You have seen these: a ValueError from int("hello"), an IndexError from reading past the end of a list, a KeyError from a missing dictionary key, and a FileNotFoundError from opening a file that is not there.

Crashing is fine while you are learning, because the error message tells you what to fix. But finished software should not crash on foreseeable problems like a mistyped filename. Instead, it should catch the problem and respond sensibly. That is what the try and except statement is for.

Handling exceptions with try and except

The try/except structure lets you attempt risky code and specify what to do if it fails. You put the code that might raise an exception inside a try block. You follow it with an except block that runs only if an exception occurs in the try. If the try succeeds, the except is skipped entirely; if the try raises an exception, Python jumps immediately to the except instead of crashing.

try:
    age = int(input("Enter your age: "))
    print("Next year you will be", age + 1)
except ValueError:
    print("That was not a whole number. Please enter digits only.")

Trace both outcomes. If the user types 20, the conversion succeeds, the try block finishes normally, and the except is skipped, printing that next year they will be 21. If the user types "hello", the int() call raises a ValueError, so Python abandons the rest of the try and runs the except block, printing a friendly message instead of crashing. The word ValueError after except names the specific kind of exception this block handles, which is good practice: catch the particular error you expect, not every possible error, so you do not accidentally hide bugs you did not anticipate.

Handling a missing file is the canonical example, and it is why exceptions and files belong in the same lesson. Attempting to open a nonexistent file raises a FileNotFoundError, which you can catch to respond gracefully.

try:
    with open("data.txt", "r") as f:
        for line in f:
            print(line.strip())
except FileNotFoundError:
    print("No data file found yet. Starting fresh.")

If data.txt exists, its lines print. If it does not, instead of crashing the program calmly reports that there is no file yet and could carry on, perhaps creating one. This is precisely the logic a program needs on its first run, before any data has been saved, and it is how the Contact Book handles starting up with no saved contacts.

Catching different errors, and the else and finally clauses

You can provide several except blocks to handle different exception types differently, and Python runs the first one that matches the exception that occurred. You can also add an optional else block that runs only when the try succeeded with no exception, and a finally block that runs no matter what, success or failure, which is useful for cleanup.

try:
    number = int(input("Enter a number: "))
    result = 100 / number
except ValueError:
    print("That was not a number.")
except ZeroDivisionError:
    print("You cannot divide by zero.")
else:
    print("100 divided by your number is", result)
finally:
    print("Thanks for using the calculator.")

Here, a non-numeric entry triggers the ValueError block, entering zero triggers the ZeroDivisionError block, and a valid nonzero number falls through to the else, which safely uses the result. In every case, the finally block runs at the end, printing the thank-you. Matching specific exception types like this lets your program give precise, helpful feedback for each kind of problem rather than one vague message.

Validating input in a loop with try and except

Combining exceptions with a while loop gives you robust input handling that keeps asking until the user provides something valid, never crashing on a typo. This is one of the most practical patterns you will use.

while True:
    try:
        age = int(input("Enter your age: "))
        break                    # success, leave the loop
    except ValueError:
        print("Please enter a whole number and try again.")
print("Your age is", age)

The loop tries to read and convert a number. If it succeeds, the break exits the loop with a valid age in hand. If the conversion fails, the except prints a message and the loop repeats, asking again. The user can mistype as many times as they like; the program patiently retries and only proceeds with good input. This is exactly how professional programs handle the keyboard, and it is far friendlier than a crash.

Common mistakes and how to fix them

  • Erasing a file by opening it with "w". Write mode wipes the file immediately. To keep existing contents and add more, use append mode "a". Double-check the mode before writing to a file that holds important data.
  • Forgetting newlines when writing. Unlike print, write() adds no newline. Include backslash-n at the end of each line yourself, or everything runs together.
  • Extra blank lines when reading. Lines read from a file keep their trailing newline. Call strip() on each line before printing to avoid double spacing.
  • Not using with. Opening a file without the with statement risks leaving it open if an error occurs. Prefer with open(...) as f: so the file always closes.
  • Catching too broadly. Prefer catching the specific exception you expect, like ValueError or FileNotFoundError, rather than a bare except that hides unrelated bugs.
  • Putting too much inside try. Keep only the risky line or two inside the try block so you know exactly what failed, rather than wrapping a large chunk of code.

Recap

This week your programs gained permanence and resilience. You learned that files store data on disk so it persists between runs, and that you open a file with open() and a mode, remembering that "w" erases, "a" appends, and "r" reads. You adopted the with statement as the right way to open files, since it closes them automatically, and you wrote to files with write(), adding your own newline characters, and read from them by looping over the file object and stripping each line to remove the trailing newline. You saw a full write-then-read round trip that mirrors how real programs save and load data. You then learned to handle exceptions, the runtime errors that would otherwise crash a program, using try and except to attempt risky code and respond gracefully to specific errors like ValueError and FileNotFoundError, and you met the else and finally clauses and the robust pattern of validating input in a loop. With files and exception handling, you now have the tools to build programs that remember data and survive the unexpected. Next week you learn to reuse vast amounts of ready-made code through modules and the standard library.

Sources

  1. Charles Severance, "Python for Everybody," Chapter 7: Files. Free full text at py4e.com/html3/07-files
  2. Al Sweigart, "Automate the Boring Stuff with Python," 2nd edition, Chapter 9: Reading and Writing Files. Free to read at automatetheboringstuff.com/2e/chapter9/
  3. Python Software Foundation, "The Python Tutorial," Section 7.2: Reading and Writing Files. docs.python.org/3/tutorial/inputoutput.html#reading-and-writing-files
  4. Python Software Foundation, "The Python Tutorial," Section 8: Errors and Exceptions (handling exceptions with try and except). docs.python.org/3/tutorial/errors.html
Key terms
File mode
The string like "r", "w", or "a" that sets whether you read, overwrite, or append.
open()
The built-in function that opens a file and returns a file object.
with statement
A block that automatically closes a resource such as a file when it ends.
Exception
An error detected while the program is running.
try/except
A structure that attempts risky code and handles any exception it raises.
ValueError
The exception raised when a value has the right type but an invalid content, such as int("abc").

Week 12 - Modules & the Standard Library

Reuse code with import

  • Import and use standard library modules.
  • Call functions from a module with dot notation.
  • Explore what the standard library offers.

One of the most important lessons in all of programming is that you do not have to build everything from scratch. Decades of programmers have already written, tested, and refined solutions to countless common problems: generating random numbers, computing square roots, working with dates, reading data formats, and thousands more. Python bundles an enormous amount of this ready-made code with every installation, free for you to use. This week you learn to tap into it through modules and the standard library. You will learn how to bring existing code into your program with the import statement, how to call the functions it provides, and how to explore what is available. Learning to find and reuse existing code, rather than reinventing it, is one of the biggest force multipliers in becoming a productive programmer, and it is a skill that separates beginners from professionals.

What a module is

A module is simply a file of Python code, containing functions, variables, and classes, that is meant to be reused by other programs. When you write a useful function, you can put it in a module and use it across many programs. But more excitingly, Python comes with hundreds of modules already written for you, forming the standard library: the large collection of modules that ships with Python at no cost and requires no separate download. Python's design philosophy is often summarized by the phrase "batteries included," meaning that a fresh Python installation already contains the tools for a huge range of tasks. This week focuses on the standard library, though everything you learn applies equally to modules you write yourself or install later.

Importing a module

Before you can use a module's contents, you must bring it into your program with the import statement. You write import followed by the module's name, usually at the very top of your file so it is clear what your program depends on. Once imported, you access the module's functions and values with dot notation: the module name, a dot, then the name of the thing you want. This dot notation is the same syntax you used for methods on strings and lists, and it clearly shows where each function comes from.

import math

print(math.sqrt(144))     # 12.0, the square root
print(math.pi)            # 3.141592653589793, a constant
print(math.floor(4.7))    # 4, rounds down
print(math.ceil(4.2))     # 5, rounds up

Here import math loads the math module, and then math.sqrt(144) calls the sqrt function that lives inside it, math.pi reads the constant pi, and so on. The math. prefix is not optional in this style; it tells Python to look for sqrt inside the math module, and it makes your code self-documenting, since a reader immediately sees that sqrt comes from math. The math module is full of useful mathematical functions and constants that would be tedious to write yourself.

The random module: bringing in chance

The random module lets your programs make random choices, which is essential for games, simulations, shuffling, and sampling. Its most useful functions for a beginner are randint(a, b), which returns a random whole number from a to b inclusive, choice(sequence), which picks a random element from a list or string, and shuffle(list), which randomly reorders a list in place.

import random

print(random.randint(1, 6))        # a dice roll: some number from 1 to 6
print(random.randint(1, 6))        # likely a different number

menu = ["tacos", "pasta", "salad", "soup"]
print(random.choice(menu))         # a random pick from the list

deck = [1, 2, 3, 4, 5]
random.shuffle(deck)               # reorders deck in place
print(deck)                        # e.g. [3, 1, 5, 2, 4]

Note that randint(1, 6) is inclusive of both ends, so it really can return 1, 6, and everything between, which is different from range, whose upper bound is excluded. This is a common point of confusion, so keep it in mind: randint includes both endpoints. The random module is what finally lets you make the number-guessing game truly unpredictable, choosing a fresh secret each time with random.randint(1, 100), rather than the fixed secret you used earlier.

Two ways to import

There are two common forms of the import statement, and it is worth knowing both. The form you have seen, import math, loads the whole module and requires you to prefix every use with the module name, as in math.sqrt. The alternative, the from import form, brings specific names directly into your program so you can use them without the prefix. You write from, the module name, import, and then the specific names you want.

from random import choice, randint

menu = ["tacos", "pasta", "salad"]
print(choice(menu))        # no random. prefix needed
print(randint(1, 10))      # called directly

from math import pi, sqrt
print(pi)                  # 3.141592653589793
print(sqrt(81))            # 9.0

Both forms are common and correct, and the choice is partly a matter of taste. The plain import math form, with prefixes, makes it obvious where each function comes from, which aids readability in larger programs and avoids name clashes if two modules happen to define functions with the same name. The from math import sqrt form is more concise and is handy when you use a function so often that the prefix becomes noise. A general guideline: prefer the prefixed form for clarity, and reach for the from form when brevity genuinely helps. Avoid the wildcard form from math import star (using an asterisk to import everything), because it dumps many names into your program invisibly and can cause confusing clashes.

Aliasing a module with as

Sometimes a module name is long, or the community has a conventional short name for it. The as keyword lets you give an imported module a shorter alias that you then use as the prefix. You will encounter this constantly in data and scientific code, where libraries have standard aliases, so it is good to recognize now even though the standard library modules in this course have short names already.

import random as rnd
print(rnd.randint(1, 100))     # use the alias rnd instead of random

A tour of the standard library

The standard library is vast, and part of becoming a capable programmer is knowing roughly what is available so you can look for it when a need arises. You do not memorize every module; you learn what kinds of problems the library solves and how to read its documentation. Here are several modules worth knowing about.

  • math: square roots, trigonometry, logarithms, constants like pi and e, and rounding functions like floor and ceil.
  • random: random numbers, random choices, and shuffling, for games and simulations.
  • datetime: working with dates and times, computing differences between dates, and formatting them.
  • os: interacting with the operating system, such as listing files in a folder or working with file paths.
  • json: reading and writing the JSON data format, which is how much data is exchanged between programs and over the web.
  • statistics: computing the mean, median, and standard deviation of data without writing the formulas yourself.

Here is a small taste of the datetime and statistics modules, to show how much power a single import can bring.

import datetime
today = datetime.date.today()
print("Today is", today)                 # e.g. 2025-06-15
print("The year is", today.year)         # e.g. 2025

import statistics
grades = [88, 92, 79, 93, 85]
print("Mean:", statistics.mean(grades))       # 87.4
print("Median:", statistics.median(grades))   # 88

Notice how statistics.mean computes an average in one call, saving you the accumulator loop you would otherwise write. This is the whole point of the standard library: common tasks are already solved, correctly and efficiently, so you can focus on the parts of your program that are unique to your problem.

Reading documentation and using help

No one memorizes the entire standard library. Instead, skilled programmers read documentation. The official Python documentation at docs.python.org describes every standard library module in detail, with the functions each provides and how to call them. You can also explore from within Python itself: the built-in help() function prints documentation for a module or function, and dir() lists the names a module contains. Learning to read documentation is arguably more important than learning any particular function, because it is how you will teach yourself everything new for the rest of your programming life.

import math
print(dir(math))          # lists all names available in math
help(math.sqrt)           # prints documentation for sqrt

When you wonder "does Python have something that does X?", the answer is very often yes, and the skill is knowing how to search the documentation to find it. Getting comfortable with docs.python.org early will pay off enormously.

A note on installing third-party modules

Beyond the standard library, an enormous ecosystem of third-party modules exists, written by the wider Python community and shared through the Python Package Index. These cover everything from web development to data science to game making. You install them with a tool called pip, typically by running a command like pip install requests in your terminal, after which you import them exactly like standard library modules. You do not need third-party packages for this course, which relies only on the standard library, but it is worth knowing they exist, because they are a major reason Python is so widely used. The import mechanics you learned this week are identical whether the module ships with Python or you installed it yourself.

A complete worked example: a randomized number-guessing game

Let us finally build the number-guessing game the way it is meant to be, with a secret that changes every time thanks to the random module. This ties together import, random.randint, a while loop, conditionals, and a counter.

import random

secret = random.randint(1, 100)    # a fresh secret each run
guesses = 0
print("I am thinking of a number between 1 and 100.")

while True:
    guess = int(input("Your guess: "))
    guesses = guesses + 1
    if guess < secret:
        print("Higher.")
    elif guess > secret:
        print("Lower.")
    else:
        print("Correct! You got it in", guesses, "guesses.")
        break

The one new ingredient compared with the earlier version is the first two lines: importing random and using random.randint(1, 100) to choose a secret. Because randint includes both endpoints, the secret can be anything from 1 to 100, and it is different every time you run the program, which is what makes the game replayable. Everything else, the loop, the hints, the counter, the break on a correct guess, is logic you already know. This is a perfect illustration of how a single import can transform a program, adding real capability with almost no extra code.

Common mistakes and how to fix them

  • Forgetting to import. Using math.sqrt or random.randint without first importing the module raises a NameError. Put your imports at the top of the file.
  • Forgetting the module prefix. After a plain import math, you must write math.sqrt(9), not just sqrt(9). To drop the prefix, import the name with the from form instead.
  • Confusing randint with range bounds. random.randint(1, 6) can return 6, because both ends are inclusive, unlike range, whose upper bound is excluded.
  • Naming your file the same as a module. If you name your own file random.py, Python may import your file instead of the real random module, causing baffling errors. Avoid naming your scripts after standard modules.
  • Overusing wildcard imports. Avoid the import-everything asterisk form, which hides where names come from and can cause clashes. Import specific names or use the prefix.
  • Assuming a function exists without checking. When unsure what a module offers, use dir() and help(), or read the official documentation, rather than guessing function names.

Recap

This week you learned to stand on the shoulders of others by reusing existing code through modules. A module is a file of reusable Python code, and the standard library is the large set of modules that comes free with Python, embodying its "batteries included" philosophy. You bring a module in with the import statement and call its contents with dot notation, as in math.sqrt and random.randint. You learned the two import forms, plain import module with a prefix and from module import name without one, along with aliasing via as, and when each is appropriate. You toured several valuable modules, math, random, datetime, os, json, and statistics, and used random to finally build a properly randomized guessing game. Most importantly, you learned that the real skill is not memorizing functions but knowing how to explore what is available with dir() and help() and how to read the official documentation, a habit that will let you teach yourself anything. You also learned that third-party packages, installed with pip, extend this same mechanism far beyond the standard library. Next week you take a major conceptual step into object-oriented programming, learning to design your own types with classes.

Sources

  1. Al Sweigart, "Automate the Boring Stuff with Python," 2nd edition, Chapter 5 and the appendices on modules; see also the coverage of the math and random modules. Free to read at automatetheboringstuff.com/2e/
  2. Python Software Foundation, "The Python Tutorial," Section 6: Modules. docs.python.org/3/tutorial/modules.html
  3. Python Software Foundation, "The Python Standard Library" reference, especially the math and random module pages. docs.python.org/3/library/index.html
  4. Python Software Foundation, "The Python Standard Library: random - Generate pseudo-random numbers." docs.python.org/3/library/random.html
Key terms
Module
A file of reusable Python code you can import into your program.
import
The statement that loads a module so you can use its contents.
Standard library
The large set of modules that comes bundled with Python.
Dot notation
Accessing a member of a module or object with a dot, like math.sqrt.
random module
A standard library module for generating random numbers and choices.
from import
A form of import that brings specific names into your file directly.

Week 13 - Introduction to Object-Oriented Programming

Model things with classes and objects

  • Define a class with attributes and methods.
  • Create objects from a class.
  • Use __init__ to initialize state.

You have spent this whole course using objects. A string is an object with methods like upper() and split(). A list is an object with methods like append() and sort(). Every value in Python is, in fact, an object. This week you take a profound step: instead of only using objects that Python's designers built, you learn to design and build your own. This is called object-oriented programming, often shortened to OOP, and it is one of the most important ways programmers organize larger programs. The central idea is to bundle related data together with the functions that operate on that data, creating your own custom types that model the things your program is about, a bank account, a playing card, a contact, an enemy in a game. This lesson introduces classes and objects gently, with plenty of examples, so that by the end you can define your own types and understand the object-oriented style you will meet everywhere in real Python code.

The problem OOP solves

Imagine writing a program that manages several dogs at a kennel, tracking each dog's name, age, and breed. With what you know so far, you might use parallel lists: a list of names, a list of ages, a list of breeds, all kept in the same order so that index 0 refers to the first dog across all three. This works, barely, but it is fragile and awkward. If you sort one list, the others fall out of sync. If you want to add a behavior, like a dog barking with its own name, you have to write a standalone function and pass it the right pieces. As the program grows, keeping related data together and coordinated becomes a real burden.

Object-oriented programming offers a cleaner way. Instead of scattering a dog's data across separate lists, you bundle everything about one dog, its data and its behaviors, into a single object. Each dog becomes one self-contained thing that knows its own name and age and knows how to bark. This bundling of state (the data) and behavior (the functions) into one unit is the essence of OOP, and it is what keeps large programs organized and understandable.

Classes and objects: blueprints and instances

Two words are at the heart of this week, and the relationship between them is the key idea. A class is a blueprint that describes what a certain kind of object looks like and can do. An object, also called an instance, is a specific thing built from that blueprint. The classic analogy is a cookie cutter and cookies: the class is the cutter, defining the shape, and each object is an individual cookie stamped out from it. Another analogy is an architect's blueprint (the class) and the many houses built from it (the objects). You define the class once, then create as many objects from it as you like, each with its own data but all sharing the same structure and behaviors.

You have already seen this relationship without naming it. list is a class, the blueprint for lists, and each list you make, like [1, 2, 3], is an object built from that blueprint. This week you create your own blueprints.

Defining a class

You define a class with the class keyword, followed by a name and a colon, then an indented block containing the class's contents. By strong convention, class names use CapWords style, also called PascalCase, where each word is capitalized and there are no underscores, as in Dog, BankAccount, or PlayingCard. This capitalization distinguishes classes from variables and functions at a glance. Here is a minimal class and how to create an object from it.

class Dog:
    def bark(self):
        return "Woof!"

# Create an object (an instance) from the class
rex = Dog()
print(rex.bark())      # Woof!

The class Dog defines one behavior, a method called bark. To create an object from the class, you write the class name followed by parentheses, as in Dog(), which builds a new Dog object and, here, stores it in the variable rex. You then call the object's methods with dot notation, just as you have always done, so rex.bark() runs the bark method and returns "Woof!". Creating an object from a class is called instantiation, and each object you create is independent of the others.

The __init__ method: giving an object its data

A dog that only barks is not very interesting; we want each dog to have its own name and age. To give objects their own data, you define a special method named __init__ (that is two underscores, the word init, and two more underscores). Python calls this method automatically every time you create an object, and its job is to set up the object's starting data. Methods with names surrounded by double underscores are special methods that Python calls in particular situations; init is the one that runs at creation, and it is often called the initializer or, loosely, the constructor.

class Dog:
    def __init__(self, name, age):
        self.name = name       # store name on this object
        self.age = age         # store age on this object

    def bark(self):
        return self.name + " says woof!"

rex = Dog("Rex", 3)            # __init__ runs with name="Rex", age=3
print(rex.name)                # Rex
print(rex.age)                 # 3
print(rex.bark())              # Rex says woof!

Trace what happens at rex = Dog("Rex", 3). Python creates a new empty Dog object and immediately calls __init__, passing the new object plus the arguments "Rex" and 3. Inside init, those get stored on the object. Afterward, rex is a fully set-up Dog that knows its name and age. Notice that although init lists three parameters, you only pass two arguments when creating the object; the first parameter, self, is supplied automatically by Python, which brings us to the most important and most confusing part of this lesson.

Understanding self

Every method in a class has a special first parameter conventionally named self, and understanding it is the key to understanding OOP. When you call a method on an object, like rex.bark(), Python automatically passes that object as the first argument, so inside the method, self refers to the specific object the method was called on, in this case rex. This is how a method knows which object's data to use. When bark does self.name, it means "the name of whichever dog I was called on."

This is why methods can work correctly on different objects. If you have two dogs and call bark on each, self refers to a different dog each time, so each bark uses that dog's own name. The self parameter is what connects a method to the particular object's data.

class Dog:
    def __init__(self, name, age):
        self.name = name
        self.age = age

    def describe(self):
        return self.name + " is " + str(self.age) + " years old"

rex = Dog("Rex", 3)
fido = Dog("Fido", 7)

print(rex.describe())     # Rex is 3 years old   (self is rex)
print(fido.describe())    # Fido is 7 years old  (self is fido)

The same describe method produces different results because self points to a different object each call. Two rules will keep you out of trouble: first, always make self the first parameter of every method you define in a class; second, always use self. to access the object's own data and methods from inside the class. You do not pass self yourself when calling a method, Python fills it in, but you must list it in every method definition. Forgetting self is the single most common beginner error in OOP, so watch for it.

Attributes: the data on an object

The pieces of data stored on an object, like self.name and self.age, are called attributes. Attributes are variables that belong to a specific object. You usually create them in __init__, and you access them from outside the object with dot notation, as in rex.name, or from inside the class with self.name. Each object has its own independent copy of its attributes, which is exactly the "bundling of state" that makes objects useful.

rex = Dog("Rex", 3)
fido = Dog("Fido", 7)

print(rex.age)        # 3
print(fido.age)       # 7  (completely independent)

rex.age = 4           # you can change an attribute from outside
print(rex.age)        # 4
print(fido.age)       # 7  (unchanged; fido is a separate object)

Changing rex's age does not touch fido's, because they are separate objects with separate attributes. This independence is a cornerstone of object-oriented design: each object manages its own state.

Methods: the behavior of an object

Functions defined inside a class are called methods, and they define what objects of that class can do. Methods often use and modify the object's attributes through self. A method can take extra parameters beyond self, just like any function, and it can return a value or change the object's state. Here is a richer class where methods actually do work with the data.

class BankAccount:
    def __init__(self, owner, balance):
        self.owner = owner
        self.balance = balance

    def deposit(self, amount):
        self.balance = self.balance + amount

    def withdraw(self, amount):
        if amount > self.balance:
            print("Insufficient funds.")
        else:
            self.balance = self.balance - amount

    def show(self):
        print(self.owner + "'s balance:", self.balance)

account = BankAccount("Ada", 100)
account.deposit(50)
account.show()             # Ada's balance: 150
account.withdraw(30)
account.show()             # Ada's balance: 120
account.withdraw(1000)     # Insufficient funds.
account.show()             # Ada's balance: 120

Study how the methods cooperate with the attributes. The deposit method adds to self.balance, changing the object's state. The withdraw method checks the balance before subtracting, using a conditional to prevent an overdraft, showing how a method can enforce rules about its object's data. The show method reports the current state. Each method takes self so it can reach this account's own balance and owner. This class bundles the data (owner and balance) with the operations that make sense for it (deposit, withdraw, show) into one coherent unit, which is precisely what OOP is about, and it is far tidier than juggling separate variables and standalone functions.

The __str__ method: a friendly text form

By default, printing an object shows something unhelpful like a memory address. You can control how your object appears when printed by defining another special method, __str__, which returns a string describing the object. Python calls it automatically when you print the object or convert it to a string, giving your objects a readable form.

class Dog:
    def __init__(self, name, age):
        self.name = name
        self.age = age

    def __str__(self):
        return "Dog(name=" + self.name + ", age=" + str(self.age) + ")"

rex = Dog("Rex", 3)
print(rex)         # Dog(name=Rex, age=3)

Without __str__, printing rex would show a cryptic default. With it, print gives the clear description we defined. Adding a str method to your classes is a small touch that makes debugging and displaying objects much more pleasant, and it is a common and recommended practice.

A complete worked example: a Rectangle class

Let us build a complete, useful class from scratch: a Rectangle that knows its width and height and can compute its own area and perimeter. This is exactly the kind of small class the assignment asks for, and it shows the full shape of a class definition.

class Rectangle:
    def __init__(self, width, height):
        self.width = width
        self.height = height

    def area(self):
        return self.width * self.height

    def perimeter(self):
        return 2 * (self.width + self.height)

    def __str__(self):
        return "Rectangle " + str(self.width) + " by " + str(self.height)

r1 = Rectangle(4, 5)
r2 = Rectangle(3, 10)

print(r1)                  # Rectangle 4 by 5
print("Area:", r1.area())          # Area: 20
print("Perimeter:", r1.perimeter())  # Perimeter: 18
print("Area of r2:", r2.area())    # Area of r2: 30

Everything comes together here. The __init__ method stores the width and height as attributes. The area and perimeter methods compute results from those attributes using self, returning values the caller can use. The __str__ method gives a readable description. Two rectangles, r1 and r2, are independent objects, each with its own dimensions, so r1.area() and r2.area() give different results. This little class models a real concept cleanly, bundling the data that defines a rectangle with the operations that make sense for one, and it is a template you can adapt to model almost any noun in a program.

Why object-oriented programming matters

You might wonder why bother, when functions and dictionaries could do similar work. The payoff grows with program size. OOP lets you model your problem in terms of the things it involves, which makes code easier to think about: a program about a library naturally has Book and Member objects, a game has Player and Enemy objects. It keeps related data and behavior together, so you are never hunting for the function that goes with some data. And it supports powerful techniques you will meet if you continue, such as inheritance, where one class builds on another. For now, the key takeaway is that a class lets you define your own type, and objects of that type carry their own data and know their own behaviors. This is the same mechanism behind the strings and lists you have used all along, now in your own hands.

Common mistakes and how to fix them

  • Forgetting self in a method definition. Every method needs self as its first parameter. Leaving it out causes errors when the method is called. You do not pass self yourself, but you must declare it.
  • Forgetting self. when accessing attributes. Inside a class, write self.name, not just name, to reach the object's data. A bare name refers to a local variable, not the attribute.
  • Getting the __init__ name wrong. It must be exactly two underscores, init, two underscores. A single underscore or a typo means Python will not call it automatically, and your objects will lack their data.
  • Passing an argument for self. When creating an object or calling a method, do not pass self; Python supplies it. For __init__(self, name, age), you call Dog("Rex", 3) with two arguments, not three.
  • Confusing the class with an object. The class is the blueprint; an object is made by calling the class, as in Dog(...). You call methods on objects, not on the class itself.
  • Lowercasing class names. By convention class names use CapWords, like BankAccount. This is not required by Python but is a strong convention that aids readability.

Recap

This week you crossed into object-oriented programming, learning to design your own types rather than only using Python's built-in ones. You saw that OOP solves the problem of scattered, uncoordinated data by bundling state and behavior into objects. You learned that a class is a blueprint and an object (or instance) is a specific thing built from it by calling the class, as with Dog(...). You met the special __init__ method that Python calls automatically to set up an object's data, and, crucially, the self parameter, which refers to the particular object a method is working on and connects methods to that object's own attributes. You learned that attributes are the object's data and methods are its behavior, that each object keeps its attributes independently, and that the __str__ method gives objects a readable printed form. You built complete BankAccount and Rectangle classes that bundle data with meaningful operations. With classes in your toolkit, you can now model the concepts your programs are about, which is exactly what the Contact Book capstone will ask you to do. Next week you turn to algorithms, learning how the same task can be solved efficiently or slowly, and how to reason about the difference.

Sources

  1. Al Sweigart, "Automate the Boring Stuff with Python," 2nd edition, and its chapters introducing classes and object-oriented programming. Free to read at automatetheboringstuff.com/2e/
  2. Python Software Foundation, "The Python Tutorial," Section 9: Classes (including the class definition syntax, __init__, self, and instance objects). docs.python.org/3/tutorial/classes.html
  3. Python Software Foundation, "The Python Tutorial," Section 9.3.5: Class and Instance Variables. docs.python.org/3/tutorial/classes.html#class-and-instance-variables
  4. Charles Severance, "Python for Everybody," Chapter 14: Objects and classes. Free full text at py4e.com/html3/14-objects
Key terms
Class
A blueprint that defines the attributes and methods of a kind of object.
Object
A specific instance created from a class.
Instance
Another word for an individual object built from a class.
__init__
The special method that initializes a new object's attributes.
Attribute
A piece of data stored on an object, accessed through self.
self
The parameter referring to the current object inside a class method.

Week 14 - Algorithms: Searching, Sorting & Big-O

Solve problems efficiently

  • Describe linear and binary search.
  • Explain the idea behind a sorting algorithm.
  • Reason informally about Big-O growth.

You now know how to store data and how to loop over it, which means you are ready for a deeper question that sits at the heart of computer science: not just how to solve a problem, but how to solve it well. The same task, finding a name in a list, or putting numbers in order, can be accomplished by different procedures that vary enormously in speed. One approach might finish instantly on a million items while another grinds for minutes. This week you study algorithms, the step-by-step procedures we use to solve problems, through two classic examples every programmer learns: searching and sorting. Along the way you will learn to reason about efficiency using Big-O notation, a way of describing how an algorithm's cost grows as the data grows. This is more conceptual than previous weeks, and that is intentional: thinking about efficiency is a skill that will make you a better programmer regardless of the language you use.

What an algorithm is

An algorithm is a precise, finite, step-by-step procedure for solving a problem or accomplishing a task. The word predates computers by centuries; a recipe is an algorithm, and so is the long-division procedure you learned in school. In programming, an algorithm is the underlying method your code implements, independent of the particular language. The crucial insight of this week is that for most problems there are many possible algorithms, and choosing a good one matters far more than clever coding tricks. A well-chosen algorithm can be thousands of times faster than a poor one on large inputs, which is the difference between a program that feels instant and one that seems broken.

Searching: finding an item in a collection

Searching is one of the most common tasks in all of computing. You have data, and you want to find a particular item, or determine that it is not there. The simplest approach is linear search, also called sequential search: start at the beginning and check each item in turn until you find the target or reach the end. It is straightforward, it works on any list whether sorted or not, and you can already write it with a loop.

def linear_search(items, target):
    for i in range(len(items)):
        if items[i] == target:
            return i          # found it, return the position
    return -1                 # not found after checking everything

numbers = [4, 8, 15, 16, 23, 42]
print(linear_search(numbers, 15))    # 2  (15 is at index 2)
print(linear_search(numbers, 99))    # -1 (99 is not present)

This function returns the index where the target is found, or -1 if it never appears, following the common convention you saw with the string find method. It checks items one by one. In the best case, the target is the very first item and we finish immediately. In the worst case, the target is last or absent, and we check every single item. For a list of n items, linear search may examine up to n items, so its cost grows in direct proportion to the size of the list. That proportionality is the key to how we measure it, which we come to shortly.

Binary search: the power of a sorted list

Linear search treats the list as an unordered pile. But if the list is sorted, we can do dramatically better with binary search. The idea is the same one you use, perhaps unknowingly, when looking up a word in a physical dictionary: you do not start at page one and read every word; you open to the middle, see whether your word comes before or after, and instantly discard half the book, then repeat. Binary search checks the middle item of the sorted list. If it is the target, done. If the target is smaller, the target must be in the lower half, so we discard the upper half; if larger, we discard the lower half. Each step halves the remaining items.

def binary_search(items, target):
    low = 0
    high = len(items) - 1
    while low <= high:
        mid = (low + high) // 2       # the middle index
        if items[mid] == target:
            return mid                # found it
        elif items[mid] < target:
            low = mid + 1             # discard the lower half
        else:
            high = mid - 1            # discard the upper half
    return -1                         # not found

sorted_numbers = [4, 8, 15, 16, 23, 42]
print(binary_search(sorted_numbers, 23))   # 4
print(binary_search(sorted_numbers, 10))   # -1

Trace a search for 23 in the list. Initially low is 0 and high is 5, so mid is 2, and items[2] is 15. Since 15 is less than 23, the target must be higher, so we set low to 3. Now low is 3 and high is 5, so mid is 4, and items[4] is 23. Found, at index 4, in just two steps. The critical requirement, which cannot be overstated, is that binary search only works on a sorted list. The whole method depends on order to decide which half to discard; on an unsorted list it gives wrong answers. This is why sorting, our next topic, is so foundational: it enables fast searching.

How much faster is binary search? Because each step halves the remaining items, the number of steps grows very slowly. Searching a list of 1,000 items takes at most about 10 steps; a million items, about 20 steps; a billion items, about 30 steps. Compare that with linear search, which might check all million items. Doubling the data adds only one step to binary search but doubles the work for linear search. That difference in growth is enormous, and it is exactly what Big-O notation is designed to capture.

Big-O notation: measuring how cost grows

Big-O notation is a way of describing how the running time (or work) of an algorithm grows as the size of its input, usually called n, grows. It deliberately ignores constant factors and small details, focusing only on the shape of the growth, because that is what dominates on large inputs. Big-O answers the question "if I make the input much bigger, how much more work is that?" You write it as a capital O followed by an expression in parentheses. Here are the common growth rates, from best to worst, that a beginner should recognize.

NotationNameMeaningExample
O(1)constantThe work does not grow with n at all.Looking up a dictionary value by key; reading a list element by index.
O(log n)logarithmicAdding data barely increases the work.Binary search on a sorted list.
O(n)linearWork grows in direct proportion to n.Linear search; a single loop over a list.
O(n log n)linearithmicSlightly worse than linear; the best general sorting.Efficient sorts like the one Python uses.
O(n squared)quadraticWork grows with the square of n; slow on big inputs.Simple sorts; a loop inside a loop over the same data.

To build intuition, imagine each algorithm running on a list, and then imagine the list getting ten times bigger. An O(1) operation takes the same time regardless, unaffected. An O(n) operation takes ten times as long: ten times the data, ten times the work. An O(n squared) operation takes a hundred times as long, because ten squared is a hundred, which is why quadratic algorithms become painful quickly. An O(log n) operation takes only a tiny bit longer, perhaps one more step. This is why we care so much about the growth rate: it determines whether your program scales to large data or collapses under it.

A practical way to estimate an algorithm's Big-O from your own code is to count nested loops over the data. A single loop over n items is usually O(n). A loop nested inside another loop, both over the data, is usually O(n squared), because the inner body runs n times for each of the n outer iterations, giving n times n total. An algorithm that repeatedly halves its problem, like binary search, is O(log n). This counting heuristic is rough but genuinely useful for reasoning about the code you write.

Sorting: putting data in order

Sorting means arranging items into order, such as numbers from smallest to largest or names alphabetically. It is one of the most studied problems in computer science, both because ordered data is so useful (it enables binary search, makes data readable, and groups similar items) and because it beautifully illustrates the range from slow to fast algorithms. There are many sorting algorithms with colorful names, bubble sort, selection sort, insertion sort, merge sort, quicksort, and part of a computer science education is studying how they work and how fast they are.

The simple, intuitive sorts, like bubble sort and selection sort, work by repeatedly scanning the list and swapping or selecting items into place. They are easy to understand but are O(n squared), because they involve a loop inside a loop, comparing many pairs. On a small list this is fine, but on a list of a million items, quadratic behavior means roughly a trillion operations, which is unacceptably slow. The more sophisticated sorts, like merge sort, cleverly divide the problem in half repeatedly and achieve O(n log n), which is vastly faster on large inputs and is, in fact, the best possible for general-purpose comparison sorting.

Use the built-in sort in real code

Here is the most important practical advice of the week: in real programs, do not write your own sorting algorithm. Python provides an excellent built-in sort that uses a highly optimized O(n log n) algorithm (a refined method called Timsort), written and tested by experts. You get it through the sorted() function, which returns a new sorted list, or the list sort() method, which sorts in place. Studying how sorting algorithms work is valuable for understanding, but for getting real work done, the built-in is faster, correct, and far less error-prone than anything you would write by hand.

data = [5, 2, 9, 1, 7, 3]
print(sorted(data))            # [1, 2, 3, 5, 7, 9]  (new list)
print(data)                    # [5, 2, 9, 1, 7, 3]  (original unchanged)

data.sort()                    # sorts in place
print(data)                    # [1, 2, 3, 5, 7, 9]

names = ["Charlie", "Alice", "Bob"]
print(sorted(names))           # ['Alice', 'Bob', 'Charlie']  (alphabetical)

print(sorted(data, reverse=True))   # [9, 7, 5, 3, 2, 1]  (descending)

The sorted() function also accepts useful options, like reverse=True to sort from largest to smallest. Related built-ins min() and max() find the smallest and largest items in one call, and they run in O(n) time by scanning the list once, which is as good as possible for unsorted data.

data = [5, 2, 9, 1, 7]
print(min(data))     # 1
print(max(data))     # 9
print(sum(data))     # 24

Writing an algorithm by hand: finding the maximum

Although you should use built-ins in real code, writing simple algorithms yourself builds deep understanding, which is why this course asks you to find a maximum without max(). The idea is a single scan that keeps track of the largest value seen so far, an accumulator-style pattern. Start by assuming the first item is the largest, then walk through the rest, updating your record whenever you find something bigger.

def find_max(numbers):
    biggest = numbers[0]           # assume the first is largest to start
    for n in numbers:
        if n > biggest:
            biggest = n            # found a bigger one, update
    return biggest

print(find_max([3, 7, 2, 9, 4]))   # 9

Trace it: biggest starts at 3, the first item. Scanning, 7 is bigger so biggest becomes 7, 2 is not, 9 is bigger so biggest becomes 9, 4 is not, and we return 9. This algorithm looks at each item exactly once, so it is O(n), linear time, which is the best you can do for finding the maximum of an unordered list, since you cannot know the largest without looking at every item at least once. Writing this yourself makes the O(n) cost tangible: one loop, one pass, work proportional to the number of items.

Why efficiency thinking matters

You might handle only small lists today, where even a slow algorithm finishes instantly, so why care? Because data grows, and the gap between good and bad algorithms explodes with size. An O(n squared) approach that is fine on 100 items may take hours on 100,000. Choosing an O(n log n) sort or an O(log n) search instead can be the difference between a usable program and an unusable one. More broadly, learning to ask "how does this scale?" changes how you think about all the code you write. You do not need to optimize prematurely or memorize proofs; you need the habit of noticing when you have a nested loop over large data, or when sorting first would let you search faster. That awareness is a lasting professional skill.

Common mistakes and how to fix them

  • Running binary search on an unsorted list. Binary search requires sorted data and gives wrong answers otherwise. Sort first, or use linear search if the data is not sorted.
  • Writing your own sort in production code. Hand-written sorts are usually slower and buggier than the built-in. Use sorted() or list.sort() unless you are learning how sorting works.
  • Not recognizing hidden nested loops. Some operations, like checking x in my_list inside a loop over another list, are secretly O(n squared). Be alert to loops within loops over large data.
  • Assigning the result of sort. The sort() method returns None and sorts in place; sorted() returns a new list. Do not write data = data.sort(), which discards your list.
  • Calling max or min on an empty list. These raise an error on an empty list. Guard against the empty case first.
  • Confusing best case and worst case. Big-O usually describes the worst case. Linear search is O(n) even though it might get lucky and find the target first; plan for the worst.

Recap

This week you thought like a computer scientist, studying not just how to solve problems but how to solve them efficiently. You learned that an algorithm is a precise step-by-step procedure, and that most problems admit many algorithms of very different speeds. You met linear search, which checks items one by one and is O(n), and binary search, which repeatedly halves a sorted list and is O(log n), dramatically faster but dependent on order. You learned Big-O notation as a way to describe how an algorithm's cost grows with input size, from O(1) constant through O(log n), O(n), and O(n log n) up to O(n squared), and you built intuition for what happens when the input grows tenfold. You surveyed sorting, seeing that simple sorts are O(n squared) while the best are O(n log n), and you learned the practical rule to use Python's built-in sorted() and sort() rather than rolling your own. Finally you wrote a linear-time maximum-finder by hand to make the O(n) cost concrete. Above all, you gained the habit of asking how code scales. Next week you explore recursion, an elegant technique where a function solves a problem by calling itself.

Sources

  1. Wikipedia, "Binary search algorithm." en.wikipedia.org/wiki/Binary_search_algorithm
  2. Wikipedia, "Big O notation." en.wikipedia.org/wiki/Big_O_notation
  3. Python Software Foundation, "Sorting Techniques" (the official sorting how-to, covering sorted() and list.sort()). docs.python.org/3/howto/sorting.html
  4. Wikipedia, "Sorting algorithm" (overview of algorithms and their complexities, including Timsort used by Python). en.wikipedia.org/wiki/Sorting_algorithm
Key terms
Algorithm
A precise, ordered set of steps that solves a problem.
Linear search
Checking each item in turn until the target is found.
Binary search
Repeatedly halving a sorted list to find a target quickly.
Sorting
Arranging items into order, such as smallest to largest.
Big-O notation
A way to describe how an algorithm's cost grows with input size.
O(n)
Linear growth, where the work is proportional to the number of items.

Week 15 - Recursion

Functions that call themselves

  • Explain the base case and recursive case.
  • Trace a simple recursive function.
  • Recognize problems suited to recursion.

This week you meet one of the most elegant and mind-bending ideas in all of programming: recursion, where a function solves a problem by calling itself. At first this sounds impossible, even paradoxical, like a snake eating its own tail or two mirrors facing each other. How can a function be defined in terms of itself without spinning forever? Yet recursion is not only possible but powerful, and for certain problems it produces solutions of striking clarity and beauty. Recursion is how you naturally express problems that contain smaller copies of themselves, walking a tree of folders, exploring a maze, or computing mathematical sequences. This lesson demystifies recursion step by step, showing you the two ingredients every recursive function needs, how to trace one by hand, and when recursion is the right tool. It rewards patience, so if it does not click immediately, that is completely normal; recursion is famous for needing a second read.

What recursion is

Recursion is a technique in which a function calls itself to solve a smaller version of the same problem. The key phrase is "smaller version." Each time the function calls itself, it does so on a simpler or smaller input, making progress toward a case so simple that the answer is immediate and no further calling is needed. This works the way certain real-world instructions work. Imagine you are standing in a long line and want to know your position. You cannot see the front, so you ask the person ahead of you their position; they do not know either, so they ask the person ahead of them, and so on. Eventually the question reaches the very first person, who knows they are first and says so. That answer then passes back down the line, each person adding one, until it reaches you. That is recursion: a question passed to a smaller version of itself, with a simple stopping point and answers flowing back.

The two essential ingredients

Every correct recursive function has exactly two parts, and understanding both is the whole secret to recursion. The first is the base case: the simplest possible situation, which the function answers directly without calling itself again. The base case is what stops the recursion. The second is the recursive case: the part where the function calls itself on a smaller input, trusting that call to handle the smaller problem. Without a base case, a recursive function would call itself endlessly, never stopping, which is a bug called infinite recursion, the recursive cousin of the infinite loop. Without a recursive case, it would not be recursive at all. The base case guarantees the process ends; the recursive case does the reducing. Always identify both, and always make sure the recursive case moves toward the base case, or the recursion will never terminate.

A first example: counting down

Let us start with a simple, concrete example before the classic mathematical one. Suppose we want to print a countdown from some number to zero. Recursively, printing a countdown from n means: print n, then print a countdown from n minus 1. The base case is when n reaches zero (or below), where we simply announce liftoff and stop.

def countdown(n):
    if n <= 0:              # base case: stop here
        print("Liftoff!")
        return
    print(n)               # do a little work
    countdown(n - 1)       # recursive case: smaller problem

countdown(3)
# prints:
# 3
# 2
# 1
# Liftoff!

Trace it carefully. We call countdown(3). Since 3 is not zero or less, we print 3, then call countdown(2). That prints 2, then calls countdown(1), which prints 1, then calls countdown(0). Now the base case fires: n is 0, so we print "Liftoff!" and return, calling nothing further. The recursion unwinds, each call returning in turn, and the program ends. Notice how each call is on a smaller n, marching steadily toward the base case of 0, which is what guarantees the countdown stops rather than running forever.

The classic example: factorial

The most famous introduction to recursion is the factorial function, because its very mathematical definition is recursive. The factorial of a whole number n, written n! and read "n factorial," is the product of all whole numbers from 1 up to n. So 5! is 5 times 4 times 3 times 2 times 1, which is 120. The recursive insight is that n! equals n times (n minus 1)!. In words, the factorial of n is n multiplied by the factorial of the next smaller number. And 0! is defined to be 1, which gives us our base case. This translates directly into code.

def factorial(n):
    if n == 0:                    # base case
        return 1
    return n * factorial(n - 1)   # recursive case

print(factorial(5))     # 120
print(factorial(3))     # 6
print(factorial(0))     # 1

The structure mirrors the definition exactly: if n is 0, return 1 (the base case); otherwise return n times the factorial of n minus 1 (the recursive case). This close correspondence between the mathematical definition and the code is one reason recursion is so admired. Let us trace factorial(3) in detail to see the machinery.

factorial(3)
  = 3 * factorial(2)
  = 3 * (2 * factorial(1))
  = 3 * (2 * (1 * factorial(0)))
  = 3 * (2 * (1 * 1))          # factorial(0) hit the base case, returned 1
  = 3 * (2 * 1)
  = 3 * 2
  = 6

Read this top to bottom, then bottom to top. Going down, each call spawns a smaller call: factorial(3) waits on factorial(2), which waits on factorial(1), which waits on factorial(0). When factorial(0) hits the base case and returns 1, the waiting calls can finally finish, multiplying their way back up: 1 times 1 is 1, times 2 is 2, times 3 is 6. The answer emerges from this cascade down to the base case and back up. Every recursive computation has this two-phase shape: winding down to the base case, then unwinding back with the results.

The call stack: how Python keeps track

You may wonder how Python remembers all those paused, waiting calls. The answer is a structure called the call stack. Every time a function is called, Python pushes a new frame onto the stack, holding that call's local variables and its place in the code. When a function returns, its frame is popped off. During recursion, the stack grows as calls wind down toward the base case, each paused call stacked on top of the previous, and then shrinks as the base case returns and calls unwind. For factorial(3), at the deepest point there are frames for factorial(3), factorial(2), factorial(1), and factorial(0) all on the stack at once. Understanding the call stack demystifies recursion: there is no magic, just Python diligently stacking and unstacking paused function calls.

The call stack also explains a real limit: it has finite space. If recursion goes too deep, thousands of calls without reaching a base case, the stack fills up and Python raises a RecursionError, reporting that the maximum recursion depth was exceeded. This almost always means your base case is missing or unreachable, so the recursion never stops. It is the recursive equivalent of an infinite loop, and the fix is the same idea: make sure the base case is reachable and the recursive case always moves toward it.

# BROKEN: infinite recursion, do not run
def bad_countdown(n):
    print(n)
    bad_countdown(n - 1)    # no base case! never stops
# This would print forever until a RecursionError crashes it

Another example: summing to n

Let us write another recursive function to reinforce the pattern, computing the sum of all integers from 1 to n. Recursively, the sum from 1 to n is n plus the sum from 1 to n minus 1, and the base case is that the sum up to 0 (or 1) is trivial. This is the exact shape the assignment asks you to build.

def sum_to(n):
    if n <= 0:                # base case
        return 0
    return n + sum_to(n - 1)  # recursive case

print(sum_to(5))     # 15  (5 + 4 + 3 + 2 + 1)
print(sum_to(100))   # 5050

The logic parallels factorial exactly, but with addition instead of multiplication and a base value of 0 instead of 1. To compute sum_to(5), Python works down to sum_to(0), which returns 0, then adds back up: 0, then 1, then 3, then 6, then 10, then 15. Once you see this shared shape, base case for the smallest input, recursive case that combines n with the result of the smaller call, you can write many recursive functions by following the template.

Recursion versus iteration

An honest and important point: anything you can do with recursion, you can also do with a loop, and vice versa. The factorial and sum examples above could easily be written with a for loop and an accumulator, as you learned weeks ago. In fact, for these simple cases, the loop version is often more efficient, because it avoids the overhead of many function calls and the risk of a deep call stack. So why learn recursion at all? Because for some problems, recursion is dramatically clearer and more natural than iteration. Problems that are inherently self-similar, where a big problem is made of smaller versions of the same problem, are where recursion shines: navigating nested folders within folders, exploring branching structures like family trees or decision trees, certain elegant sorting algorithms like merge sort, and mathematical definitions that are themselves recursive. For those, a recursive solution can be a few readable lines where a loop-based one would be a tangle. The skilled programmer knows both and chooses the clearer tool for each problem.

# The same factorial written iteratively, for comparison
def factorial_loop(n):
    result = 1
    for i in range(1, n + 1):
        result = result * i
    return result

print(factorial_loop(5))    # 120, same answer

How to think about writing a recursive function

When designing a recursive function, a helpful mindset is to trust the recursion. Do not try to trace every level in your head; that way lies confusion. Instead, ask three questions. First, what is the simplest input, the base case, and what is its answer? Second, assuming the function already works correctly for a smaller input, how do I use that smaller answer to solve the current one? Third, does my recursive call definitely move toward the base case? If you can answer these, the function will work, even though it feels like you are assuming the very thing you are defining. This "leap of faith," trusting that the smaller call returns the right answer, is the mental key to writing recursion. Verify the base case is reachable, get the combination step right, and let the recursion handle the rest.

A word on efficiency and a caution

Recursion is elegant but not free. Each call uses stack space and has a small time cost, and naive recursion can be wildly inefficient for some problems. The classic cautionary example is computing Fibonacci numbers by the obvious double recursion, which recomputes the same values an exponential number of times and becomes unusably slow for even modest inputs. This is not a flaw in recursion itself but a reminder that how you structure a recursive solution matters, and that some recursive formulations need techniques you will learn later to make them practical. For this course, the takeaway is balanced: recursion is a beautiful and essential idea to understand, ideal for naturally self-similar problems, but for simple counting and summing, a loop is often the better everyday choice. Learn recursion for the problems that truly call for it, and for the way it stretches how you think.

Common mistakes and how to fix them

  • Missing or unreachable base case. Without a base case that the recursion actually reaches, the function calls forever and crashes with a RecursionError. Always write the base case first and confirm the recursive case moves toward it.
  • Recursive call not getting smaller. If you call the function with the same or a larger input, it never approaches the base case. Ensure each recursive call shrinks the problem, such as passing n minus 1.
  • Forgetting to return the recursive call. In a function that computes a value, you usually need return n * factorial(n - 1), not just n * factorial(n - 1) on its own. Omitting return gives None.
  • Expecting recursion to be faster than a loop. For simple problems, recursion is usually a bit slower and uses more memory than an equivalent loop. Choose recursion for clarity on self-similar problems, not for speed on simple ones.
  • Going too deep. Python limits recursion depth (around a thousand calls by default). Deeply recursive tasks on large inputs may hit this limit; an iterative approach avoids it.
  • Trying to trace every level mentally. Instead, trust that the smaller call works, and focus on the base case and the single combination step. Over-tracing leads to confusion.

Recap

This week you explored recursion, the technique where a function calls itself on a smaller version of the same problem. You learned that every correct recursive function needs two ingredients: a base case that returns an answer directly and stops the recursion, and a recursive case that calls the function on a smaller input and combines the result. You traced a countdown and the classic factorial by hand, watching the computation wind down to the base case and unwind back with the answer, and you learned that Python manages this with the call stack, pushing a frame for each paused call and popping it on return. You saw that a missing or unreachable base case causes infinite recursion and a RecursionError, the recursive analog of an infinite loop. You compared recursion with iteration, learning that either can solve the same problems but recursion is clearest for naturally self-similar ones, and you adopted the mindset of trusting the recursion: nail the base case, get the combination step right, and ensure progress toward the base. Next week is the capstone, where you bring the entire course together to build a complete program.

Sources

  1. Wikipedia, "Recursion (computer science)." en.wikipedia.org/wiki/Recursion_(computer_science)
  2. Python Software Foundation, "The Python Tutorial," Section 4.7 and 4.8: Defining Functions (functions, including recursive calls). docs.python.org/3/tutorial/controlflow.html#defining-functions
  3. Al Sweigart, "Automate the Boring Stuff with Python" and its author's companion book "The Recursive Book of Recursion" for extended treatment. Free materials at automatetheboringstuff.com and inventwithpython.com
  4. Python Software Foundation, "sys.setrecursionlimit" and recursion depth notes in the standard library reference. docs.python.org/3/library/sys.html#sys.setrecursionlimit
Key terms
Recursion
A technique where a function calls itself to solve a smaller subproblem.
Base case
The simplest case that stops the recursion without calling again.
Recursive case
The part of the function that calls itself on a smaller input.
Call stack
The record of active function calls waiting to finish.
Factorial
The product of all whole numbers from 1 up to n, a classic recursion example.
Infinite recursion
Recursion with no reachable base case, which eventually crashes.

Week 16 - Capstone Project & Where to Go Next

Build the project and keep learning

  • Combine the course skills into one program.
  • Structure a small application with functions and a class.
  • Plan your next steps in programming.

Congratulations. In fifteen weeks you have traveled an extraordinary distance, from your very first print("Hello, world!") all the way through variables, conditionals, loops, functions, lists, dictionaries, files, exceptions, classes, algorithms, and recursion. That is a genuinely complete foundation in programming, the same set of ideas that professional developers use every day. This final week is about synthesis: bringing all of those separate skills together to build one real, working program, the Contact Book described in your final project. Building a complete application is different from doing individual exercises, because you must make the pieces cooperate, structure your code well, and handle the messy realities of real use. We will walk through how to design and build such a program step by step, and then we will talk about where to go from here, because finishing this course is not an ending but a launch.

How to approach a whole project

The most important lesson about building larger programs is this: do not try to write the whole thing at once. Beginners often type out a hundred lines, run it, get a wall of errors, and feel overwhelmed. Professionals work in small, tested increments. You build one small feature, run it, confirm it works, and only then add the next. This approach, sometimes called incremental development, keeps you always close to a working program, so when something breaks you know it was the small thing you just added, not a mystery buried in a hundred untested lines. Start with the simplest possible version that runs, then grow it. This single habit will do more for your success as a programmer than any clever technique.

It also helps to plan before you code. For the Contact Book, list what it must do: add a contact, list all contacts, search by name, delete a contact, and save to a file so contacts survive between runs, loading them again on startup. Each of those becomes a function. Thinking in terms of "what are the pieces, and what does each piece do" is the essence of good design, and functions are how you turn that plan into organized code.

The menu loop: the shape of a command-line program

Most interactive command-line tools share a common structure: a menu loop that repeatedly shows the user their options, reads their choice, and calls the right function to handle it, continuing until the user chooses to quit. This is just a while loop wrapped around an if-elif-else chain, both of which you know well. Here is the skeleton, which we will flesh out.

def show_menu():
    print()
    print("Contact Book")
    print("1) Add a contact")
    print("2) List all contacts")
    print("3) Search by name")
    print("4) Delete a contact")
    print("5) Quit")

def main():
    contacts = []
    while True:
        show_menu()
        choice = input("Choose an option: ")
        if choice == "1":
            print("(add not built yet)")
        elif choice == "2":
            print("(list not built yet)")
        elif choice == "3":
            print("(search not built yet)")
        elif choice == "4":
            print("(delete not built yet)")
        elif choice == "5":
            print("Goodbye!")
            break
        else:
            print("Unknown option, please choose 1 to 5.")

main()

Notice the strategy: the menu loop works completely before any feature is real. Each option just prints a placeholder for now, but you can run this, navigate the menu, and confirm the structure is sound, including that quitting with option 5 breaks the loop and that an unknown choice is handled gracefully. Getting this skeleton solid first gives you a reliable frame to hang the real features on, one at a time. The contacts list, which will hold all our contacts, is created once at the start of main so it persists across every iteration of the loop.

Representing a contact with a dictionary

Each contact has several pieces of information, a name, a phone number, and an email, which naturally group together as a record. As you learned in the dictionaries week, a dictionary is the ideal way to hold a record with named fields. So each contact will be a dictionary, and all the contacts together will be a list of dictionaries, that important data shape you met earlier. Let us build the add feature, which creates a contact dictionary and appends it to the list.

def add_contact(contacts):
    name = input("Name: ")
    phone = input("Phone: ")
    email = input("Email: ")
    contact = {"name": name, "phone": phone, "email": email}
    contacts.append(contact)
    print("Added", name)

This function takes the contacts list as a parameter, gathers the three fields from the user, packages them into a dictionary, and appends that dictionary to the list. Because a list is mutable and passed by reference, appending inside the function actually modifies the caller's list, exactly what we want. Now the listing feature simply loops over the list of dictionaries and prints each record's fields.

def list_contacts(contacts):
    if len(contacts) == 0:
        print("No contacts yet.")
        return
    print("You have", len(contacts), "contacts:")
    for contact in contacts:
        print("-", contact["name"], "|", contact["phone"], "|", contact["email"])

Here we guard against the empty case first, returning early with a friendly message if there are no contacts, then loop over the list, using dictionary key access to read each contact's fields. This is the list-of-dictionaries pattern in action, and it is the beating heart of the whole program.

Searching and deleting

Searching means looking through the contacts for ones whose name matches what the user typed. This is a linear search, which you studied in the algorithms week, combined with the case-insensitive comparison technique from the strings week so that searching for "ada" finds "Ada". Deleting is similar: find the matching contact, then remove it from the list.

def search_contacts(contacts):
    query = input("Search for which name? ").lower()
    found = False
    for contact in contacts:
        if query in contact["name"].lower():
            print("Found:", contact["name"], "|", contact["phone"], "|", contact["email"])
            found = True
    if not found:
        print("No matching contacts.")

def delete_contact(contacts):
    target = input("Delete which name? ").lower()
    for contact in contacts:
        if contact["name"].lower() == target:
            contacts.remove(contact)
            print("Deleted", contact["name"])
            return
    print("No contact with that name.")

The search function lowercases both the query and each contact's name so capitalization does not matter, and it uses the in operator to match partial names, so "ad" would find "Ada". It tracks whether anything was found with a boolean flag, reporting failure if nothing matched. The delete function finds the first exact name match, removes that dictionary from the list with the list remove() method, and returns. Note that we return right after removing, both because our job is done and because modifying a list while looping over it can misbehave, so we stop as soon as we make the change.

Saving to a file and loading on startup

The feature that makes the Contact Book genuinely useful is persistence: saving contacts to a file so they survive after the program closes, and loading them back when it starts. This draws directly on the files week. We will save each contact as one line of comma-separated values, name, phone, and email joined by commas, and load them back by reading each line and splitting on the comma. This is a simple but real data format.

def save_contacts(contacts, filename):
    with open(filename, "w") as f:
        for contact in contacts:
            line = contact["name"] + "," + contact["phone"] + "," + contact["email"]
            f.write(line + "\n")
    print("Saved", len(contacts), "contacts.")

def load_contacts(filename):
    contacts = []
    try:
        with open(filename, "r") as f:
            for line in f:
                parts = line.strip().split(",")
                if len(parts) == 3:
                    contact = {"name": parts[0], "phone": parts[1], "email": parts[2]}
                    contacts.append(contact)
    except FileNotFoundError:
        print("No saved contacts found. Starting fresh.")
    return contacts

Study how much of the course lives in these two functions. Saving uses the with statement to open the file safely, loops over the contacts, builds a comma-separated line from each dictionary's fields, and writes it with a trailing newline. Loading is where exception handling earns its place: on the very first run there is no file yet, so open would raise a FileNotFoundError and crash, but the try and except catch it and start with an empty list instead. When the file does exist, we read each line, strip its newline, split it on commas into parts, and, checking that we got exactly three fields for safety, rebuild the contact dictionary. This is the split-and-rebuild pattern from text processing, applied to real saved data.

Putting it all together

Now we assemble the full program. The main function loads existing contacts on startup, runs the menu loop wiring each option to its function, and saves before quitting so no data is lost. This complete program weaves together nearly every concept from the entire course.

FILENAME = "contacts.txt"

def main():
    contacts = load_contacts(FILENAME)   # load saved data on startup
    while True:
        show_menu()
        choice = input("Choose an option: ")
        if choice == "1":
            add_contact(contacts)
        elif choice == "2":
            list_contacts(contacts)
        elif choice == "3":
            search_contacts(contacts)
        elif choice == "4":
            delete_contact(contacts)
        elif choice == "5":
            save_contacts(contacts, FILENAME)   # save before leaving
            print("Goodbye!")
            break
        else:
            print("Unknown option, please choose 1 to 5.")

main()

Look at how the whole thing fits. On startup, load_contacts reads any saved file so the user picks up where they left off. The menu loop dispatches each choice to a focused function that does one job. On quit, save_contacts writes everything back to disk so nothing is lost. Each function is small and testable, the data is a clean list of dictionaries, files give persistence, and exception handling keeps the first run from crashing. This is what a real program looks like: not one giant blob, but a set of cooperating functions with clear responsibilities. If you built it incrementally, one feature at a time as recommended, each addition was a small, confident step.

Adding a class, and validating input

The project brief asks for at least one class, so let us see how a Contact class could replace the dictionary, applying the object-oriented ideas from week thirteen. A class bundles the contact's data with behavior, such as knowing how to format itself for saving or display. This is optional polish, but it shows how OOP fits into a real program.

class Contact:
    def __init__(self, name, phone, email):
        self.name = name
        self.phone = phone
        self.email = email

    def to_line(self):
        return self.name + "," + self.phone + "," + self.email

    def __str__(self):
        return self.name + " | " + self.phone + " | " + self.email

c = Contact("Ada", "555-0100", "ada@example.com")
print(c)                # Ada | 555-0100 | ada@example.com
print(c.to_line())      # Ada,555-0100,ada@example.com

With a Contact class, each contact is an object that carries its own data and knows how to turn itself into a savable line and a printable string, moving that logic out of the free-standing functions and into the object itself. You would then store a list of Contact objects instead of dictionaries. Either design is valid; the dictionary version is simpler to start with, and the class version is a nice step up that demonstrates OOP. You should also validate input where it matters, for example refusing an empty name or looping until an email contains an "@", using the input-validation loops with try and except that you learned. Good programs are defensive about the data they accept.

Debugging and refining your program

As you build, things will go wrong, and learning to debug calmly is part of the craft. When you hit an error, read the message from the bottom up: the last line names the error type and the line number where it happened. Add print() statements to see the actual values of your variables at key points, which reveals where reality diverges from your expectation. Check the usual suspects you have met all course: forgetting to convert input from a string, an off-by-one index, a missing self, comparing with a single equals instead of a double, or stray whitespace breaking a match. Once your program works, you can refactor it, improving its structure without changing its behavior, such as splitting a long function or renaming variables for clarity. Working code that you then make cleaner is exactly how real software matures.

Where to go next

You now have the foundation to learn almost anything in programming, and the field is vast and welcoming. Here are some rewarding directions. If data interests you, explore the world of data analysis with libraries like pandas and visualization tools, a hugely popular use of Python. If you want to build websites, learn a web framework such as Flask (lightweight and beginner-friendly) or Django (full-featured). If you enjoy making your computer do your chores, dive deeper into automation, which the "Automate the Boring Stuff" book is entirely devoted to. If games appeal to you, a library like pygame lets you build them. And whatever you choose, learn version control with Git, the tool professionals use to track changes to their code and collaborate, because it will serve you in every project from here on.

Beyond any specific topic, the most important advice is simple: keep building. Programs you write yourself, however small, teach you more than any amount of passive reading. Pick projects you actually want to exist, a tool for a hobby, a script that saves you time, a small game, and build them, looking things up as you go. Read other people's code to see how experienced programmers solve problems. Get comfortable reading official documentation, which you practiced in the modules week, because it is how you will teach yourself everything new. Programming is a field where nobody ever stops learning, and that is the joy of it. You started this course unable to write a line of code, and you can now build a complete, file-backed, object-oriented application. That is a real achievement. Be proud of it, and keep going.

Recap

This final week was about synthesis: combining the whole course into one working program. You learned to approach a project incrementally, building and testing one small feature at a time rather than writing everything at once, and to plan by listing the features and turning each into a function. You built a complete Contact Book around a menu loop, representing each contact as a dictionary and all contacts as a list of dictionaries, with functions to add, list, search, and delete. You gave it persistence by saving contacts to a file and loading them on startup, using the with statement and guarding the first run with try and except for a missing file. You saw how a Contact class could apply object-oriented design, and how input validation and calm debugging make a program robust. Most importantly, you saw that a real program is a set of small, cooperating functions with clear jobs, not one giant block. You have completed a full introduction to programming with Python, from your first print to a finished application. The path forward is to keep building projects you care about, keep reading code and documentation, and keep learning, because you now have everything you need to continue on your own.

Sources

  1. Al Sweigart, "Automate the Boring Stuff with Python," 2nd edition, especially the practice projects that reinforce building complete programs. Free to read at automatetheboringstuff.com/2e/
  2. Charles Severance, "Python for Everybody," later chapters on data and real-world programs. Free full text at py4e.com/book
  3. Python Software Foundation, "The Python Tutorial" (a complete reference to revisit as you build). docs.python.org/3/tutorial/index.html
  4. Python Software Foundation, "Python Setup and Usage" and the official "What's New in Python" notes for staying current. docs.python.org/3/using/index.html
Key terms
Capstone project
A culminating program that integrates the skills from the whole course.
Menu loop
A loop that repeatedly shows choices and dispatches to the right code.
Refactoring
Improving the structure of working code without changing what it does.
Debugging
Finding and fixing errors in a program.
Persistence
Saving data to a file or database so it survives between runs.
Version control
Tools like Git that track changes to your code over time.

Open the interactive version with quizzes and progress →