Skip to content

Python lesson 8 of 9

Python Functions: Defining, Calling, and Returning

Learn Python functions from scratch - def syntax, parameters versus arguments, return values, keyword and default arguments, *args, **kwargs, and scope.

Published · Every example on this page was run before it was published.

By now you can store values, make decisions, and repeat work with a loop. The next problem you will run into is not about any single feature — it is about size. As a program grows, the same handful of lines keeps reappearing in slightly different places, and one long script becomes impossible to hold in your head. A function solves both problems at once: it lets you give a name to a piece of behaviour, write that behaviour exactly once, and then use it as often as you like just by saying its name.

What a Function Is

A function is a named block of code that only runs when you ask it to. You write it once, under a name you choose, and from then on that name stands for the whole block.

Think of the buttons on a microwave. "Popcorn" is a single word printed on a key, but behind it sits a fixed sequence of steps: run at this power level, for this long, then beep. Nobody pressing the button needs to know those steps, and nobody has to re-enter them for every bag. The button names the behaviour, and pressing it runs the behaviour. Functions work the same way — def builds the button, and using the name presses it.

Here is the smallest useful example:

Python
def greet():
    print("Hello there!")
    print("Welcome to the lesson.")

greet()
greet()
Output
Hello there!
Welcome to the lesson.
Hello there!
Welcome to the lesson.

Four pieces make up that definition. The keyword def tells Python a function is being defined. greet is the function name, which follows the same rules as a variable name — lowercase words joined by underscores is the usual Python style, so send_email rather than SendEmail. The empty parentheses will soon hold inputs. The colon ends the header line, and the indented lines beneath it are the function body, the code that belongs to the function.

Writing greet() on its own line is a function call: the parentheses are the instruction to actually run the body. Leave them off and nothing happens, because the bare name greet just refers to the function without running it.

Defining and calling are genuinely separate events, and it is worth seeing that clearly:

Python
def announce():
    print("The function body ran.")

print("Before the call")
announce()
print("After the call")
Output
Before the call
The function body ran.
After the call

Python read the def block first, but it did not execute the print inside it. It simply stored the body under the name announce and moved on. Only the call line released it. This is why a function must be defined above the line that first calls it — Python has to have met the name already.

Parameters and Arguments

A function that always does exactly the same thing is limited. Most functions need an input, and that is what the parentheses are for.

A parameter is a name listed in the def line; it behaves like a variable that exists inside the function. An argument is the actual value you hand over when you call the function. The parameter is the labelled slot, the argument is what you drop into it. Beginners often mix the two words up, and now you have a way to keep them apart: parameters are written once, arguments are supplied on every call.

Python
def greet(name):
    print("Hello, " + name + "!")

greet("Ana")
greet("Ben")
Output
Hello, Ana!
Hello, Ben!

One definition, two calls, two different results. On the first call the parameter name holds "Ana"; on the second it holds "Ben". Each call starts fresh.

A function can take several parameters, separated by commas. By default Python matches arguments to parameters strictly by position — first to first, second to second:

Python
def introduce(name, city):
    print(name, "lives in", city)

introduce("Ana", "Lisbon")
introduce("Lisbon", "Ana")
Output
Ana lives in Lisbon
Lisbon lives in Ana

The second call is not an error as far as Python is concerned. It filled the slots in the order it was given them and printed a perfectly formed sentence that happens to be nonsense. Arguments matched this way are called positional arguments, and their order is your responsibility.

Sending a Value Back with return

Printing shows a result to a human. It does not give the result back to the rest of your program. For that you need return, which hands a value out of the function to whoever called it.

Python
def area_of_rectangle(width, height):
    return width * height

small = area_of_rectangle(3, 4)
large = area_of_rectangle(10, 7)

print(small)
print(large)
print(small + large)
Output
12
70
82

The call area_of_rectangle(3, 4) does not just run — it becomes the value 12, so it can be stored in a variable, added to something else, or passed straight into another function. That is what makes functions composable.

A function with no return statement still produces a value: Python gives back None, a built-in value that means "nothing here". This surprises nearly everyone once:

Python
def double(number):
    print(number * 2)

