Writing Logic and Functions

Functions: Writing Reusable Code

7 min read

The copy-paste trap

Imagine you need to calculate the area of a rectangle in five different places in your program. You could write width * height five times. But what happens when requirements change and you need to add validation? You'd have to find and update all five places — and probably miss one.

Functions solve this. A function packages a piece of logic under a name. You write it once, call it anywhere. If you need to change the logic, you change it in one place.


Defining a function

Use the def keyword to define a function:

def greet_user(name):
    return f"Hello, {name}! Welcome to Python."

Breaking this down:

  • def — tells Python you're defining a function
  • greet_user — the name you're giving the function
  • (name) — a parameter (an input the function expects)
  • The indented block — the code that runs when the function is called
  • return — sends a value back to whoever called the function

Calling a function

Define it once, then call it by name:

def greet_user(name):
    return f"Hello, {name}! Welcome to Python."

message = greet_user("Sonia")
print(message)

Output:

Hello, Sonia! Welcome to Python.

When Python sees greet_user("Sonia"):

  1. It jumps into the function definition
  2. Sets name = "Sonia"
  3. Runs the code inside
  4. Returns the result back to the line that called it
  5. Continues from there

Functions with multiple parameters

Functions can accept multiple inputs:

def calculate_area(width, height):
    return width * height

area = calculate_area(5, 10)
print("Area:", area)
# Area: 50

When calling with multiple arguments, they match the parameters in order: width = 5, height = 10.


Default parameter values

You can give parameters a default value — used when no argument is provided for that parameter:

def make_greeting(name, language="English"):
    if language == "Spanish":
        return f"Hola, {name}!"
    elif language == "French":
        return f"Bonjour, {name}!"
    return f"Hello, {name}!"

print(make_greeting("Mina"))                    # Hello, Mina!
print(make_greeting("Mina", language="Spanish")) # Hola, Mina!
print(make_greeting("Mina", language="French"))  # Bonjour, Mina!

If you call make_greeting("Mina") without specifying a language, it defaults to "English".

A default value can only follow other defaults — once a parameter has one, every parameter after it must have one too:

def make_greeting(language="English", name):   # SyntaxError
    ...

Python can't tell whether a value provided in a call belongs to language or name, so it refuses to define the function at all.


Keyword arguments

So far, arguments have been matched to parameters by position — the first value fills the first parameter, and so on. Keyword arguments let you match them by name instead, in any order:

def make_greeting(name, language="English"):
    if language == "Spanish":
        return f"Hola, {name}!"
    return f"Hello, {name}!"

print(make_greeting(language="Spanish", name="Mina"))  # Hola, Mina!
print(make_greeting(name="Mina", language="Spanish"))  # Hola, Mina!

Both calls above work identically — with keyword arguments, the order you write them in doesn't matter. This is especially handy when a function has several parameters with defaults and you only want to override one:

def show_range(upper_limit=10, step=1):
    print(list(range(0, upper_limit, step)))

show_range(step=2)   # only override step, keep upper_limit's default

One rule to keep in mind: once you start passing arguments by keyword in a call, every argument after it must also be passed by keyword.

make_greeting(name="Mina", "Spanish")   # SyntaxError

The return statement

return sends a value back from the function to the caller. Once Python hits return, it exits the function immediately.

def is_even(number):
    if number % 2 == 0:
        return True
    return False

print(is_even(4))   # True
print(is_even(7))   # False

A function without a return statement (or with a bare return) gives back None:

def say_hello(name):
    print(f"Hello, {name}!")   # prints, but doesn't return

result = say_hello("Alex")
print(result)   # None

Functions with no parameters

Not every function needs inputs:

def show_menu():
    print("1. Start new game")
    print("2. Load saved game")
    print("3. Settings")
    print("4. Quit")

show_menu()

Functions like this are useful for organizing code into named sections, even when there's nothing to pass in.


Why functions matter

1. Write once, use everywhere

def calculate_tax(price, rate=0.08):
    return price * rate

# Use it anywhere without rewriting the logic
item1_tax = calculate_tax(29.99)
item2_tax = calculate_tax(49.99, rate=0.1)  # different tax rate
print(f"Tax on item 1: ${item1_tax:.2f}")
print(f"Tax on item 2: ${item2_tax:.2f}")

2. One place to fix bugs

If the tax calculation ever needs to change, you update one function — not every place you've computed a tax.

3. Readable code

Functions make code self-documenting. A function named validate_email() tells you exactly what it does without reading the implementation.


A practical example: student grade tracker

Here's a small but complete program that uses functions to organize logic:

def calculate_average(scores):
    return sum(scores) / len(scores)

def letter_grade(average):
    if average >= 90:
        return "A"
    elif average >= 80:
        return "B"
    elif average >= 70:
        return "C"
    elif average >= 60:
        return "D"
    return "F"

def print_report(name, scores):
    avg = calculate_average(scores)
    grade = letter_grade(avg)
    print(f"Student: {name}")
    print(f"Average: {avg:.1f}")
    print(f"Grade: {grade}")

print_report("Sonia", [85, 92, 78, 88, 91])

Output:

Student: Sonia
Average: 86.8
Grade: B

Each function does one thing. print_report calls calculate_average and letter_grade — functions calling other functions is completely normal and encouraged.


Returning multiple values

return isn't limited to a single value. Separate values with commas, and Python automatically packs them into a tuple:

def points(x):
    y = 1 - x
    return x, y

result = points(0.3)
print(result)   # (0.3, 0.7)

At the call site, you can unpack the returned tuple straight into separate variables — the same tuple-unpacking syntax you'd use for any tuple:

x_val, y_val = points(0.3)
print(x_val, y_val)   # 0.3 0.7

This is the standard way to hand back more than one result from a function — for example, a function that computes both an average and a total, or both a minimum and a maximum.


Scope: where variables live

A variable created inside a function only exists while that function is running. It's not accessible outside:

def my_function():
    x = 10    # only exists inside this function
    print(x)

my_function()   # prints 10
print(x)        # NameError: name 'x' is not defined

This is called local scope. Variables defined outside all functions are in global scope and can be read anywhere:

counter = 10

def print_counter():
    print(counter)   # reads the global counter just fine

print_counter()   # 10

Assigning to a global variable's name creates a local one instead

Reading a global variable works without any special syntax. But assigning to a name inside a function always creates a new local variable, even if a global variable already has that name:

counter = 10

def change_counter():
    counter = 20              # creates a local `counter`, doesn't touch the global one
    print("Counter in:", counter)

change_counter()               # Counter in: 20
print("Counter out:", counter) # Counter out: 10  (unchanged)

Using global to modify the outer variable

To actually change a global variable from inside a function, declare it with the global keyword first:

counter = 10

def change_counter():
    global counter
    counter = 20
    print("Counter in:", counter)

change_counter()               # Counter in: 20
print("Counter out:", counter) # Counter out: 20  (changed)

Use global sparingly — functions that quietly modify variables outside their own scope are harder to reason about. Prefer returning a new value and reassigning it at the call site instead.


Key takeaway

Functions package reusable logic under a name. You define them with def, give them parameters for inputs, and use return to send results back. Default parameter values and keyword arguments make functions flexible to call; global lets you opt into modifying a variable outside a function, though it's best used sparingly. The payoff: write the logic once, use it everywhere, fix it in one place.

What's next?

You've covered the core of Python — the building blocks every program is made from. The final lesson brings in two more essential tools: modules (borrowing code others have written) and file I/O (saving and loading data to and from files).