Functions in Python: def, return, and Arguments
A practical guide to Python functions — defining with def, returning values, arguments and parameters, docstrings, and the habits that make functions worth reusing.
What you'll learn
- ✓How to define functions with def
- ✓How return works — and what happens when you omit it
- ✓The difference between parameters and arguments
- ✓How Python passes arguments (and why "by reference" is the wrong phrase)
- ✓How to write a useful docstring
- ✓The single-responsibility habit that makes functions reusable
Prerequisites
- •Comfortable with control flow — see Conditionals and For Loops
Functions are the unit of reuse in Python. They let you name a chunk of behaviour, hide its details behind a clear signature, and call it from anywhere. As a program grows, the difference between code that stays readable and code that does not is almost entirely about how its functions are shaped. This post covers the syntax in full and the habits that come with it.
Defining a function
The keyword is def, followed by a name, a parenthesised parameter list, and a colon. The body is indented:
def greet(name):
print(f"Hello, {name}!")
greet("Alice") # Hello, Alice!
The name follows the same identifier rules as variables — lowercase with underscores by convention; see Python Variables and Naming.
A function may take zero parameters:
def banner():
print("=" * 40)
banner()
Or several:
def add(a, b):
return a + b
print(add(3, 4)) # 7
return
return ends the function and sends a value back to the caller. Every expression after the return in that branch is skipped:
def absolute(n):
if n < 0:
return -n
return n
print(absolute(-7)) # 7
A function without an explicit return returns None. So does a bare return with no value:
def log(message):
print(message)
result = log("Hi")
print(result) # None
This is by design. A function that performs an action (logging, mutating, printing) typically returns nothing, and None is the natural placeholder.
You can return multiple values by returning a tuple, which the caller unpacks:
def divmod_(a, b):
return a // b, a % b
quotient, remainder = divmod_(17, 5)
print(quotient, remainder) # 3 2
This is purely a packed tuple — see Python Tuples for the underlying mechanism.
Parameters vs arguments
The names listed in the def are parameters. The values you pass at the call site are arguments. The distinction matters when you read documentation or error messages.
def rectangle_area(width, height): # parameters
return width * height
rectangle_area(3, 4) # arguments: 3 and 4
You can pass arguments by position or by keyword:
rectangle_area(3, 4) # positional
rectangle_area(width=3, height=4) # keyword
rectangle_area(height=4, width=3) # keyword — order doesn't matter
Keyword arguments are wonderful for readability. make_request("https://api.example.com", 30, True) tells you nothing; make_request(url, timeout=30, verify=True) tells you everything. The next post — Default and Keyword Arguments — covers their full power.
How arguments are passed
You may have heard Python is “pass by value” or “pass by reference.” Both phrases mislead. The accurate description is pass by assignment: the parameter inside the function is bound to the same object the caller passed.
This means:
- For immutable objects (numbers, strings, tuples), the function cannot affect the caller’s value.
- For mutable objects (lists, dicts, sets), the function can mutate the object in place, and the caller will see the changes.
def add_one(n):
n = n + 1 # rebinds local name; caller's number is untouched
x = 5
add_one(x)
print(x) # 5
def append_one(lst):
lst.append(1) # mutates the same list the caller holds
items = [10, 20]
append_one(items)
print(items) # [10, 20, 1]
The rule: reassignment never reaches the caller; mutation does. This catches everyone at least once, especially with default arguments — a topic we cover in the Default and Keyword Arguments post.
Try it yourself. Write a function swap_first_last(items) that swaps the first and last elements of a list in place and returns None. Then write swap_first_last_copy(items) that returns a new list with the swap applied and leaves the original alone. Test both on [1, 2, 3, 4, 5].
Docstrings
The first statement of a function can be a string literal. By convention, this is the function’s docstring — a short description of what it does. Tools like help(), IDEs, and documentation generators read it.
def average(numbers):
"""Return the arithmetic mean of a non-empty list of numbers.
Raises ValueError if numbers is empty.
"""
if not numbers:
raise ValueError("numbers must be non-empty")
return sum(numbers) / len(numbers)
help(average)
Use triple quotes even for a one-line docstring — it gives you room to grow. Describe what the function returns and what it raises, not how it works internally.
Type hints
Python 3 lets you annotate parameters and return values with types. These are hints, not enforced constraints — but they help readers and tools:
def average(numbers: list[float]) -> float:
return sum(numbers) / len(numbers)
list[float] and -> float are pure documentation at runtime. Tools like mypy and the type checker built into many editors use them to catch mismatches. We will use light type hints throughout the rest of the series; you can adopt them at your own pace.
Single responsibility
A function should do one thing well. The clearest test is whether you can describe it in a single sentence without using “and.”
A small validate_password function can stay small:
def validate_password(password: str) -> bool:
"""Return True if the password meets minimum complexity rules."""
if len(password) < 8:
return False
if not any(c.isdigit() for c in password):
return False
if not any(c.isupper() for c in password):
return False
return True
When you find yourself writing a function that loads data, validates it, transforms it, and saves it, that is four functions trying to be one. Split it. The orchestration is its own (very short) function.
def run(input_path, output_path):
data = load(input_path)
validate(data)
result = transform(data)
save(result, output_path)
Each named step now has a place to grow. Each can be tested in isolation.
Calling functions you have already written
Functions can call other functions. This is how programs are built — small named pieces composed into larger ones:
def is_vowel(c: str) -> bool:
return c.lower() in "aeiou"
def count_vowels(text: str) -> int:
return sum(1 for c in text if is_vowel(c))
print(count_vowels("Hello, World!")) # 3
count_vowels reads almost like English because the small helper carries the detail. That is the payoff.
Returning early
When a function has several “this case is handled, done” branches, return as soon as you reach each one. The result is a flat function with no nested blocks:
def classify(score: int) -> str:
if score < 0 or score > 100:
return "invalid"
if score < 50:
return "fail"
if score < 70:
return "pass"
if score < 85:
return "merit"
return "distinction"
Compare this to the deeply nested if/elif/else equivalent — same logic, far worse to read. See Python Conditionals for more on flattening branches.
Try it yourself. Write a function word_count(text: str) -> dict[str, int] that returns a dictionary mapping each word in text to the number of times it appears. Words should be lowercased and split on whitespace. Write a docstring and at least one assertion-style check on the output.
A worked example
A small program that scores a list of essays using several reusable functions:
def word_count(text: str) -> int:
"""Return the number of whitespace-separated words in text."""
return len(text.split())
def has_intro(text: str) -> bool:
"""Return True if text contains an introduction paragraph (>= 40 words before first newline)."""
first_para = text.split("\n", 1)[0]
return word_count(first_para) >= 40
def grade_essay(text: str) -> str:
"""Return a simple grade based on length and structure."""
words = word_count(text)
if words < 100:
return "too short"
if not has_intro(text):
return "missing intro"
if words < 250:
return "C"
if words < 500:
return "B"
return "A"
essays = [
"Short note.",
"A longer essay " * 60, # ~120 words, no intro
("This intro paragraph is forty words long " * 6).strip() + "\n" + ("Body text " * 200),
]
for i, essay in enumerate(essays, start=1):
print(f"Essay {i}: {grade_essay(essay)}")
Each helper does one job. grade_essay orchestrates without doing any low-level work itself. This is the structure to aim for.
Recap
You now know:
def name(params):defines a function; bodies are indentedreturnends the function and produces a value; missingreturnyieldsNone- Parameters are names in the
def; arguments are values at the call site - Python passes by assignment — rebinding doesn’t escape the function, mutation does
- A triple-quoted docstring is the conventional way to document a function
- Type hints document signatures and unlock external checking
- Aim for a single responsibility per function; flatten branches with early returns
Next steps
The next post covers what functions can do beyond simple positional arguments: default values, keyword-only parameters, and arbitrary *args/**kwargs. These are the tools that make Python APIs feel ergonomic.
→ Next: Default and Keyword Arguments in Python
Questions or feedback? Email codeloomdevv@gmail.com.