Level 01 · Python Fluency

Core Python

Values, decisions, loops, functions and the built-in collections.

Level 1 · Python Fluency: Core Python

The Python Fluency path is the language layer that runs underneath the other three paths. The Algorithms path asks what to compute and how fast; Statistics asks what data can tell you; Machine Learning asks how a program can learn from examples. This path asks a different question: once you know what you want to compute, how do you say it in Python so that it is clear, correct and efficient? Every section starts with a small real problem, solves it the plain way that a newcomer would write, and then shows the part of the language that expresses the same idea more naturally.

Level 1 covers the core: values, conditions, loops, functions and the four built-in collections (lists, tuples, dictionaries and sets). If you have never programmed before, read the Algorithms Level 1 lesson first; it introduces print, input, arithmetic, if, while, for and lists from the very beginning. This lesson assumes you have seen those pieces and concentrates on using them well: choosing the right collection, avoiding the handful of traps that catch almost everyone, and writing code that someone else (including you, next month) can read at a glance.

None of this is decoration. Choosing a set instead of a list can turn a program that takes minutes into one that takes milliseconds, and a tuple instead of a list can make a whole class of bugs impossible. The habits you build here are the ones the rest of the site relies on.

Numbers, strings and booleans

Suppose you have three test scores and want to report their mean to two decimal places. A first version glues the pieces together with + and str:

scores = [72, 85, 90]
mean = (scores[0] + scores[1] + scores[2]) / 3
print("Mean: " + str(round(mean, 2)) + " from " + str(len(scores)) + " scores")
Mean: 82.33 from 3 scores

It works, but the line is hard to read and every number needs its own str(...). An f-string puts the values straight into the text, and a format specification after a colon controls how each one looks:

scores = [72, 85, 90]
mean = sum(scores) / len(scores)
print(f"Mean: {mean:.2f} from {len(scores)} scores")
print(f"Mean: {90 / 1:.2f}")      # .2f always shows two decimals
print(f"{0.4567:.1%}")            # as a percentage
print(f"{1234567:,}")             # thousands separator
Mean: 82.33 from 3 scores
Mean: 90.00
45.7%
1,234,567

round(x, 2) changes the number; :.2f changes only how it is shown, and it always pads to two decimals, which round does not (round(90.0, 2) prints as 90.0). When a problem asks for output "to two decimal places", use the format specification.

Python's two number types behave differently in ways worth knowing. Integers never overflow: 2 ** 100 is exact. Floats are binary approximations, so simple decimal sums can be slightly off, and comparing floats with == is a bug waiting to happen. Use math.isclose instead:

import math
print(2 ** 100)
print(0.1 + 0.2)
print(0.1 + 0.2 == 0.3)
print(math.isclose(0.1 + 0.2, 0.3))
1267650600228229401496703205376
0.30000000000000004
False
True

You will meet this again in the Machine Learning path, where distances and losses are floats and "equal" always means "close enough".

Booleans are the results of comparisons, and in Python they are also integers: True counts as 1 and False as 0. That makes counting the scores above a threshold a matter of adding up comparisons:

scores = [72, 85, 90, 64, 81]
passed = 0
for s in scores:
    passed += s >= 80
print(passed, True + True)
3 2

Level 2 shrinks that loop to a single expression, sum(s >= 80 for s in scores), which is the same idea.

Common mistakes: comparing floats with ==; writing "Total: " + 5, which raises TypeError because a string and a number cannot be added; forgetting the f before the quote, so the braces are printed literally; and rounding in the middle of a calculation instead of only when displaying the result, which lets rounding errors pile up.

Practice: Average of Three, Money with Thousands Separators, Does the Till Agree?

Conditionals

A grading function turns a score into a letter. A first version tests each band completely, both ends of every range:

def grade(score):
    if score >= 90:
        return "A"
    if score >= 80 and score < 90:
        return "B"
    if score >= 70 and score < 80:
        return "C"
    return "F"

print(grade(95), grade(85), grade(70), grade(12))
A B C F

The and score < 90 parts are redundant: by the time the second test runs, the first has already ruled out 90 and above. Redundant conditions are not harmless. When someone moves the A boundary to 88, they must change it in two places; update the B test but forget the A test, and scores of 88 and 89 match neither band and are graded F. An elif chain states each boundary once and relies on the order:

def grade(score):
    if score >= 90:
        return "A"
    elif score >= 80:
        return "B"
    elif score >= 70:
        return "C"
    else:
        return "F"

print(grade(95), grade(85), grade(70), grade(12))
A B C F

