Python Functions Tutorial: Arguments, Scope, and Return Values
How Python functions really work: argument styles, default values, scope and closures, and return values, with the pitfalls that bite in production.
A function adds a tag to a list and returns the list. The first call returns ['urgent']. The second call, with a different tag and no list passed, returns ['urgent', 'billing']. Nobody passed 'urgent' the second time. This bug has shipped to production in countless codebases, and it comes from one misunderstood rule about default values.
Python functions are named blocks of code that accept inputs, called arguments, and hand back a result with return. They are also ordinary objects: you can store them in variables, pass them to other functions, and return them.
This guide uses Python 3.10 and only the standard library. Run python --version to confirm your interpreter. Some examples use lists and dictionaries, and you can read more about lists, tuples, sets, and dictionaries if those are new to you. You do not need to install anything, although you should set up a virtual environment before you add third-party packages to a real project.
My position: a function’s signature matters more than its body. A clear signature prevents wrong calls before they happen, and Python gives you precise tools to write one.
Define and call Python functions
You define a function with def, a name, a parameter list, and an indented body. You call it with parentheses.
def greet(name: str) -> str:
"""Return a greeting for one person."""
return f'Hello, {name}'
message = greet('Asha')
print(message)
Hello, Asha
Two terms get mixed up constantly. A parameter is the name in the definition, here name. An argument is the value you pass in a call, here 'Asha'. The annotations : str and -> str are type hints. They document intent for readers and tools, and Python does not enforce them at runtime.
The string on the first line of the body is a docstring. Tools such as help(greet) display it, so write one for any function other people will call.
Arguments: positional, keyword, and defaults
You can pass an argument by position or by name. Parameters with a default value become optional.
def connect(host: str, port: int = 5432, timeout: float = 10.0) -> str:
return f'{host}:{port} (timeout {timeout}s)'
print(connect('db.example.com')) # defaults used
print(connect('db.example.com', 6432)) # by position
print(connect('db.example.com', timeout=2.5)) # by keyword, skipping port
db.example.com:5432 (timeout 10.0s)
db.example.com:6432 (timeout 10.0s)
db.example.com:5432 (timeout 2.5s)
Positional arguments must come before keyword arguments in a call. Keyword arguments cost a few extra characters, but they make the call site read like documentation. Consequently, prefer them whenever the meaning of a value is not obvious from its position.
Force the calling style with / and *
By default, callers may pass any parameter either way. Two markers in the signature let you restrict that choice. Parameters before / are positional-only. Parameters after * are keyword-only.
def f(pos_only, /, standard, *, kw_only):
| | |
| | must be passed by name
| by position or by name
must be passed by position
Here is a complete, runnable example. Save it as pricing.py.
def apply_discount(
price: float, /, percent: float = 0.0, *, minimum: float = 0.0
) -> float:
"""Return price reduced by percent, never below minimum."""
if not 0 <= percent <= 100:
raise ValueError(f'percent must be between 0 and 100, got {percent}')
discounted = price * (1 - percent / 100)
return round(max(discounted, minimum), 2)
def total(*prices: float, **options: float) -> float:
"""Sum any number of prices, applying the same discount options to each."""
return round(sum(apply_discount(price, **options) for price in prices), 2)
def main() -> None:
print(apply_discount(80.0))
print(apply_discount(80.0, 25))
print(apply_discount(80.0, percent=90, minimum=10.0))
print(total(80.0, 20.0, percent=50))
if __name__ == '__main__':
main()
python pricing.py
80.0
60.0
10.0
50.0
The markers turn wrong calls into immediate errors. Passing the price by name fails:
TypeError: apply_discount() got some positional-only arguments passed as keyword arguments: 'price'
Passing the minimum by position fails too:
TypeError: apply_discount() takes from 1 to 2 positional arguments but 3 were given
Positional-only parameters let you rename a parameter later without breaking callers. Keyword-only parameters stop callers from writing an unreadable call such as apply_discount(80.0, 90, 10.0). The trade-off is a stricter API, which is harder to loosen by accident and slightly more verbose to call.
Accept any number of arguments with *args and **kwargs
A parameter prefixed with * collects extra positional arguments into a tuple. A parameter prefixed with ** collects extra keyword arguments into a dictionary. The names args and kwargs are conventions, and the total function above uses clearer ones: *prices and **options.
The same symbols work in reverse at a call site, where they unpack a container into arguments:
def describe(name: str, role: str, team: str) -> str:
return f'{name} is a {role} on {team}'
fields = ('Asha', 'developer', 'payments')
record = {'name': 'Ben', 'role': 'tester', 'team': 'search'}
print(describe(*fields)) # tuple unpacked into positions
print(describe(**record)) # dict unpacked into keywords
Asha is a developer on payments
Ben is a tester on search
Use these forms when the function truly accepts a variable number of inputs, or when it forwards arguments to another function. However, they hide the real signature from readers, editors, and type checkers. A typo such as percnt=50 travels through **options and fails far from where you wrote it.
The mutable default argument trap
Python evaluates a default value once, when the def statement runs. It does not evaluate the default again on each call. If the default is a mutable object such as a list, every call shares that one object.
# WRONG: one list is created at definition time and reused forever
def add_tag(tag: str, tags: list = []) -> list:
tags.append(tag)
return tags
print(add_tag('urgent'))
print(add_tag('billing'))
['urgent']
['urgent', 'billing']
# RIGHT: use None as the default and create the list inside the call
def add_tag(tag: str, tags: list | None = None) -> list:
if tags is None:
tags = []
tags.append(tag)
return tags
print(add_tag('urgent'))
print(add_tag('billing'))
['urgent']
['billing']
The fix moves the creation of the list from definition time to call time. You can watch the rule in action with a timestamp. This is my own illustrative test:
import time
def stamp(now: float = time.time()) -> float:
return now
if __name__ == '__main__':
first = stamp()
time.sleep(1)
second = stamp()
print(first == second)
True
Both calls return the same time, because time.time() ran once, when Python defined the function. The same rule explains why def log(when=datetime.now()) stamps every record with the moment the program started.
The myth: Python passes arguments by value, or by reference
Developers arriving from other languages argue about whether Python passes arguments by value or by reference. Neither label fits. Python passes a reference to the same object, and the parameter becomes a new local name for it. The usual term is “call by sharing”.
Two consequences follow, and both surprise people:
def append_item(items: list) -> None:
items.append('added') # changes the caller's object
def replace_items(items: list) -> None:
items = ['replaced'] # rebinds the local name only
basket = ['original']
append_item(basket)
print(basket)
replace_items(basket)
print(basket)
['original', 'added']
['original', 'added']
Mutating the object through the parameter affects the caller, because both names point at one list. Assigning to the parameter does not, because assignment only rebinds the local name. Therefore, a function that changes its argument in place should say so in its name or docstring, and should usually return None.
Scope: where Python looks up a name
Scope decides which variable a name refers to. Python searches four places in order, known as the LEGB rule.
B Built-in names such as len, print, ValueError
G Global names defined at the top level of the module
E Enclosing names in any outer function
L Local names assigned inside the current function
Lookup order: L, then E, then G, then B. The first match wins.
Reading a global variable inside a function works without any keyword. Assigning to it is different. Any assignment inside a function makes that name local for the whole function body.
# WRONG: the assignment makes counter local, so the read fails
counter = 0
def increment() -> None:
counter += 1
increment()
UnboundLocalError: local variable 'counter' referenced before assignment
# RIGHT: take the value in and hand the new value back
def increment(counter: int) -> int:
return counter + 1
counter = 0
counter = increment(counter)
print(counter)
You could also fix the first version with a global counter statement. However, global state makes functions hard to test and unsafe to call from several places. Passing values in and returning results is almost always the better design.
Closures and nonlocal
A function defined inside another function can use the outer function’s variables, even after the outer function has returned. That combination of a function and its remembered variables is a closure. To assign to an enclosing variable, declare it nonlocal.
def make_counter():
count = 0
def increment() -> int:
nonlocal count
count += 1
return count
return increment
next_id = make_counter()
print(next_id(), next_id(), next_id())
1 2 3
Closures look up variables when the inner function runs, not when you define it. This late binding causes a well-known bug in loops:
# WRONG: every function sees the final value of i
callbacks = [lambda: i for i in range(3)]
print([callback() for callback in callbacks]) # [2, 2, 2]
# RIGHT: a default argument captures the value at definition time
callbacks = [lambda i=i: i for i in range(3)]
print([callback() for callback in callbacks]) # [0, 1, 2]
Here is the connection the documentation does not draw for you. The mutable default trap and the loop closure fix are the same rule seen from two sides. Defaults bind when def runs, and closure variables bind when the function is called. Once you remember which moment each one uses, both behaviors stop being surprises.
Return values
Every function returns something. A function without a return statement, or with a bare return, hands back None.
def parse_range(text: str) -> tuple[int, int]:
low, high = text.split('-')
return int(low), int(high) # one tuple, two values
start, end = parse_range('10-25')
print(start, end)
10 25
Returning several values means returning one tuple, which the caller unpacks. Early returns also keep code flat: check for invalid input first, return or raise, and leave the main path unindented.
Keep the return type consistent. A function that returns a list on success and False on failure forces every caller to check the type. Instead, return an empty list when “nothing found” is a normal result, and raise an exception when something went wrong.
Functions are objects
You can pass a function as an argument, without parentheses, wherever a callable is expected. The standard library relies on this heavily.
from functools import partial
employees = [('Asha', 41), ('Ben', 29), ('Chen', 35)]
def by_age(employee: tuple[str, int]) -> int:
return employee[1]
print(sorted(employees, key=by_age))
parse_binary = partial(int, base=2) # a new function with base fixed
print(parse_binary('1010'))
[('Ben', 29), ('Chen', 35), ('Asha', 41)]
10
The key parameter receives the function itself, and sorted calls it once per item. functools.partial builds a new function with some arguments already filled in. A lambda is a one-expression function without a name, which suits short keys. For anything longer, a named def gives you a better traceback.
How real systems structure function signatures
Mature Python codebases converge on a few habits. You can see them throughout the standard library and popular frameworks.
- Options are keyword-only. Look at
sorted(iterable, /, *, key=None, reverse=False). You cannot passreverseby position, so every call site says what the flag means. - Pure functions at the core. Business rules live in functions that take values and return values, with no file, network, or global access. Tests then need no setup.
- Side effects at the edges. A thin outer layer reads input, calls the pure functions, and writes output. This is why scripts keep a small
main()behindif __name__ == '__main__':. - None as the sentinel default. Library code uses
Nonefor “not provided” and builds lists or dictionaries inside the function. - Explicit forwarding only in wrappers.
*argsand**kwargsappear mainly in decorators and adapters that pass arguments through unchanged.
A mistake I have seen in production is a helper declared as def build_query(filters={}) in a web service. Each request added keys to the shared dictionary, so one customer’s filters leaked into the next customer’s query. It passed every unit test, because each test ran in a fresh process. We found it only by logging the dictionary’s id() across requests.
Designing a signature: a decision framework
Work through these questions for each parameter, in order.
- Is the argument’s meaning obvious from the function name? Keep it positional, as in
len(items)orapply_discount(price). Add/if the parameter name carries no meaning for callers. - Is it a flag, an option, or one of several values of the same type? Make it keyword-only by placing it after
*. - Does it have a sensible default? Provide one, and use
Noneif the default would be a list, dictionary, set, or a value computed at call time. - Does the function accept an open-ended number of similar inputs? Use
*argswith a descriptive name, or accept a single list if callers usually have one already. - Do you have more than five parameters? Group related ones into a dictionary or a small class, or split the function.
When NOT to write a plain function
A function is the right default unit of code, but three situations call for something else.
- When several functions share the same state. If you pass the same five arguments to a family of functions, or reach for
global, that state belongs in a class. - When the result is a large sequence you consume once. Building and returning a list of ten million items wastes memory. A generator function, which uses
yield, produces items one at a time. - When the wrapper adds nothing. A function such as
def get_length(x): return len(x)gives readers one more name to look up. Call the original directly.
Common mistakes
- Using a mutable default argument. A default list or dictionary is shared by every call. Data from one call leaks into the next.
- Forgetting the return statement. The function computes a result and discards it, so the caller receives
None. The error appears later asTypeError: 'NoneType' object is not subscriptable. - Calling the function when you meant to pass it. Writing
sorted(items, key=by_age())runs the function immediately. You get aTypeErrorabout a missing argument. - Assigning to a global without declaring it. The assignment creates a local name, and reading it first raises
UnboundLocalError. - Shadowing a built-in name. A parameter called
list,id, ortypehides the built-in inside the function. A later call tolist(...)fails with a confusing error. - Returning different types from one function. Mixing a value,
None, andFalsepushes type checks onto every caller, and one of them will forget.
Key takeaways
- Treat the signature as an API: required inputs positional, options keyword-only.
- Use
/for positional-only parameters and*for keyword-only parameters. - Never use a list, dictionary, or set as a default. Use
Noneand create the object inside the function. - Python passes object references: mutation reaches the caller, and reassignment does not.
- Name lookup follows Local, Enclosing, Global, Built-in, and any assignment makes a name local.
- Prefer passing values in and returning results over
globalandnonlocal. - Return one consistent type, and raise an exception for failures.
FAQ
What is the difference between *args and **kwargs in Python?
*args collects extra positional arguments into a tuple. **kwargs collects extra keyword arguments into a dictionary. The names are conventions, and only the asterisks matter.
What is the difference between a parameter and an argument?
A parameter is the name listed in the function definition. An argument is the actual value you pass when you call the function.
Can a Python function return multiple values?
Yes. Write return a, b, which returns one tuple. The caller can unpack it with x, y = func().
Why should I avoid mutable default arguments in Python?
Python creates a default value once, when it defines the function. A mutable default such as a list is therefore shared by every call, so changes from one call appear in the next.
What does a Python function return if there is no return statement?
It returns None. The same happens with a bare return that has no value after it.
Design the signature before you write the body
Most function bugs are call-site bugs: a swapped argument, a shared default, or a silent None. Python’s markers and scope rules let you rule those out in the definition itself. Spend a minute on the signature, and the body tends to follow.
Rule of thumb: values in, values out, options by name, and nothing mutable in the defaults.
