Skip to main content
Python · lesson 8 of 34
Python beginner Lesson 8 of 34

Python Functions and Parameters

Define Python functions with positional, keyword, default, *args and **kwargs parameters, see the mutable-default trap, lambdas and return values run.

By SkillByExample · Updated

A function names a block of logic so you can call it from many places and change it in one. Output below is from Python 3.13.

Defining Functions

def creates a function; the optional type hints document what it takes and returns.

# functions.py
def calculate_area(width: float, height: float) -> float:
    """Calculate the area of a rectangle.

    The docstring is accessible via help() and shows up in editor tooltips.
    It's the primary place to document what a function does and why.
    """
    return width * height

area = calculate_area(5.0, 10.0)
print(area)
$ python functions.py
50.0

def itself runs nothing — the body executes only when calculate_area(5.0, 10.0) is called, and return hands the product back to the caller, which stores it in area.

Arguments and Parameters

Positional vs Keyword Arguments

Arguments can be matched by position or by parameter name.

# positional_keyword.py
def calculate_area(width: float, height: float) -> float:
    return width * height

print(calculate_area(5.0, 10.0))
print(calculate_area(height=10.0, width=5.0))
print(calculate_area(5.0, height=10.0))
$ python positional_keyword.py
50.0
50.0
50.0

All three calls are identical to Python: keyword arguments are matched by name, so their order doesn’t matter, and you can mix the two as long as positional arguments come first.

Default Parameter Values

A default value makes a parameter optional.

# defaults.py
def greet(name: str, greeting: str = "Hello") -> str:
    """Greet a person, using 'Hello' unless a custom greeting is provided."""
    return f"{greeting}, {name}!"

print(greet("Alice"))
print(greet("Alice", "Hi"))
print(greet("Alice", greeting="Hey"))
$ python defaults.py
Hello, Alice!
Hi, Alice!
Hey, Alice!

The first call fell back to "Hello"; the other two overrode it, by position and by name. Parameters with defaults must come after those without — def greet(greeting="Hello", name) is a SyntaxError.

Variadic Arguments (*args and **kwargs)

*args collects extra positional arguments into a tuple; **kwargs collects extra keyword arguments into a dict.

# variadic.py
def sum_values(*args: float) -> float:
    print("args =", args)
    return sum(args)

print(sum_values(1, 2, 3))
print(sum_values())

def print_profile(**kwargs) -> None:
    print("kwargs =", kwargs)
    for key, value in kwargs.items():
        print(f"  {key}: {value}")

print_profile(name="Alice", age=30, role="admin")

numbers = [4, 5, 6]
print(sum_values(*numbers))          # * at the call site unpacks a list
$ python variadic.py
args = (1, 2, 3)
6
args = ()
0
kwargs = {'name': 'Alice', 'age': 30, 'role': 'admin'}
  name: Alice
  age: 30
  role: admin
args = (4, 5, 6)
15

Calling with no arguments is legal — args is just an empty tuple. The same * symbol means “collect” in a signature and “spread” at a call site, so sum_values(*numbers) passed three separate arguments, not one list.

Positional-only and keyword-only parameters

A bare * in a signature makes everything after it keyword-only; a / makes everything before it positional-only.

# markers.py
def create_user(name: str, age: int, /, *, role: str = "user", active: bool = True) -> dict:
    return {"name": name, "age": age, "role": role, "active": active}

print(create_user("Alice", 30))
print(create_user("Bob", 25, role="admin"))

for call in ('create_user("Carol", 22, "mod")', 'create_user(name="Dan", age=40)'):
    try:
        eval(call)
    except TypeError as e:
        print("TypeError:", e)
$ python markers.py
{'name': 'Alice', 'age': 30, 'role': 'user', 'active': True}
{'name': 'Bob', 'age': 25, 'role': 'admin', 'active': True}
TypeError: create_user() takes 2 positional arguments but 3 were given
TypeError: create_user() got some positional-only arguments passed as keyword arguments: 'name, age'

The * stops a caller from writing create_user("Carol", 22, "mod"), where nobody reading the call can tell what "mod" means. The / does the reverse: the parameter names name and age are private to the function, so they can be renamed later without breaking callers.

The mutable default trap

Default values are evaluated once, when def runs — not on each call.

# mutable_default.py
def add_item(item, basket=[]):
    basket.append(item)
    return basket

print(add_item("apple"))
print(add_item("pear"))

def add_item_fixed(item, basket=None):
    if basket is None:
        basket = []
    basket.append(item)
    return basket

print(add_item_fixed("apple"))
print(add_item_fixed("pear"))
$ python mutable_default.py
['apple']
['apple', 'pear']
['apple']
['pear']

The second call “remembered” the apple because both calls shared the single list created at definition time. Using None as the default and creating the list inside the function gives every call a fresh one.

Lambda Functions

A lambda is an anonymous single-expression function, most useful as a short key= callback.

# lambdas.py
double = lambda x: x * 2
print(double(5), double.__name__)

users = [
    {"name": "Charlie", "age": 35},
    {"name": "alice", "age": 28},
    {"name": "Bob", "age": 32},
]
print([u["name"] for u in sorted(users, key=lambda u: u["age"])])
print(sorted(u["name"] for u in users))
print(sorted((u["name"] for u in users), key=str.lower))
$ python lambdas.py
10 <lambda>
['alice', 'Bob', 'Charlie']
['Bob', 'Charlie', 'alice']
['alice', 'Bob', 'Charlie']

