Browse this section
Browse these items

All writing

Details and outline
Kind
Essay
Published
27 October 2026
Length
4 min read
Authorship
Created, drafted, and interpreted by Claude
Model
Claude Sonnet 5.5

AI disclaimer: this page was written for the site owner to read occasionally. AI can make mistakes, so the content may be inaccurate. Please read it with caution. The site owner does not recommend making any decision based on AI-generated content.

Essay

Learning Python, Part 3: Functions and Scope

Study notes on defining functions, return values, default arguments, scope, what a function can change, and structuring a program. Written entirely by an AI model.

A function is a named, reusable piece of a program. It lets you write something once, test it on its own and give it a name that says what it does. From here on, most exercises ask for a function rather than a script, so this part is the one to get comfortable with.

Defining and calling

def greet(person):
    print("Welcome,", person)
    print("The tea is on the left.")

greet("Mei")
greet("Omar")
  • def name(parameters): introduces the function, and the indented body is what runs.
  • Defining a function does not run it. Calling it, with brackets, does. Writing greet without brackets only refers to the function object.
  • Parameters are the names in the definition. Arguments are the values you pass when calling. Passing the wrong number gives a TypeError.

Return versus print

print shows something on the screen. return hands a value back to whoever called the function and ends it immediately.

def area(width, height):
    return width * height

room = area(4, 3)         # 12, now available for more calculation

A function with no return returns None. If you see None in your output, look for a missing return, or for a print where you meant return. A function that prints its answer is hard to reuse, because the caller cannot do anything further with it.

To return several values, return a tuple and unpack it:

def min_and_max(values):
    return min(values), max(values)

low, high = min_and_max([4, 9, 2, 7])   # 2 and 9

Default and keyword arguments

A parameter can have a default, used when the caller leaves it out. Defaults must come after parameters without them.

def price(base, tax=0.1, discount=0.0):
    return base * (1 + tax) * (1 - discount)

price(100)                       # 110.00000000000001
price(100, discount=0.5)         # 55.00000000000001
price(100, 0.0, 0.2)             # 80.0

Naming an argument (discount=0.5) lets you skip over the ones in between. The floating-point tails are the same effect covered in Part 1.

The mutable default trap

A default value is created once, when the function is defined. If it is a list or dictionary, every call shares it:

def add_stop(stop, route=[]):          # bug
    route.append(stop)
    return route

add_stop("Market")     # ['Market']
add_stop("Pier")       # ['Market', 'Pier']   the same list again

The fix is to default to None and make a fresh list inside the function:

def add_stop(stop, route=None):
    if route is None:
        route = []
    route.append(stop)
    return route

Scope

Names created inside a function are local. They exist while that call runs and nowhere else. Parameters are local too, and every call gets a fresh set.

def double(n):
    result = n * 2
    return result

double(4)
print(result)      # NameError: result is not defined out here

When Python looks up a name, it checks the local scope first, then any enclosing function, then the module’s global names, then the built-ins. An assignment inside a function always creates a local name, even if a global of the same name exists. Pass values in as parameters and send results out with return, and you will almost never need the global keyword.

Can a function change its argument?

This catches many people out. The parameter starts out bound to the same object as the argument. Two different things can then happen:

Rebinding the parameter to something new affects only the local name:

def add_bonus(balance):
    balance = balance + 50      # a new number; the caller's name is untouched

wallet = 100
add_bonus(wallet)
print(wallet)                   # 100

Mutating a mutable object such as a list changes it for everyone who can see it:

def add_bonus_all(balances):
    for i in range(len(balances)):
        balances[i] += 50

pots = [100, 220]
add_bonus_all(pots)
print(pots)                     # [150, 270]

Numbers, strings and tuples are immutable, so they can only be rebound. Lists, dictionaries and sets are mutable. As a rule, return a new value unless you have a clear reason to modify the argument, and say so in a comment when you do.

Building a program from functions

A good shape is one job per function, with a main function that shows the overall plan:

def read_scores():
    ...

def average(scores):
    return sum(scores) / len(scores)

def report(avg):
    print(f"Average: {avg:.1f}")

def main():
    scores = read_scores()
    report(average(scores))

if __name__ == "__main__":
    main()

The if __name__ == "__main__": guard runs main() when you run the file directly, but not when another file imports it. That is what lets you test the other functions without triggering the whole program. Importing a module runs its top-level code once, then reuses it.

Test as you go

Before trusting a function, write down a few calls and the answers you expect, including awkward ones:

assert area(4, 3) == 12
assert area(0, 9) == 0
assert min_and_max([5]) == (5, 5)

An assert that fails stops the program with an AssertionError. That is exactly the noise you want. It is also the idea behind the checker in the drills tool.

Where to practise

Every drill in Python Drills is a function. Start with the first section of Set A or Set B. Part 4 covers strings, lists and files.