answer = double(21)
print("The variable holds:", answer)
Output
42
The variable holds: None

The 42 appeared because the function printed it. The variable is empty because nothing was returned. Swap print for return and the value survives the call:

Python
def double(number):
    return number * 2

answer = double(21)
print("The variable holds:", answer)
print(double(5) + double(10))
Output
The variable holds: 42
30

return also ends the function immediately — any lines after it are skipped. That makes it a neat way to leave early as soon as an answer is known:

Python
def first_negative(numbers):
    for number in numbers:
        if number < 0:
            return number
    return None

print(first_negative([4, 7, -2, 9, -8]))
print(first_negative([1, 2, 3]))
Output
-2
None

The loop stops the instant it meets -2; the -8 further along is never even looked at. If the loop finishes without finding anything, control reaches the final line and None is returned on purpose. A function may contain as many return statements as it needs, but only one of them ever runs per call.

Keyword Arguments and Default Values

You can also pass an argument by naming the parameter it belongs to. That is a keyword argument, and because the name says where the value goes, order stops mattering:

Python
def book_room(guest, nights, city):
    print(guest, "is staying", nights, "nights in", city)

book_room("Ana", 3, "Lisbon")
book_room(nights=3, city="Lisbon", guest="Ana")
book_room("Ana", city="Lisbon", nights=3)
Output
Ana is staying 3 nights in Lisbon
Ana is staying 3 nights in Lisbon
Ana is staying 3 nights in Lisbon

All three calls are identical in effect. The one rule to remember is that positional arguments must come before keyword arguments in a call — once you start naming, you cannot go back to relying on position. Keyword arguments are worth using whenever a call would otherwise be a row of mystery values; send_alert(True, False, True) tells a reader nothing, while send_alert(urgent=True, retry=False) explains itself.

A parameter can also carry a default value, used whenever the caller does not supply that argument:

Python
def order_pizza(size, topping="cheese", extra_sauce=False):
    print("A", size, "pizza with", topping)
    if extra_sauce:
        print("...and extra sauce")

order_pizza("large")
order_pizza("small", "mushroom")
order_pizza("medium", extra_sauce=True)
Output
A large pizza with cheese
A small pizza with mushroom
A medium pizza with cheese
...and extra sauce

size has no default, so it is required. topping and extra_sauce are optional. Notice the third call: it skips topping entirely and jumps straight to extra_sauce by name, which is only possible because keyword arguments exist. Defaults let one function serve the common case in a single word while still allowing full control when you want it.

Parameters with defaults must be listed after all the parameters without them. Writing def order_pizza(topping="cheese", size) is a syntax error, because Python could never work out which slot a lone positional argument was meant to fill.

Accepting Any Number of Arguments

Sometimes you cannot know in advance how many values a caller will have. Putting a single * in front of a parameter name tells Python to sweep up every leftover positional argument into that one name. The name args is only a convention; the star is what does the work.

The collected values arrive as a tuple, which is an ordered sequence much like a list, except that it cannot be changed after it is built. Tuples print with round brackets:

Python
def total(*numbers):
    print("Received:", numbers)
    running = 0
    for number in numbers:
        running += number
    return running

print(total(1, 2, 3))
print(total(10, 20))
print(total())
Output
Received: (1, 2, 3)
6
Received: (10, 20)
30
Received: ()
0

Three calls with three different argument counts, and one definition handled all of them — including the call with no arguments at all, where numbers is simply an empty tuple.

Two stars do the same trick for keyword arguments. Every keyword argument that has no matching parameter is collected into a dictionary, a collection that stores values under names rather than positions:

Python
def describe(**details):
    print("Got", len(details), "details")
    for key, value in details.items():
        print("-", key, "=", value)

describe(color="blue", size="medium")
describe()
Output
Got 2 details
- color = blue
- size = medium
Got 0 details

details.items() walks the dictionary and hands you each name-and-value pair in turn. The pairs come back in the order they were written.

Ordinary parameters, *args, and **kwargs can all appear in one definition, and they must be written in that order:

Python
def log_event(event, *tags, **fields):
    print("Event:", event)
    print("Tags:", tags)
    print("Fields:", fields)