Three more tools make conditions read like the idea behind them. Chained comparisons test a range the way you would write it in maths. A conditional expression picks one of two values in a single line, which is exactly the threshold rule of the Machine Learning path. And truthiness lets you test for "empty" directly: empty strings, empty lists, 0 and None all count as false.

x = 7
print(0 <= x < 10)                 # instead of 0 <= x and x < 10

score, threshold = 0.73, 0.5
label = 1 if score >= threshold else 0
print(label)

def first_word(text):
    if not text:                   # guard clause: deal with the empty case first
        return None
    return text.split()[0]

print(first_word("hello world"), first_word(""))
True
1
hello None

The last function also shows a guard clause: handle the awkward case at the top and return early, so the main logic below is not buried inside an else.

Common mistakes: putting a broader test before a narrower one (if score >= 70 first catches every A); writing if x == True: instead of if x:; and relying on truthiness when zero is a valid value. if not count: is true for 0 as well as None, so write if count is None: when you mean "missing".

Practice: Air Quality Label, Letter Grade, One Line per Sensor

Loops

Find the first reading in a list that is above a limit. A first version, written the way many other languages teach it, keeps an index by hand:

readings = [12, 15, 11, 31, 18, 40]
i = 0
found = None
while i < len(readings):
    if readings[i] > 30:
        found = readings[i]
        break
    i = i + 1
print(found)
31

There are three ways to get this wrong: start i at 1, write <= instead of <, or forget i = i + 1 and loop forever. A for loop over the list itself removes all three, because Python hands you each element in turn and stops at the end:

readings = [12, 15, 11, 31, 18, 40]
found = None
for r in readings:
    if r > 30:
        found = r
        break
print(found)
31

break leaves the loop at once, and continue skips to the next element. A for loop may also have an else block, which runs only when the loop finished without a break. It is the natural place for "searched everything and found nothing":

def first_divisor(n):
    for d in range(2, n):
        if n % d == 0:
            print(n, "is divisible by", d)
            break
    else:
        print(n, "is prime")

first_divisor(91)
first_divisor(97)
91 is divisible by 7
97 is prime

So when should you still use while? When you do not know in advance how many steps there will be: halving a range in binary search, following the Collatz rule until you reach 1, or repeating gradient descent until the loss stops improving. If there is a collection to walk through, use for.

Common mistakes: writing for i in range(len(items)): and then only ever using items[i] (loop over items directly; Level 2 shows enumerate for when you need the position too); changing a list while looping over it, which skips elements; and misreading for ... else as "else, if the list was empty". It means "if no break happened".

Practice: Shopping Until the Money Runs Out, Collatz Steps, Into the First Van With Room

Functions

You have a home location and two shops, and you want the distance to each. A first version repeats the formula:

home = (0, 0)
shop_a = (3, 4)
shop_b = (6, 8)
d_a = ((shop_a[0] - home[0]) ** 2 + (shop_a[1] - home[1]) ** 2) ** 0.5
d_b = ((shop_b[0] - home[0]) ** 2 + (shop_b[1] - home[1]) ** 2) ** 0.5
print(d_a, d_b)
5.0 10.0

Copying a formula is where typos breed: change shop_b[1] to shop_b[0] by accident and the program still runs, just wrongly. A function gives the idea a name, writes it once and lets you test it once:

def distance(p, q):
    """Straight-line (Euclidean) distance between two points."""
    return ((p[0] - q[0]) ** 2 + (p[1] - q[1]) ** 2) ** 0.5

home = (0, 0)
print(distance(home, (3, 4)), distance(home, (6, 8)))
5.0 10.0

