Lesson 12
Python Functions
Functions package a block of code under a name so you can reuse it — the foundation of clean, readable scripts.
A function is a named block of code you define once and call as many times as you need. You’ve been calling functions since Lesson 03 — print, len, str, split, replace, sorted. This lesson is about writing your own.
Two principles to keep in mind:
- Don’t repeat yourself. If you find yourself copy-pasting three lines and changing one number, those lines belong inside a function.
- Name what code does. A well-named function turns five mysterious lines into one readable line —
clean_filename(name)is more useful than the five lines that strip spaces, punctuation, and case.
Defining a function
The keyword is def, followed by the name, parentheses with any parameters, a colon, and an indented block.
def double(a):
return a * 2
a is a parameter — a placeholder for whatever value the caller passes in. Inside the function, a behaves like any other variable. The function ends either at the first un-indented line or at a return statement.
Calling it
Use the name plus parentheses and the arguments that fill in the parameters:
def double(a):
return a * 2
print(double(3)) # 6
result = double(7)
print(result) # 14
You can call a function inside a loop — exactly the use case that makes them earn their keep:
def double(a):
return a * 2
for n in [1, 2, 3]:
print(double(n))
Returning values
return is what hands a value back to the caller. A function that doesn’t return anything implicitly returns None:
def shout(text):
print(text.upper()) # prints, but returns nothing
x = shout("hello")
print(x) # None
Compare with a function that does return:
def shouting(text):
return text.upper()
x = shouting("hello")
print(x) # 'HELLO'
Both versions are valid; they’re just answering different questions. print displays. return hands a value to whoever called you. Most useful functions return.
A function can return more than one value at once, separated by commas. The result is a tuple:
def name_parts(full):
first, last = full.split(maxsplit=1)
return first, last
f, l = name_parts("Ada Lovelace")
print(f, l) # Ada Lovelace
Parameters with defaults
A parameter can have a default value, used when the caller doesn’t supply one:
def greet(name, greeting="Hello"):
return f"{greeting}, {name}."
print(greet("Ada")) # 'Hello, Ada.'
print(greet("Ada", greeting="Bonjour")) # 'Bonjour, Ada.'
Defaults make a function easy to use for the common case without locking out the unusual one.
Keyword arguments — call by name
You can pass arguments by position (in the order the parameters were defined) or by name:
def slice_text(text, start, end):
return text[start:end]
print(slice_text("digital humanities", 0, 7)) # by position
print(slice_text(text="digital humanities", start=0, end=7)) # by name
For a function with two or three obvious parameters, positional is fine. As soon as a function has four or more, naming arguments makes calls vastly more readable — you can tell what slice_text(text, 0, 7) is doing at a glance.
Type hints — recommended once you’re comfortable
Modern Python code annotates parameters and return types. The annotations don’t change what the code does — Python is still dynamic — but they make the function self-documenting and catch real bugs when used with a checker like mypy or pyright.
def double(a: int) -> int:
return a * 2
def greet(name: str, greeting: str = "Hello") -> str:
return f"{greeting}, {name}."
You don’t have to use type hints to write Python well. But once you start, you’ll find your own functions easier to come back to in three months.
Docstrings — describe what the function does
A string at the top of a function body becomes its docstring — visible in editors, tools, and help(). Triple quotes, one line for short ones, multiple lines if a function deserves explanation:
def slice_text(text: str, start: int, end: int) -> str:
"""Return text[start:end]. Indexes follow Python's half-open slice rule."""
return text[start:end]
For a one-liner you’d otherwise comment, this is strictly better — the documentation lives with the code and shows up where it’s useful.
Empty functions — pass
Python won’t accept an empty block. When you’re sketching out a script and want a function you’ll fill in later, use pass:
def parse_letter(text):
pass # TODO: extract sender, date, body
pass is also useful in if and while blocks where you need a placeholder but no action.
Scope — what a function can see
A variable defined inside a function is local to that function. It doesn’t exist outside, and it doesn’t leak into other functions:
def f():
x = 10
return x
print(f())
print(x) # NameError: name 'x' is not defined
A function can read variables defined in the enclosing module, but it should be rare and deliberate. The cleaner pattern is to pass everything a function needs in as parameters and return what it produces. Functions that depend on hidden global state are hard to test and harder to reuse.
A worked example — cleaning text
A small function that does something real: strip punctuation and lowercase a string, the kind of preprocessing you’ll do constantly in text analysis.
import string
def normalize(text: str) -> str:
"""Lowercase the text and strip ASCII punctuation."""
no_punct = text.translate(str.maketrans("", "", string.punctuation))
return no_punct.lower()
print(normalize("The quality of mercy, is not strain'd."))
# 'the quality of mercy is not straind'
That’s three lines of work behind one descriptive name. From here on, anywhere you’d otherwise repeat the punctuation-stripping logic, you call normalize(text) instead. That’s the whole game.
When you can write functions comfortably, move on to Lesson 13: Python Classes.
Running the code
Save any snippet from this lesson to a file — say try.py — and run it from your project folder:
uv run try.py
uv run uses the project’s Python and dependencies automatically; no virtualenv to activate. If you haven’t set the project up yet, Lesson 01 walks through it.