log_event("login", "web", "mobile", user="ana", ok=True)
Output
Event: login
Tags: ('web', 'mobile')
Fields: {'user': 'ana', 'ok': True}

The first positional argument filled event because that parameter came first. Everything positional after it landed in tags, and every named argument landed in fields.

Local and Global Scope

Scope is the region of a program where a particular name can be seen. Every function call creates a fresh private workspace, and any variable first assigned inside the function lives only in that workspace. Such a variable is local: it is created when the call starts and thrown away when the call ends. A variable defined at the top level of your file, outside every function, is global and can be read from anywhere.

Because the two live in different places, the same word can mean different things in each:

Python
message = "I am global"

def show():
    message = "I am local"
    print("Inside:", message)

show()
print("Outside:", message)
Output
Inside: I am local
Outside: I am global

Assigning to message inside show did not touch the global one. Python saw an assignment in the function body and immediately treated that name as local for the whole function. The global message was never in danger — and that isolation is a feature, because it means you can name a loop counter i inside a function without wondering whether some distant part of the program also uses i.

Reading a global is allowed without any special syntax, which is how constants are usually shared:

Python
TAX_RATE = 0.2

def price_with_tax(price):
    return price + price * TAX_RATE

print(price_with_tax(100))
print(price_with_tax(50))
Output
120.0
60.0

Python does offer a global keyword that lets a function reassign a global variable, but reach for it rarely. A function that quietly rewires values elsewhere in the program is hard to test and harder to debug, because reading the call tells you nothing about what it changed. Take what you need through parameters, hand the result back with return, and the function stays self-contained.

Docstrings: Explaining a Function to People

A docstring is a string written as the very first line of a function body, wrapped in triple quotes. Python stores it on the function itself, so tools and editors can show it to whoever is about to use your code. A good one says what the function does, what it expects, and what it gives back.

Python
def celsius_to_fahrenheit(celsius):
    """Convert a Celsius temperature to Fahrenheit.

    celsius: a temperature in degrees Celsius.
    Returns the same temperature in degrees Fahrenheit.
    """
    return celsius * 9 / 5 + 32

print(celsius_to_fahrenheit(100))
print(celsius_to_fahrenheit(0))
print(celsius_to_fahrenheit.__doc__.splitlines()[0])
Output
212.0
32.0
Convert a Celsius temperature to Fahrenheit.

That last line proves the docstring is real data attached to the function, reachable through __doc__, and not just a comment that Python threw away. Triple quotes are used because they may span several lines. Describe the behaviour rather than restating the code: "Return the larger of two values" is useful, while "this function has an if statement" is not.

A Worked Example

This small program combines defaults, keyword arguments, *args, return, and docstrings to summarise exam results:

Python
def average(*scores):
    """Return the mean of the given scores, or 0.0 if none were given."""
    if not scores:
        return 0.0
    return sum(scores) / len(scores)


def verdict(score, passing=60):
    """Return a one-word judgement for a single score."""
    if score >= 90:
        return "excellent"
    if score >= passing:
        return "pass"
    return "retake"


def report(name, *scores, passing=60):
    """Print one student's average and verdict, and return the average."""
    mean = average(*scores)
    print(name, "averaged", round(mean, 1), "- verdict:", verdict(mean, passing))
    return mean

ana = report("Ana", 92, 95, 88)
ben = report("Ben", 55, 61, 48, passing=50)
print("Class average:", round(average(ana, ben), 1))
Output
Ana averaged 91.7 - verdict: excellent
Ben averaged 54.7 - verdict: pass
Class average: 73.2

Walking through it in order. average uses *scores, so it accepts any number of numbers and receives them as a tuple. Its first check, if not scores, is true only when that tuple is empty, and returning 0.0 there avoids dividing by a length of zero. Otherwise it divides the built-in sum of the scores by how many there are.

verdict takes one score plus a passing parameter that defaults to 60. Its three return statements are tried in order, and the first one that runs ends the function — so a score of 95 never reaches the passing comparison at all.

report is the interesting one. passing is written after *scores, which means it can only ever be supplied by name: any bare number in the call would be swallowed by *scores instead. That is exactly what you want here, because it makes report("Ben", 55, 61, 48, passing=50) unambiguous.

