Chapter 8: Tips and Tricks
Once your code works, you still have to read it, test it, and fix it. This chapter covers the habits and tools that make that easier, along with the mistakes almost every beginner makes at least once.
In this chapter you will learn:
- How to follow the PEP 8 style guide and let tools format and check your code
- How to write useful comments, docstrings, and type hints
- How to test your code with
unittestand pytest - How to set up a virtual environment and install packages
- How to track down bugs with
print(), tracebacks, the debugger,assert, and logging - Which common pitfalls to watch out for, and how to avoid them
Best practices for writing Python code
These practices make your code easier to read and maintain, for other people and for yourself a few months from now.
Use a good editor and version control
Use a code editor or IDE that has syntax highlighting, code completion, and a built-in debugger, such as VS Code or PyCharm. A good editor makes code easier to write and catches many mistakes as you type.
Use version control software such as Git to track changes to your code. Git records every version of your files, so you can see what changed, undo a change that broke something, and collaborate with other developers.
Follow the PEP 8 style guide
PEP 8 is the official style guide for Python code. You can read it at peps.python.org/pep-0008. Following it makes your code consistent with most other Python code, so it is easier for anyone to read. The next few sections cover its most important points.
Indent with 4 spaces
Use 4 spaces for each level of indentation, and don’t use tabs. Tabs can be displayed at different widths in different editors, and mixing tabs and spaces leads to confusing errors. Most code editors can insert 4 spaces when you press the Tab key; check your editor’s settings.
def describe_numbers(limit):
for i in range(limit): # 4 spaces
if i % 2 == 0: # 8 spaces
print(i, 'is even') # 12 spaces
else:
print(i, 'is odd')
describe_numbers(3)
# Output:
# 0 is even
# 1 is odd
# 2 is even
Keep lines short
PEP 8 recommends a maximum line length of 79 characters. Long lines are hard to read, especially when you have two files side by side or are working on a small screen. Many teams use a slightly longer limit, such as 88 characters (the default of the Black and Ruff formatters) or 100. Whichever you choose, use the same limit across the whole project.
The preferred way to break a long line is to put the line break inside parentheses, brackets, or braces. Python knows the statement isn’t finished until the closing bracket, so you can continue on the next line:
def total_price(price, quantity, tax_rate, discount):
return price * quantity * (1 + tax_rate) - discount
# One argument per line, inside the parentheses of the call
total = total_price(
price=19.99,
quantity=3,
tax_rate=0.08,
discount=5,
)
print(f'{total:.2f}') # Output: 59.77
To split a long string, put several shorter strings next to each other inside parentheses. Python joins string literals that sit next to each other into one string:
message = (
'Python joins string literals that sit next to each other, '
'so you can split a long string over several lines.'
)
print(message)
# Output:
# Python joins string literals that sit next to each other, so you can split a long string over several lines.
You can also end a line with a backslash (\) to continue it on the next line, but PEP 8 prefers parentheses. A backslash is easy to miss, and inside a string it doesn’t do what you might expect: the indentation of the next line becomes part of the string.
# A backslash at the end of a line continues the statement...
total = 1 + 2 + \
3
print(total) # Output: 6
# ...but inside a string, the spaces at the start of the next line
# end up in the string
text = 'first part \
second part'
print(text) # Output: first part second part
Pick one quote style and stick to it
In Python, single quotes and double quotes create exactly the same kind of string. PEP 8 doesn’t prefer either one; it only asks you to pick one style and use it consistently. (The examples in this book use both, so you can see they behave the same. In your own projects, pick one: the Black and Ruff formatters use double quotes.)
When a string contains a quote character, use the other kind of quote around it, so you don’t need a backslash:
# Single and double quotes make the same string
print('Hello, world!' == "Hello, world!") # Output: True
# Use the other kind of quote when the string contains a quote
print('He said, "Hello!"') # Output: He said, "Hello!"
print("It's a nice day") # Output: It's a nice day
If you need to use the same kind of quote that surrounds the string, put a backslash (\) in front of it. This is called escaping the quote:
print('It\'s a nice day') # Output: It's a nice day
print("He said, \"Hello!\"") # Output: He said, "Hello!"
Triple-quoted strings, written with three single quotes or three double quotes, can span multiple lines and contain both kinds of quotes without escaping:
text = """This string spans
several lines and can contain 'single'
and "double" quotes without escaping."""
print(text)
# Output:
# This string spans
# several lines and can contain 'single'
# and "double" quotes without escaping.
Choose clear names
Use clear and descriptive names for variables, functions, and classes. A good name tells the reader what a value means or what a function or class is for, so the code explains itself. Avoid abbreviations unless they are widely known, and keep names as short as you can while still being descriptive.
PEP 8 also says how to write names:
- Variable and function names use snake_case: lowercase words separated by underscores (for example
my_variable,calculate_total). - Class names use CapWords, also called PascalCase: each word starts with a capital letter and there are no underscores (for example
MyClass,StudentRecord). This is different from camelCase, which starts with a lowercase letter (myClass). PEP 8 allows camelCase only in code that already uses it, such as older standard library modules likelogging(logging.getLogger), so don’t use it in new code. - Constants, values that never change while the program runs, are written in UPPER_CASE with underscores (for example
MAX_SCORE).
Compare these two functions. They do the same thing, but only the second tells you what it is for:
# Hard to understand: what are f, a, b, and x?
def f(a):
b = 0
for x in a:
b += x
return b / len(a)
# Clear and descriptive names
MAX_SCORE = 100
def calculate_average_score(scores):
total_score = 0
for score in scores:
total_score += score
return total_score / len(scores)
class StudentRecord:
def __init__(self, name, scores):
self.name = name
self.scores = scores
student = StudentRecord('Asha', [80, 92, 74])
print(calculate_average_score(student.scores)) # Output: 82.0
print(f(student.scores)) # Output: 82.0
Write useful comments and docstrings
Use comments to explain why your code does something, especially when that isn’t obvious from the code itself. A comment that only repeats what the code says adds nothing, and it can become wrong if someone later changes the code but not the comment.
rows = ['name,score', 'Asha,80', 'Ben,92']
# Not useful: the comment repeats the code
data_rows = len(rows) - 1 # Subtract 1 from the length of rows
# Useful: the comment explains why
data_rows = len(rows) - 1 # The first row is a header, not data
print(data_rows) # Output: 2
To describe what a function, class, or module does, write a docstring: a string on the first line of its body. Unlike a comment, a docstring is kept by Python, and tools such as help() and your editor show it to anyone who uses the function.
def shipping_cost(weight_kg):
"""Return the cost in dollars of shipping a parcel.
The courier charges $2.50 per kilogram, with a minimum of $5.
"""
return max(weight_kg * 2.5, 5.0)
print(shipping_cost(4)) # Output: 10.0
print(shipping_cost(1)) # Output: 5.0
Some general guidelines for comments:
- Explain the purpose of a function or class in its docstring, and the logic of complex or non-obvious code in comments.
- Don’t repeat what the code already says (for example, “Increment x by 1” next to
x += 1). - Keep comments clear, short, and up to date.
- Don’t leave old code commented out “just in case”. Delete it. Your version control system keeps every earlier version, so you can always get it back.
Let tools format and check your code
You don’t have to apply style rules by hand. A formatter rewrites your code in a consistent style, and a linter points out likely mistakes, such as unused imports or variables that are used before they are defined. Two popular choices are:
- Ruff, which is both a fast linter and a formatter
- Black, a formatter
Install Ruff with pip (see Virtual environments and packages below), then run it from the folder that holds your code:
python -m pip install ruff
ruff check . # Look for problems in every Python file in this folder
ruff format . # Reformat every Python file in this folder
Most code editors can run a formatter every time you save a file, so you never have to think about it.
Add type hints
Type hints say what types of values a function expects and what it returns. They are written after a colon for each parameter, and after -> for the return value:
def area(width: float, height: float) -> float:
return width * height
print(area(3.0, 4.5)) # Output: 13.5
# Python doesn't check type hints when the program runs
print(area('ab', 3)) # Output: ababab
Type hints are documentation that tools can read. Python itself ignores them when it runs your code, as the second call shows. Your editor uses them to suggest completions and flag mistakes, and a type checker such as mypy checks the whole program before you run it:
python -m pip install mypy
mypy shapes.py
For the code above, saved as shapes.py, mypy reports an error for the second call: Argument 1 to "area" has incompatible type "str"; expected "float".
Handle errors with exceptions
Use try and except to handle errors that you expect could happen, such as a missing file or invalid input, instead of letting your program crash. The else block runs only if no exception was raised, and the finally block always runs:
x = 10
y = 0
try:
result = x / y
except ZeroDivisionError:
print('Error: Cannot divide by zero')
else:
print(result)
finally:
print('Finished')
# Output:
# Error: Cannot divide by zero
# Finished
Catch only the exceptions you expect and know how to handle, like ZeroDivisionError here. A bare except: catches every error, including bugs you would want to know about. Chapter 4 covers exception handling in more detail.
Test your code
Automated tests are small pieces of code that check that your code gives the right results. Run them after every change, and they tell you right away if the change broke something that used to work (this is called a regression).
A test calls your code with some input and checks that the result matches the output you expect. Python comes with the unittest module for writing tests:
import unittest
def add(x, y):
return x + y
class TestAdd(unittest.TestCase):
def test_positive_numbers(self):
self.assertEqual(add(2, 3), 5)
def test_negative_numbers(self):
self.assertEqual(add(-1, -1), -2)
if __name__ == '__main__':
unittest.main()
Each method whose name starts with test is one test. When you run the file, unittest runs every test and prints a dot for each one that passes, then a summary (the time will differ):
..
----------------------------------------------------------------------
Ran 2 tests in 0.000s
OK
If a test fails, unittest prints an F instead of a dot, followed by the name of the failing test and both values, for example AssertionError: -1 != 5.
Many Python projects use pytest instead, a third-party package that lets you write tests as plain functions with assert:
# test_calculator.py
def add(x, y):
return x + y
def test_add_positive_numbers():
assert add(2, 3) == 5
def test_add_negative_numbers():
assert add(-1, -1) == -2
Install pytest, then run it from the folder that holds your tests. It finds every file whose name starts with test_ and runs every function in it whose name starts with test_:
python -m pip install pytest
pytest
pytest prints a dot for each passing test and ends with a summary such as 2 passed in 0.01s.
Virtual environments and packages
Python packages that aren’t in the standard library, such as requests or pandas, are installed with pip. By default, pip installs packages into the Python installation you run it with, so every project on your computer shares the same packages. That causes problems when two projects need different versions of the same package.
A virtual environment solves this. It is a folder inside your project with its own python command and its own packages. The python command runs the Python installation you created the environment with (on macOS and Linux it is a link to it), so the folder doesn’t hold a full copy of Python. Each project gets its own environment, and you can delete the folder and start over at any time without affecting anything else.
Create a virtual environment
Open a terminal in your project folder and create a virtual environment in a folder named .venv. On macOS and Linux:
python3 -m venv .venv
On Windows:
py -m venv .venv
Activate it
Before you use the environment, activate it. On macOS and Linux:
source .venv/bin/activate
On Windows (Command Prompt or PowerShell):
.venv\Scripts\activate
If PowerShell says that running scripts is disabled on your system, use Command Prompt instead.
Your prompt now starts with (.venv). While the environment is active, the python command runs the environment’s Python on every operating system, and the packages you install go into the .venv folder.
Install packages
Install packages with pip:
python -m pip install requests
Running pip as python -m pip makes sure it installs into the Python you are currently using.
Save your package list
To record the packages your project uses, save them in a file named requirements.txt:
python -m pip freeze > requirements.txt
Anyone who wants to run your project, including you on another computer, can then create a fresh environment and install the same versions:
python -m pip install -r requirements.txt
Don’t put the .venv folder itself in version control; add it to your .gitignore file and commit requirements.txt instead.
Deactivate it
When you are done working on the project, leave the environment:
deactivate
Debugging techniques
Debugging means finding and fixing errors in your code. These are some common techniques.
Print statements
One of the simplest and most effective techniques is to add print() calls that show the values of variables at different points in your code. They show you what the code is really doing and where it starts to differ from what you expected. An f-string with = after the expression prints both the expression and its value:
def average(numbers):
total = sum(numbers)
print(f'{total=}, {len(numbers)=}') # Temporary debugging line
return total / len(numbers)
print(average([4, 8, 6]))
# Output:
# total=18, len(numbers)=3
# 6.0
Remember to remove debugging prints when you have found the problem.
Read the traceback
When an exception isn’t handled, Python stops and prints a traceback. Suppose this program is saved as shop.py:
def get_price(prices, item):
return prices[item]
prices = {'apple': 0.5, 'pear': 0.75}
print(get_price(prices, 'banana'))
Running it prints:
Traceback (most recent call last):
File "/Users/you/python-practice/shop.py", line 5, in <module>
print(get_price(prices, 'banana'))
~~~~~~~~~^^^^^^^^^^^^^^^^^^
File "/Users/you/python-practice/shop.py", line 2, in get_price
return prices[item]
~~~~~~^^^^^^
KeyError: 'banana'
Read it from the bottom up. The last line names the exception and gives a message: there is no key 'banana' in the dictionary. The lines above show the chain of calls that led there, ending with the line where the error happened: line 2, inside get_price().
Use a debugger
A debugger lets you pause your program and look around: run it one line at a time and inspect the values of variables at each step. Most code editors have a graphical debugger, where you click next to a line number to set a breakpoint, the place where the program pauses.
Python also has a built-in debugger called pdb. Add a call to breakpoint() where you want the program to pause:
def average(numbers):
total = sum(numbers)
breakpoint() # The program pauses here and starts the debugger
return total / len(numbers)
print(average([4, 8, 6]))
When the program reaches breakpoint(), it shows a (Pdb) prompt where you can type commands. The most useful ones are:
n Run the next line (without stepping into function calls)
s Step into the function called on the current line
p total Print the value of a variable or expression
c Continue running until the next breakpoint
q Quit the debugger and stop the program
Remove the breakpoint() call when you are done.
Check assumptions with assert
The assert statement checks that a condition is true. If it is, the program continues. If it isn’t, Python raises an AssertionError with the message you give. This makes a wrong value stop the program right where the problem starts, instead of causing a confusing error later on.
def apply_discount(price, percent):
assert 0 <= percent <= 100, f'percent must be 0-100, got {percent}'
return price * (1 - percent / 100)
print(apply_discount(50, 10)) # Output: 45.0
try:
apply_discount(50, 150)
except AssertionError as error:
print(f'AssertionError: {error}')
# Output: AssertionError: percent must be 0-100, got 150
Use assert to catch mistakes in your own code, not to check user input. Python skips assert statements when it is run with the -O (optimize) option, so they can’t be relied on to protect your program.
Log instead of print
The logging module is a more flexible alternative to debugging prints. Each message has a level, such as DEBUG, INFO, WARNING, or ERROR, and you choose which levels to show. You can turn messages off by changing one line, without deleting them from your code, and you can send them to a file instead of the screen.
import logging
import sys
# Show messages of level INFO and above.
# By default, logging writes to the error stream (stderr);
# stream=sys.stdout sends the messages to the same place as print().
logging.basicConfig(
level=logging.INFO,
format='%(levelname)s: %(message)s',
stream=sys.stdout,
)
def divide(a, b):
logging.debug('divide() called with %s and %s', a, b) # Hidden: below INFO
if b == 0:
logging.error('Cannot divide %s by zero', a)
return None
logging.info('Dividing %s by %s', a, b)
return a / b
print(divide(10, 2))
print(divide(1, 0))
# Output:
# INFO: Dividing 10 by 2
# 5.0
# ERROR: Cannot divide 1 by zero
# None
Change level=logging.INFO to level=logging.DEBUG to see the debug messages too. To write the messages to a file, replace stream=sys.stdout with filename='app.log'.
Common pitfalls to avoid
Some mistakes come up again and again when you are learning Python, and knowing about them in advance saves you a lot of time.
Errors Python reports for you
These mistakes stop your program with an error message, so at least you know something is wrong:
- Indentation errors: Indentation defines code blocks, such as the body of a function, loop, or
ifstatement. If the indentation is wrong, Python raises anIndentationError, or your code runs in the wrong block. Use 4 spaces for each level, and never mix tabs and spaces. - Name errors: You have to define a variable, function, or class before you use it. Using a name that doesn’t exist, often because of a typo, raises a
NameError. - Type errors: An operation that doesn’t work for a type raises a
TypeError. For example, you can’t join a string and an integer with+(see the example below). - Syntax errors: Missing colons, brackets, or quotes raise a
SyntaxErrorbefore your program even starts. The error message points to where Python got confused; the actual mistake is often on that line or the line before it. - Unhandled exceptions: If an exception isn’t handled, your program stops and prints a traceback. Use
tryandexceptfor errors you can expect, such as a missing file or invalid input.
Here is the TypeError from item 3, and two ways to fix it:
age = 25
try:
print('Age: ' + age)
except TypeError as error:
print(f'TypeError: {error}')
# Output: TypeError: can only concatenate str (not "int") to str
# Fix: convert the number to a string, or use an f-string
print('Age: ' + str(age)) # Output: Age: 25
print(f'Age: {age}') # Output: Age: 25
The pitfalls in the rest of this section are harder to spot, because they don’t raise an error. Your program runs, but gives the wrong result.
Mutable default arguments
A default argument value is created only once, when the function is defined, not each time the function is called. If the default is a mutable object, such as a list or dictionary, every call that uses the default shares the same object:
def add_item(item, items=[]):
items.append(item)
return items
print(add_item('apple')) # Output: ['apple']
print(add_item('banana')) # Output: ['apple', 'banana']
The second call still sees 'apple' from the first call. The fix is to use None as the default and create a new list inside the function:
def add_item(item, items=None):
if items is None:
items = []
items.append(item)
return items
print(add_item('apple')) # Output: ['apple']
print(add_item('banana')) # Output: ['banana']
Changing a list while looping over it
Removing items from a list while a for loop goes through it makes the loop skip items. Here the loop should remove every even number, but one is left behind:
numbers = [1, 2, 2, 3, 4]
for number in numbers:
if number % 2 == 0:
numbers.remove(number)
print(numbers) # Output: [1, 2, 3]
When the first 2 is removed, every later item moves one position to the left, so the loop skips the second 2. The fix is to build a new list with the items you want to keep, or to loop over a copy of the list (numbers[:]):
numbers = [1, 2, 2, 3, 4]
odd_numbers = [number for number in numbers if number % 2 != 0]
print(odd_numbers) # Output: [1, 3]
Confusing is with ==
== checks whether two values are equal. is checks whether two names refer to the very same object in memory:
a = [1, 2, 3]
b = [1, 2, 3]
print(a == b) # Output: True
print(a is b) # Output: False
The lists have the same contents, so they are equal, but they are two separate objects. Use == to compare values. Use is only to check for None, as in if items is None:. Sometimes is seems to work for small numbers or short strings, because Python reuses some objects behind the scenes, but you can’t rely on that.
/ versus //
The / operator always returns a float, even when both numbers are integers and the result is a whole number. The // operator (floor division) returns the result rounded down to a whole number. Rounding down means toward negative infinity, so -7 // 2 is -4, not -3:
print(7 / 2) # Output: 3.5
print(6 / 2) # Output: 3.0
print(7 // 2) # Output: 3
print(-7 // 2) # Output: -4
This matters when you use the result as a list index, which has to be an integer:
items = ['a', 'b', 'c', 'd', 'e']
try:
print(items[len(items) / 2])
except TypeError as error:
print(f'TypeError: {error}')
# Output: TypeError: list indices must be integers or slices, not float
print(items[len(items) // 2]) # Output: c
Shadowing built-in names
Python lets you use the names of built-in functions and types, such as list, str, dict, sum, max, input, or id, as variable names. But once you do, the built-in is hidden behind your variable:
list = [3, 1, 2] # 'list' now means this list, not the built-in type
try:
letters = list('abc')
except TypeError as error:
print(f'TypeError: {error}')
# Output: TypeError: 'list' object is not callable
The fix is to choose a more descriptive name, which also makes your code clearer:
numbers = [3, 1, 2]
letters = list('abc')
print(letters) # Output: ['a', 'b', 'c']
Key takeaways
- Follow PEP 8: 4-space indentation, short lines, snake_case for functions and variables, and CapWords for classes. Let a formatter and linter such as Ruff do the work.
- Write comments that explain why, docstrings that explain what a function does, and type hints that tools such as mypy can check.
- Test your code automatically with
unittestor pytest, so you find out right away when a change breaks something. - Give each project its own virtual environment, and record its packages in
requirements.txt. - To find bugs, read tracebacks from the bottom up, and use
print(),breakpoint(),assert, andloggingto see what your code is really doing. - Watch out for mutable default arguments, changing a list while looping over it,
isversus==,/versus//, and variable names that hide built-ins.
Practice
Exercise 1: Fix the shared list
The function below is supposed to start a new list of scores for each student, unless it is given an existing list. Instead, every student’s scores end up in the same list. Find the bug and fix it so that the program prints [90] and then [75].
def add_score(score, scores=[]):
scores.append(score)
return scores
print(add_score(90)) # Output: [90]
print(add_score(75)) # Output: [90, 75]
Solution
def add_score(score, scores=None):
# Create a new list on each call, instead of sharing one default list
if scores is None:
scores = []
scores.append(score)
return scores
print(add_score(90))
print(add_score(75))
# Output:
# [90]
# [75]
Exercise 2: Test a function with assert
Write a function celsius_to_fahrenheit(celsius) that converts a temperature using the formula celsius * 9 / 5 + 32. Then write a function test_celsius_to_fahrenheit() that uses assert to check at least three values: 0 degrees Celsius is 32 degrees Fahrenheit, 100 is 212, and -40 is -40. Call the test function and print All tests passed at the end. (This test function would also work unchanged with pytest.)
Solution
def celsius_to_fahrenheit(celsius):
return celsius * 9 / 5 + 32
def test_celsius_to_fahrenheit():
assert celsius_to_fahrenheit(0) == 32
assert celsius_to_fahrenheit(100) == 212
assert celsius_to_fahrenheit(-40) == -40
test_celsius_to_fahrenheit()
print('All tests passed')
# Output:
# All tests passed
Exercise 3: Clean up a function
The function below works, but it is hard to read. Rewrite it following the advice in this chapter: give the function and its variables descriptive names, add a docstring and type hints, and make sure it doesn’t shadow any built-in names. The function should return the total price of the items whose price is above a given limit.
def f(list, x):
sum = 0
for i in list:
if i > x:
sum = sum + i
return sum
print(f([5.0, 20.0, 12.5, 3.0], 10)) # Output: 32.5
Solution
def total_above(prices: list[float], limit: float) -> float:
"""Return the sum of the prices that are greater than limit."""
total = 0.0
for price in prices:
if price > limit:
total += price
return total
print(total_above([5.0, 20.0, 12.5, 3.0], 10))
# Output:
# 32.5