Defining, Calling and Returning
A function is a named block of code you can run whenever you like. def starts the definition, the name follows, then parentheses listing the parameters, then a colon and an indented body. Running the def statement does not execute the body — it creates a function object and attaches the name to it. The body runs only when you call it, and the parentheses are what make the call.
That distinction matters more than it sounds. greet is the function itself; greet() is the result of running it. Forgetting the parentheses is a real bug: if is_valid: where is_valid is a function is always true, because a function object is a non-empty thing. You will meet the useful side of this in Lesson 14, where functions get passed around as values.
return sends a value back and ends the function immediately — any lines after it in that branch never run. A function with no return, or one that reaches the end of its body, returns None. That is why result = my_list.sort() gives you None: sort() changes the list and returns nothing.
Returning a value and printing one are different acts, and confusing them is the most common beginner mistake with functions. A function that prints can only ever put text on a screen. A function that returns can be printed, stored, added to something else, fed into another function, or tested. Return the value; let the caller decide what to do with it.
One habit to start now: a string on the first line of the body is a docstring. It is not a comment — Python stores it, help(function_name) shows it, and editors display it while you type the call. One sentence saying what the function does, in terms of what it returns, is enough.
def greet(name):
"""Return a greeting for the given name."""
return f"Hello, {name}!"
print(greet("Asha")) # Hello, Asha!
print(greet) # <function greet at 0x...> — the object itself
# No return means None
def show(name):
print(f"Hello, {name}!")
result = show("Ravi") # prints
print(result) # None — nothing came back
# return ends the function immediately
def classify(marks):
if marks >= 40:
return "pass"
return "fail" # only reached when the first return did not run
print(classify(78)) # pass
# Returning lets the caller decide what happens next
def average(marks):
"""Return the mean of a non-empty list of marks."""
return sum(marks) / len(marks)
avg = average([78, 84, 91])
print(f"{avg:.2f}") # 84.33
print(average([50, 60]) > avg) # comparisons work — a printed value could not
print(average.__doc__) # Return the mean of a non-empty list of marks. def name(params):— creates the function; the body runs only when callednameis the function object;name()calls itreturn value— sends a value back and exits immediately- No
returnmeans the function returnsNone return a, breturns a tuple, which the caller can unpack- A docstring on the first line documents the function for
help()and your editor
- Function names use
snake_caseand should usually be verb phrases —calculate_total,load_students,is_eligible. A name starting withis_orhas_signals that the function returns a boolean, which saves the reader a trip to the definition.
Positional, Keyword and Default Arguments
Arguments can be passed by position or by name. By position, order is everything: divide(10, 2) and divide(2, 10) are different calls. By name — divide(numerator=10, denominator=2) — order stops mattering and the call documents itself.
Keyword arguments earn their keep with boolean flags and options. send_report(data, True, False) tells the reader nothing; send_report(data, include_charts=True, compress=False) tells them everything. The rule when mixing the two forms is that every positional argument must come before every keyword argument; the other way round is a SyntaxError.
A default value makes a parameter optional: def greet(name, title="Mr") can be called with one argument or two. Parameters with defaults must come after those without, because otherwise Python could not tell which value belonged to which parameter. Defaults are how a function grows new options without breaking every existing call.
You can also force a parameter to be passed by name. A bare * in the parameter list means "everything after this is keyword-only", which is worth doing for flags and rarely-used options. It stops callers writing process(data, True, True, False) and guarantees that adding a new option later cannot silently change the meaning of an existing call.
def divide(numerator, denominator):
return numerator / denominator
print(divide(10, 2)) # 5.0 — by position
print(divide(denominator=2, numerator=10)) # 5.0 — by name, any order
# Positional arguments must come first
# print(divide(numerator=10, 2)) # SyntaxError: positional argument
# follows keyword argument
# Defaults make a parameter optional
def greet(name, title="Mr", punctuation="!"):
return f"Hello, {title} {name}{punctuation}"
print(greet("Rao")) # Hello, Mr Rao!
print(greet("Rao", "Dr")) # Hello, Dr Rao!
print(greet("Rao", punctuation=".")) # skip the middle one by naming
# Defaults must come last
# def bad(title="Mr", name): # SyntaxError: non-default argument follows
# default argument
# Keyword-only parameters: everything after the bare *
def export(data, *, compress=False, overwrite=False):
return f"{len(data)} rows, compress={compress}, overwrite={overwrite}"
print(export([1, 2, 3], compress=True))
# print(export([1, 2, 3], True)) # TypeError: takes 1 positional argument
# but 2 were given - Positional: order decides which parameter gets which value
- Keyword:
name=value; order stops mattering and the call self-documents - Positional arguments must all appear before keyword arguments
param=defaultmakes it optional; defaults must come after non-defaults- A bare
*in the signature makes everything after it keyword-only
- Two calls with three or more positional arguments are hard to compare at a glance. A good rule is that anything a reader could not name from the call site — a bare True, a bare number, a magic string — is better passed by keyword.
The Mutable Default Argument Trap
This one deserves a section of its own, because it is the single most famous Python gotcha and it turns up in interviews as often as it turns up in code. Write def add_task(task, tasks=[]), call it twice without the second argument, and the second call sees the first call's data.
The reason is that the default value is evaluated once, when the def statement runs — not each time the function is called. That empty list is created at definition time and stored on the function object, so every call that relies on the default shares the same list. Appending to it changes the default for every future call, permanently, for as long as the program runs.
You can see the shared object directly: add_task.__defaults__ holds it, and it visibly grows. Nothing is broken and no error is raised, which is precisely why the bug survives testing — it only shows up on the second call.
The fix is a standard idiom. Use None as the default and create the real value inside the function body, which does run on every call. Write it as if tasks is None: tasks = []. Since None is a value nobody would pass on purpose, it works as a clear signal that means "nothing was supplied".
The same trap catches any mutable default — lists, dictionaries and sets — and it catches anything computed at definition time. def log(message, when=datetime.now()) does not stamp the current time; it stamps the moment the module was imported, and every log line for the rest of the day carries that same timestamp. Immutable defaults such as numbers, strings, True and None are always safe, because there is nothing to change.
# The bug
def add_task(task, tasks=[]):
tasks.append(task)
return tasks
print(add_task("buy notebook")) # ['buy notebook']
print(add_task("submit lab")) # ['buy notebook', 'submit lab'] <- surprise
print(add_task("revise")) # all three, in one list
# The shared list is visible on the function object
print(add_task.__defaults__) # (['buy notebook', 'submit lab', 'revise'],)
# The fix
def add_task_safe(task, tasks=None):
if tasks is None:
tasks = [] # a NEW list on every call that needs one
tasks.append(task)
return tasks
print(add_task_safe("buy notebook")) # ['buy notebook']
print(add_task_safe("submit lab")) # ['submit lab']
# ...and passing your own list still works
mine = ["read notes"]
print(add_task_safe("revise", mine)) # ['read notes', 'revise']
# The same trap, in a form that looks harmless
from datetime import datetime
def log(message, when=datetime.now()): # frozen at import time
return f"[{when}] {message}"
def log_ok(message, when=None):
if when is None:
when = datetime.now() # evaluated per call
return f"[{when}] {message}" - Immutable defaults are safe:
def f(x=0),def f(s=""),def f(flag=False)anddef f(x=None)all behave as you expect, because nothing about them can be changed in place.
*args, **kwargs and Unpacking at the Call Site
Sometimes a function should accept however many arguments the caller has. *args in the parameter list collects any extra positional arguments into a tuple. **kwargs collects any extra keyword arguments into a dictionary, keyed by the names used at the call site. The names args and kwargs are convention only — the * and ** are what matter — and a more descriptive name such as *marks or **options is often better.
The parameter order is fixed and worth memorising: ordinary parameters, then *args, then keyword-only parameters, then **kwargs. Anything appearing after *args automatically becomes keyword-only, since the star has already swallowed every remaining positional argument.
The same two symbols work in the other direction, at the point of the call, and this is the half people forget. f(*items) spreads a list into separate positional arguments, and f(**settings) spreads a dictionary into keyword arguments. That is how you pass a list of values to a function expecting several parameters, and how you build a call's options in a dictionary before making it.
The most common real use of **kwargs is passing options through to another function without listing them all. A wrapper takes **kwargs and hands them straight on, so it keeps working when the inner function gains a new option. The cost is that the wrapper's signature no longer documents what it accepts — so use it where the pass-through is the point, not as a way to avoid naming parameters.
def total(*marks):
print(type(marks)) # <class 'tuple'>
return sum(marks)
print(total(78, 84, 91)) # 253
print(total()) # 0 — zero arguments is fine
def describe(**options):
print(type(options)) # <class 'dict'>
for key, value in options.items():
print(f"{key}: {value}")
describe(name="Asha", branch="CSE")
# The fixed order: normal, *args, keyword-only, **kwargs
def report(title, *rows, separator=" | ", **meta):
print(title)
for row in rows:
print(separator.join(str(c) for c in row))
print(meta)
report("Marks", ("Asha", 87), ("Ravi", 92), separator=" - ", term="Aug 2026")
# Unpacking at the CALL site — the same symbols, the other way round
def make_row(name, marks, branch):
return f"{name} ({branch}): {marks}"
values = ["Asha", 87, "CSE"]
print(make_row(*values)) # spreads the list into three arguments
fields = {"name": "Ravi", "marks": 92, "branch": "ECE"}
print(make_row(**fields)) # spreads the dict into keyword arguments
# Passing options straight through
def save(data, **file_options):
return open("out.txt", **file_options) # whatever the caller asked for *args— extra positional arguments, collected into a tuple**kwargs— extra keyword arguments, collected into a dictionary- Signature order: normal,
*args, keyword-only,**kwargs f(*list)at the call site spreads a list into positional argumentsf(**dict)at the call site spreads a dictionary into keyword arguments- The star is the syntax;
argsandkwargsare just conventional names
print(*marks)uses exactly this feature: it turns a list into separate arguments soprintshows87 92 78rather than[87, 92, 78].
Scope, References, and Why global Is a Smell
A name created inside a function is local to it: it exists while the function runs and disappears afterwards. Two functions can both use a variable called total without interfering. When Python meets a name, it looks in the local scope first, then in any enclosing function, then at module level, then among the built-ins.
Reading a module-level variable from inside a function works without ceremony. Assigning to it does not — an assignment anywhere in the body makes that name local for the whole function, so reading it before that line raises UnboundLocalError. The error message often confuses people because the variable clearly does exist outside; the point is that the function has its own version and it has not been set yet.
The global keyword suppresses that rule and lets a function rebind a module-level name. It works, and it is almost always the wrong choice. A function that changes hidden state is a function you cannot test in isolation, cannot call twice safely, and cannot read without also reading everything else that touches the same variable. Take the value in as a parameter and hand the new value back with return — the function then says everything it does in its own signature.
There is a subtlety that catches people between these two ideas. Arguments are passed as references to objects. If you rebind a parameter inside a function — items = [9] — you have only pointed the local name somewhere else and the caller sees nothing. If you mutate the object — items.append(9) — the caller's data changes, no global required. With immutable arguments like numbers and strings, mutation is impossible, so they always behave as if copied.
That is a tool as much as a trap. Modifying a caller's list is fine when the function is documented as doing so and named accordingly. It is a problem when it happens as a side effect of something that sounded like a calculation. If in doubt, work on a copy and return the result.
total = 0 # module level
def show_total():
print(total) # reading is fine
def bump_broken():
total += 1 # UnboundLocalError: local variable 'total'
# referenced before assignment
def bump_global():
global total # works, and hides what the function does
total += 1
def bump(current): # better: value in, value out
return current + 1
total = bump(total)
print(total) # 1
# Rebinding a parameter does not affect the caller
def replace(items):
items = [9] # a new local name only
marks = [1, 2, 3]
replace(marks)
print(marks) # [1, 2, 3]
# Mutating the object does affect the caller
def add_bonus(items):
items.append(5) # same object the caller holds
add_bonus(marks)
print(marks) # [1, 2, 3, 5]
# Work on a copy when the caller's data should be left alone
def with_bonus(items):
result = items.copy()
result.append(5)
return result
print(with_bonus([1, 2]), "original untouched") nonlocalis the equivalent ofglobalfor a name in an enclosing function rather than at module level. You will meet it if you write closures; the same advice applies — prefer passing values in and returning them out.
Writing Functions Worth Reading
A function should do one job that you can name. If describing it needs the word "and", it is probably two functions. That is not a style rule for its own sake: a single-job function can be tested on its own, reused somewhere unexpected, and understood without reading the rest of the file.
Handle the failure cases first and get them out of the way. Checking the bad inputs at the top and returning early — the guard clause pattern — keeps the real work at one level of indentation instead of buried inside three nested if blocks. It also puts the preconditions where a reader looks first.
Type hints record what a function expects and returns. def average(marks: list[float]) -> float: is the same function with the shapes written down. Python does not enforce them at runtime — passing a string still runs until something breaks — but your editor uses them to catch mistakes as you type, and they double as documentation that cannot drift out of date as easily as a comment. Add them to anything another person will call; skip them on three-line helpers if they add more noise than signal.
Two smaller habits. Keep functions short enough to see at once — if you are scrolling, look for a piece that wants extracting. And prefer returning a value over printing one, with the printing done by the code that called you. That single split is what lets the same function serve a command-line script, a web request and a test.
Finally, remember that a function definition is a statement like any other. It runs when the file runs, so a function must be defined before the line that calls it. That is why scripts tend to put definitions at the top and the code that uses them at the bottom, often guarded by if __name__ == "__main__": so the file can be imported without running its demo.
def calculate_grade(marks: int) -> str:
"""Return the letter grade for a mark out of 100.
Raises ValueError if marks is outside 0-100.
"""
# Guard clauses first — failures handled and out of the way
if not isinstance(marks, int):
raise TypeError("marks must be a whole number")
if not 0 <= marks <= 100:
raise ValueError(f"marks out of range: {marks}")
# The real work, unindented
if marks >= 90:
return "A"
if marks >= 80:
return "B"
if marks >= 40:
return "C"
return "F"
def summarise(marks: list[int]) -> dict:
"""Return count, average and highest for a list of marks."""
return {
"count": len(marks),
"average": sum(marks) / len(marks),
"highest": max(marks),
}
# The caller decides what to display
if __name__ == "__main__":
data = [78, 84, 91]
stats = summarise(data)
print(f"{stats['count']} students, average {stats['average']:.1f}")
print(calculate_grade(stats["highest"])) # A - One job per function; if the description needs "and", split it
- Guard clauses at the top; the main path stays unindented
- Type hints document the shapes and help your editor; they are not enforced
- Return values, do not print them — the caller decides what to display
- Definitions run top to bottom, so define before you call
if __name__ == "__main__":lets a file be imported without running its demo
list[int]as a hint requires Python 3.9 or newer. On older versions you importListfrom thetypingmodule and writeList[int]instead.