Inside report, the line average(*scores) uses a star at the call site, which does the opposite of what it does in a definition: it unpacks the tuple back into separate arguments, so average sees three numbers rather than one tuple. The result is rounded for display only, while the full unrounded mean is handed back by return so no precision is lost.

The last three lines use those return values. Ana's and Ben's averages are stored, then fed into average once more — a function that was written for exam scores works just as well on two averages, because it was never written to care. The class average of 73.2 is computed from the exact values, not from the rounded numbers on screen.

Common Mistakes

Using a mutable object as a default value. A default value is evaluated once, when the def line is first read, and the same object is then reused by every later call. With a list as the default, the results pile up:

Python
def add_item(item, basket=[]):
    basket.append(item)
    return basket

print(add_item("apple"))
print(add_item("bread"))
print(add_item("milk"))
Output
['apple']
['apple', 'bread']
['apple', 'bread', 'milk']

Each call was meant to start from an empty basket, but there is only one list in existence and every call appends to it. The standard fix is to default to None and build a new list inside the function, which guarantees a fresh one per call:

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

print(add_item("apple"))
print(add_item("bread"))
print(add_item("milk", ["eggs"]))
Output
['apple']
['bread']
['eggs', 'milk']

The same trap applies to dictionaries and any other value that can be changed in place. Numbers, strings, and True or False cannot be modified, so they are always safe as defaults.

Printing a result instead of returning it. The output looks right, so the bug hides until you try to use the value:

Python
def square(number):
    print(number * number)

result = square(6)
print("result is", result)
print(result is None)
Output
36
result is None
True

36 reached the screen but never reached result. The next step in a real program — something like square(6) + 1 — would stop with a TypeError, because Python cannot add a number to None. Return the value and let the caller decide whether to print it:

Python
def square(number):
    return number * number

result = square(6)
print("result is", result)
print(square(2) + square(3))
Output
result is 36
13

Naming a function after a built-in. Python already provides functions such as sum, max, len, list, and type. Defining your own with the same name does not cause an error — it quietly replaces the built-in for the rest of the file:

Python
def max(first, second):
    """Return the larger of two values."""
    if first > second:
        return first
    return second

print(max(3, 9))
print(max("apple", "pear"))
Output
9
pear

Nothing looks wrong yet. But the real max accepts a whole list, and that ability is now gone: a later max([4, 1, 7]) would fail with a TypeError complaining about a missing argument, and the error message would point at your own function rather than at the line you suspect. Pick a name that is not taken:

Python
def larger_of(first, second):
    """Return the larger of two values."""
    if first > second:
        return first
    return second

print(larger_of(3, 9))
print(max([4, 1, 7]))
Output
9
7

Passing the wrong number of arguments. Python checks the count on every call, and a required parameter with no matching argument stops the program:

Python
def greet(name, greeting):
    return greeting + ", " + name

try:
    print(greet("Ana"))
except TypeError:
    print("Python refused the call: greet needs two arguments")

print(greet("Ana", "Good morning"))
Output
Python refused the call: greet needs two arguments
Good morning, Ana

The try block here only exists to keep the example running; in your own code you would fix the call rather than catch the error. When a TypeError mentions a missing positional argument, count the parameters in the def line and count the arguments in the call — one of the two is out of date. Giving a rarely-needed parameter a sensible default is often the cleanest cure.

Next Steps

The linked practice problem, nth-fibonacci-memoized, is the natural place to take this. It asks you to write a function that returns the nth Fibonacci number and to remember answers it has already worked out, which means calling your own function from inside itself and carrying a dictionary of known results between calls — a direct workout for parameters, defaults, and return.

Before you start, open the Python playground and experiment. Write a function with a default argument and call it three ways. Print the return value of a function that has no return statement and confirm you get None. Then try the mutable-default trap deliberately, so that the first time you meet it in real code you recognise it instantly instead of losing an afternoon to it.

When you want a harder one

Interview-style problems graded against hidden tests — a big step up from the exercises. Come back to these when the ideas in this lesson feel comfortable rather than new.