The string on the first line of the body is a docstring: it tells readers (and the editor's tooltips) what the function does. This exact function reappears in the Machine Learning path, where nearest-neighbour prediction is little more than "call distance on every training point and keep the smallest".

Parameters can have default values, and callers can pass arguments by name. Both make calls easier to read than a row of bare numbers:

def describe(values, decimals=2, label="mean"):
    m = sum(values) / len(values)
    return f"{label} = {m:.{decimals}f}"

print(describe([1, 2, 4]))
print(describe([1, 2, 4], decimals=4))
print(describe([1, 2, 4], label="average", decimals=0))
mean = 2.33
mean = 2.3333
average = 2

One default value is a famous trap. A default is created once, when the function is defined, so a list used as a default is shared by every call:

def add_item(item, basket=[]):          # bug: one list shared by all calls
    basket.append(item)
    return basket

print(add_item("apple"))
print(add_item("pear"))                 # the apple is still there

def add_item(item, basket=None):        # fix: create a fresh list inside
    if basket is None:
        basket = []
    basket.append(item)
    return basket

print(add_item("apple"))
print(add_item("pear"))
['apple']
['apple', 'pear']
['apple']
['pear']

Common mistakes: printing a result instead of returning it (the caller gets None); using a list or dictionary as a default value; and writing one long function that reads input, computes and prints. Keep the computation in a function that takes arguments and returns a value, so it can be tested on its own.

Practice: Distance Between Two Points

Lists

Keep the even numbers from a list. A first version builds the answer by joining lists together:

nums = [5, 8, 2, 7, 4]
evens = []
for x in nums:
    if x % 2 == 0:
        evens = evens + [x]
print(evens)
[8, 2, 4]

The answer is right, but evens + [x] builds a brand-new list and copies every element into it on each step. For a list of a million numbers that adds up to more than a hundred billion copies. append adds to the end of the existing list in constant time:

nums = [5, 8, 2, 7, 4]
evens = []
for x in nums:
    if x % 2 == 0:
        evens.append(x)
print(evens)
[8, 2, 4]

A list is the right tool when order matters and you mostly add or remove at the end. append and pop make it a stack, which the Algorithms path uses for bracket matching and depth-first search. Removing from the front (pop(0)) has to shift every other element along, so a queue should use collections.deque instead.

The second thing to understand about lists is that a variable holds a reference to a list, not a copy. Two names can point at the same list:

a = [1, 2, 3]
b = a              # the same list under a second name
b.append(4)
print(a)

c = a.copy()       # a new, separate list
c.append(5)
print(a, c)

grid = [[0] * 3] * 2           # two references to ONE row
grid[0][0] = 9
print(grid)
grid = [[0] * 3 for _ in range(2)]   # two separate rows
grid[0][0] = 9
print(grid)
[1, 2, 3, 4]
[1, 2, 3, 4] [1, 2, 3, 4, 5]
[[9, 0, 0], [9, 0, 0]]
[[9, 0, 0], [0, 0, 0]]

The last fix uses a list comprehension, which Level 2 covers properly. For now, remember the rule: * on a list repeats references, so it is safe for numbers and unsafe for lists inside lists.

Finally, sorting: nums.sort() sorts the list in place and returns None; sorted(nums) leaves the original alone and returns a new sorted list.

nums = [3, 1, 2]
print(sorted(nums), nums)
print(nums.sort(), nums)
[1, 2, 3] [3, 1, 2]
None [1, 2, 3]

Common mistakes: nums = nums.sort() (now nums is None); nums = nums.append(x) for the same reason; copying with b = a and then being surprised that a changed; and building a two-dimensional grid with [[0] * n] * m.

Practice: Passing Grades Only, Undo the Last Actions, Drop the Highest and Lowest Score

Tuples

A function should return both the smallest and the largest value of a list. A first version returns a list:

def min_max(nums):
    return [min(nums), max(nums)]

result = min_max([4, 9, 1, 7])
print(result[0], result[1])
1 9

Nothing stops a caller from appending to that list or changing result[0], and result[0] does not say which value is which. A tuple is a fixed-length, unchangeable sequence, written with commas (the brackets are optional). Returning a tuple and unpacking it into two names says exactly what the function produces:

def min_max(nums):
    return min(nums), max(nums)

lo, hi = min_max([4, 9, 1, 7])
print(lo, hi)

a, b = 1, 2
a, b = b, a          # swap without a temporary variable
print(a, b)
1 9
2 1

Use a tuple when the position of each item has a meaning: a point (x, y), a date (year, month, day), a pair (priority, task). Use a list when you have a varying number of items of the same kind.

Because a tuple cannot change, Python can use it as a dictionary key or put it in a set; a list cannot. That is how the Algorithms path records which grid cells a search has visited. Tuples also compare element by element, left to right, so a tuple sorts first by its first item and breaks ties with the next. A heap of (priority, name) pairs relies on exactly this:

visited = {(0, 0), (0, 1)}
print((0, 1) in visited)

tasks = [(2, "write"), (1, "plan"), (2, "test")]
print(sorted(tasks))
print((1, "z") < (2, "a"))

try:
    bad = {[0, 0]: "origin"}
except TypeError:
    print("a list cannot be a key")
True
[(1, 'plan'), (2, 'test'), (2, 'write')]
True
a list cannot be a key

Common mistakes: writing (5) for a one-element tuple (that is just the number 5; write (5,)); unpacking into the wrong number of names, which raises ValueError; and trying to change a tuple in place. To "change" a tuple, build a new one: point = (point[0] + 1, point[1]).

Practice: Smallest and Largest, Oldest and Newest Diary Entry, Hours, Minutes, Seconds

Dictionaries

Count how often each word appears in a sentence. A first version keeps two parallel lists, one of words and one of counts, and looks each word up with index:

text = "the cat saw the dog and the cat ran"
words, counts = [], []
for w in text.split():
    if w in words:
        counts[words.index(w)] += 1
    else:
        words.append(w)
        counts.append(1)
print(words)
print(counts)
['the', 'cat', 'saw', 'dog', 'and', 'ran']
[3, 2, 1, 1, 1, 1]

Two lists that must stay in step are fragile (sort one and forget the other, and every count is attached to the wrong word), and both w in words and words.index(w) scan the list from the start, so the whole loop is O(n²). A dictionary maps each key directly to its value, and looking up a key takes about the same time however large the dictionary is:

text = "the cat saw the dog and the cat ran"
counts = {}
for w in text.split():
    counts[w] = counts.get(w, 0) + 1
print(counts)
print(counts["the"], counts.get("bird", 0))
for word, n in counts.items():
    if n > 1:
        print(word, n)
{'the': 3, 'cat': 2, 'saw': 1, 'dog': 1, 'and': 1, 'ran': 1}
3 0
the 3
cat 2

counts.get(w, 0) returns the stored value, or 0 if the key is missing, which is exactly what a counter needs. Looping over .items() gives you each key and value together. Dictionaries remember the order in which keys were first inserted, so the output above is predictable.

This word count is a frequency table, the first object of the Statistics path (the mode is just the key with the largest count). In the Algorithms path the same structure answers "have I seen this before, and where?" in one step, which is how the two-sum problem drops from O(n²) to O(n). Level 3 introduces collections.Counter, which does this counting for you.

Common mistakes: reading d[key] for a key that might be missing (it raises KeyError; use get or test key in d first); using a list as a key (use a tuple); and adding or removing keys while looping over the same dictionary, which raises RuntimeError. Loop over list(d) if you need to delete as you go.

Practice: Every Table Gets Its Own Order, Character Census, Who Worked on What

Sets

Does a list contain any value twice? A first version remembers what it has seen in a list:

def has_duplicate(nums):
    seen = []
    for x in nums:
        if x in seen:
            return True
        seen.append(x)
    return False

print(has_duplicate([3, 1, 4, 1, 5]), has_duplicate([2, 7, 1, 8]))
True False

x in seen checks the list one element at a time, so for 100,000 distinct numbers this makes about five billion comparisons. A set stores each value once and answers in in constant time on average, just like a dictionary key lookup. Changing one line turns the function from O(n²) into O(n):

def has_duplicate(nums):
    seen = set()
    for x in nums:
        if x in seen:
            return True
        seen.add(x)
    return False

print(has_duplicate([3, 1, 4, 1, 5]), has_duplicate([2, 7, 1, 8]))
print(len(set([3, 1, 4, 1, 5])) < 5)     # the one-line version
True False
True

Sets also answer questions about two groups at once, with operators borrowed from mathematics: & (in both), | (in either), - (in the first but not the second) and ^ (in exactly one).

alg = {"ana", "ben", "chen", "dev"}
stat = {"ben", "dev", "eli"}
print(sorted(alg & stat))
print(sorted(alg | stat))
print(sorted(alg - stat))
print("eli" in stat, "eli" in alg)
['ben', 'dev']
['ana', 'ben', 'chen', 'dev', 'eli']
['ana', 'chen']
True False

The examples wrap each result in sorted for a reason: a set has no order, and the order in which it prints strings can change from one run to the next. Never rely on it. In the Algorithms path a set is the visited collection of every graph search; in Statistics, len(set(data)) counts the distinct values.

Common mistakes: writing {} for an empty set (that is an empty dictionary; write set()); expecting a set to keep insertion order or duplicates; and putting lists into a set (use tuples). If you need counts as well as membership, you want a dictionary, not a set.

Practice: The First Key Pressed Twice, Regulars and Newcomers at the Climbing Gym, Where the Rover Crosses Its Track

Practice

These problems are required on this path at this level; the path adds one problem from each of the level's topics, and one for each construct the others do not use, picked for you and kept.

Checklist

  • Format numbers in f-strings with :.2f, :.1% and :,, and compare floats with math.isclose.
  • Write an elif chain that states each boundary once, use chained comparisons and conditional expressions, and handle empty input with a guard clause.
  • Loop over a collection directly instead of managing an index, and use for ... else for "searched and not found".
  • Turn repeated code into a function with a docstring, defaults and keyword arguments, and avoid mutable default values.
  • Build lists with append, tell sort from sorted, and explain why b = a and [[0] * n] * m surprise people.
  • Choose a tuple for fixed, meaningful positions, unpack it, and use it as a dictionary key or set element.
  • Count and look things up with a dictionary, using get and .items().
  • Replace list membership tests with a set, and use &, | and - to compare groups.