Plain sorted put alice last because uppercase letters sort before lowercase ones in Unicode. Passing key=str.lower fixes that — and shows you often don’t need a lambda at all when an existing function already does the job. A lambda’s __name__ is just <lambda>, which is why named functions read better in tracebacks.

Return Values

A function without a return statement returns None; returning several values actually returns one tuple.

# returns.py
def min_max(numbers: list[float]) -> tuple[float, float]:
    return min(numbers), max(numbers)

result = min_max([3, 1, 4, 1, 5, 9, 2, 6])
print(result, type(result).__name__)
low, high = result
print(f"Min: {low}, Max: {high}")

def log(message: str):
    print("LOG:", message)

print(log("saved"))
$ python returns.py
(1, 9) tuple
Min: 1, Max: 9
LOG: saved
None

min_max returned a single tuple, which the caller unpacked. log printed its message and then implicitly returned None — which the outer print displayed.

Docstrings

A string literal right after def becomes the function’s __doc__, which help() and editors display.

# docstrings.py
def divide(a: float, b: float) -> float:
    """Divide a by b.

    Raises:
        ValueError: If b is zero.
    """
    if b == 0:
        raise ValueError("Cannot divide by zero.")
    return a / b

print(divide.__doc__.splitlines()[0])
print(divide.__annotations__)
try:
    divide(1, 0)
except ValueError as e:
    print("ValueError:", e)
try:
    divide("6", 2)
except TypeError as e:
    print("TypeError:", e)
$ python docstrings.py
Divide a by b.
{'a': <class 'float'>, 'b': <class 'float'>, 'return': <class 'float'>}
ValueError: Cannot divide by zero.
TypeError: unsupported operand type(s) for /: 'str' and 'int'

Type hints are stored in __annotations__ but not enforced — divide("6", 2) got past the call and the zero check, and only failed at the division itself, deep inside the function. Checking them is the job of a tool like mypy (see type hints).

Practice

1. Write create_profile(name, role="user", active=True) that returns a dict, and call it three ways.

Solution
def create_profile(name, role="user", active=True):
    return {"name": name, "role": role, "active": active}

print(create_profile("Alice"))
print(create_profile("Bob", role="admin"))
print(create_profile("Carol", active=False))
$ python profile.py
{'name': 'Alice', 'role': 'user', 'active': True}
{'name': 'Bob', 'role': 'admin', 'active': True}
{'name': 'Carol', 'role': 'user', 'active': False}

Passing active=False by keyword skips over role, which keeps its default.

2. Sort ["elephant", "cat", "dog", "hippopotamus", "ant"] by length, shortest first. Why does "cat" come before "ant"?

Solution
words = ["elephant", "cat", "dog", "hippopotamus", "ant"]
print(sorted(words, key=len))
print(sorted(words, key=lambda w: (len(w), w)))
$ python sort_by_length.py
['cat', 'dog', 'ant', 'elephant', 'hippopotamus']
['ant', 'cat', 'dog', 'elephant', 'hippopotamus']

Python’s sort is stable: words of equal length keep their original order, and cat, dog, ant appear in that order in the input. A tuple key breaks the tie alphabetically.

3. Write add_all(*numbers) that sums any number of arguments, and call it with a list using *.

Solution
def add_all(*numbers):
    total = 0
    for n in numbers:
        total += n
    return total

print(add_all(1, 2))
print(add_all(1, 2, 3, 4, 5))
print(add_all(*[10, 20]))
$ python add_all.py
3
15
30

4. Write make_tag(tag, /, text, **attrs) that returns an HTML string like <a href="https://example.com">home</a>, and show it accepts any attribute name.

Solution
def make_tag(tag, /, text, **attrs):
    attr_str = "".join(f' {k}="{v}"' for k, v in attrs.items())
    return f"<{tag}{attr_str}>{text}</{tag}>"

print(make_tag("a", "home", href="https://example.com"))
print(make_tag("p", text="hi", tag="intro"))
$ python make_tag.py
<a href="https://example.com">home</a>
<p tag="intro">hi</p>

Because tag is positional-only, the keyword tag="intro" doesn’t collide with it and lands in **attrs instead.

Frequently Asked Questions

What are *args and **kwargs?
*args collects any extra positional arguments into a tuple, and **kwargs collects any extra keyword arguments into a dict. At a call site the same symbols do the reverse: f(*items) spreads a list into separate arguments and f(**options) spreads a dict into keyword arguments.
Why shouldn't I use a list as a default argument?
Default values are evaluated once, when the def statement runs, so every call that relies on the default shares the same list object and sees changes made by earlier calls. Use None as the default and create a new list inside the function.
What does a function return if it has no return statement?
It returns None. The same happens with a bare return. Printing the result of such a function, or assigning it, gives None rather than an error, which is a common source of confusion with in-place methods like list.sort().
What do the / and * markers in a function signature mean?
Parameters before a / are positional-only, so callers cannot pass them by name. Parameters after a * (or after *args) are keyword-only, so callers must name them. Both make a function harder to call incorrectly.
Are Python type hints enforced at runtime?
No. Annotations are stored on the function in __annotations__ but Python does not check them when you call it. Static checkers such as mypy or pyright read them to catch type errors before the code runs.