Benjamin Patch

Python Kickstart (part 4): Functions

By Benjamin Patch ·

Practical Code

python python kickstart best practices open-source

Welcome back to our Python Kickstart series! In part 1, we installed Python and covered variables. In part 2, we explored data types and data structures. And in part 3, we made our programs dynamic with control flow and conditionals. Now we’re ready to tackle one of the most important concepts in programming: functions.

If control flow is the director’s vision that shapes how a story unfolds, then functions are the reusable scenes, shots, and setups that a production team can assemble into a finished film. A skilled director doesn’t reinvent how to block a dialogue scene every single time - they rely on proven techniques they can apply again and again. Functions give your Python programs that same kind of leverage.

Why Functions Matter

Before we dive into syntax, let’s talk about why functions exist in the first place. Imagine you’re writing a program that analyzes several films. For each one, you need to compute its age, classify its runtime, and format a summary line. Without functions, you’d copy and paste that logic for every movie. When you inevitably find a bug, you’d have to fix it in every single copy.

Functions solve this by letting you:

  • Reuse code - write logic once, call it as many times as you want
  • Name ideas - a well-named function documents what your code does
  • Isolate complexity - hide fiddly details behind a clean interface
  • Test smaller pieces - verify one chunk of logic at a time
  • Avoid repetition - following the DRY principle (Don’t Repeat Yourself)

As your programs grow, functions become the difference between code you can maintain and code you dread opening. Let’s see how to create them.

Defining Your First Function

In Python, you define a function using the def keyword, followed by the function’s name, a pair of parentheses, and a colon. The body of the function is indented underneath - just like the code blocks we saw with conditionals and loops in part 3.

# A simple function that prints a movie introduction
def introduce_movie():
    print("🎬 Now presenting a cinematic masterpiece...")
    print("Please silence your phones and enjoy the show.")

# Calling the function
introduce_movie()
introduce_movie()  # We can call it as many times as we want

Output:

🎬 Now presenting a cinematic masterpiece...
Please silence your phones and enjoy the show.
🎬 Now presenting a cinematic masterpiece...
Please silence your phones and enjoy the show.

A few things to notice:

  1. def tells Python we’re defining a function
  2. The function name follows the same rules as variable names - lowercase with underscores, no spaces, can’t start with a number
  3. The parentheses () will hold parameters (we’ll get there in a moment)
  4. The colon : and indented block define the function’s body
  5. Calling the function means writing its name followed by () - this actually runs the code inside

Defining a function and calling a function are two separate steps. Python reads the def block and remembers it, but nothing happens until you actually call the function by name.

Parameters: Passing Data to Functions

Functions become far more useful when you can pass information into them. The values a function accepts are called parameters when you define the function, and arguments when you call it.

# A function that takes a movie title as a parameter
def introduce_movie(title):
    print(f"🎬 Now presenting: {title}")
    print("Please silence your phones and enjoy the show.")

# Passing different arguments
introduce_movie("The Godfather")
introduce_movie("Seven Samurai")
introduce_movie("In the Mood for Love")

Output:

🎬 Now presenting: The Godfather
Please silence your phones and enjoy the show.
🎬 Now presenting: Seven Samurai
Please silence your phones and enjoy the show.
🎬 Now presenting: In the Mood for Love
Please silence your phones and enjoy the show.

Functions can take multiple parameters, separated by commas:

# Multiple parameters for richer output
def introduce_movie(title, director, year):
    print(f"🎬 Now presenting: {title} ({year})")
    print(f"   Directed by {director}")
    print("   Please silence your phones and enjoy the show.\n")

introduce_movie("Vertigo", "Alfred Hitchcock", 1958)
introduce_movie("Parasite", "Bong Joon-ho", 2019)

When you call a function with multiple arguments, Python matches them to parameters in order. "Vertigo" goes into title, "Alfred Hitchcock" goes into director, and 1958 goes into year. Getting that order wrong is a common bug - we’ll talk about how to avoid it shortly.

Return Values: Getting Data Back

Printing things is useful, but often you want a function to compute a value and hand it back so other code can use it. That’s where the return statement comes in.

# Calculate how many years ago a film was released
def years_since_release(release_year, current_year):
    return current_year - release_year

# The return value can be stored in a variable
godfather_age = years_since_release(1972, 2026)
casablanca_age = years_since_release(1942, 2026)

print(f"The Godfather is {godfather_age} years old")
print(f"Casablanca is {casablanca_age} years old")

# Or used directly in an expression
print(f"2001: A Space Odyssey is {years_since_release(1968, 2026)} years old")

Output:

The Godfather is 54 years old
Casablanca is 84 years old
2001: A Space Odyssey is 58 years old

A function that uses return immediately stops executing and hands back whatever value you specify. If you don’t write a return statement at all, the function returns a special value called None - Python’s way of saying “nothing here.”

You can also return multiple values by separating them with commas, which Python packs into a tuple:

# Return multiple pieces of related information
def analyze_runtime(minutes):
    hours = minutes // 60
    leftover_minutes = minutes % 60
    is_epic = minutes >= 180
    return hours, leftover_minutes, is_epic

# Unpack the returned tuple directly into variables
hrs, mins, epic = analyze_runtime(228)  # Lawrence of Arabia
print(f"Lawrence of Arabia: {hrs}h {mins}m")
if epic:
    print("This is an epic-length film!")

Output:

Lawrence of Arabia: 3h 48m
This is an epic-length film!

This kind of multi-return is perfect when several pieces of data naturally belong together, like the components of a runtime analysis.

Default Parameters

Sometimes a parameter has an obvious default value that most callers will want. You can provide one directly in the function definition:

# The current_year has a sensible default
def years_since_release(release_year, current_year=2026):
    return current_year - release_year

# Callers can omit current_year when the default is fine
print(years_since_release(1972))        # Uses default: 54
print(years_since_release(1972, 2030))  # Override: 58

Default parameters make functions more flexible without forcing every caller to supply every value. One important rule: parameters with defaults must come after parameters without defaults in the function signature. Python will raise a SyntaxError if you try it the other way around.

# ✅ Correct — required parameters come first
def rate_movie(title, rating=5.0):
    return f"{title}: {rating}/10"

# ❌ Incorrect — will cause SyntaxError
# def rate_movie(title="Untitled", rating):
#     return f"{title}: {rating}/10"

Keyword Arguments: Calling by Name

When a function takes several parameters, remembering their order can be tricky - especially if some of them are the same type. Python lets you pass arguments by name, which makes your code clearer and lets you skip over parameters with defaults.

def describe_film(title, director, year, genre="Drama", runtime=120):
    print(f"📽️  {title} ({year})")
    print(f"   Director: {director}")
    print(f"   Genre: {genre}")
    print(f"   Runtime: {runtime} minutes\n")

# Positional arguments — order matters
describe_film("Pulp Fiction", "Quentin Tarantino", 1994)

# Keyword arguments — order doesn't matter
describe_film(
    title="Spirited Away",
    year=2001,
    director="Hayao Miyazaki",
    genre="Animation",
    runtime=125,
)

# Mix positional and keyword — positional must come first
describe_film("Seven Samurai", "Akira Kurosawa", 1954, runtime=207)

Notice how the describe_film("Seven Samurai", ...) call skips genre entirely (using the default "Drama") while still providing a custom runtime. Keyword arguments are a powerful way to make function calls self-documenting.

Flexible Arguments with *args and **kwargs

Occasionally you’ll want a function that can accept an arbitrary number of arguments. Python provides two special syntaxes for this: *args for extra positional arguments, and **kwargs for extra keyword arguments.

# *args collects extra positional arguments into a tuple
def average_rating(*ratings):
    if not ratings:
        return 0.0
    return sum(ratings) / len(ratings)

print(average_rating(9.2, 8.7, 9.0))         # Three ratings
print(average_rating(8.3))                    # One rating
print(average_rating(9.2, 8.7, 9.0, 7.5, 8.1))  # Five ratings

# **kwargs collects extra keyword arguments into a dictionary
def build_movie_entry(title, **details):
    entry = {"title": title}
    entry.update(details)
    return entry

inception = build_movie_entry(
    "Inception",
    director="Christopher Nolan",
    year=2010,
    genre="Sci-Fi Thriller",
    rating=8.8,
)

print(inception)

Output:

8.966666666666667
8.3
8.5
{'title': 'Inception', 'director': 'Christopher Nolan', 'year': 2010, 'genre': 'Sci-Fi Thriller', 'rating': 8.8}

The names args and kwargs are just convention - what matters is the * and **. Use these features sparingly: they’re powerful, but they also make a function’s signature less obvious at a glance.

Variable Scope: Where Names Live

When you create a variable inside a function, it exists only within that function. This is called local scope. Variables defined outside any function live in global scope and are visible throughout the file.

# Global variable
favorite_director = "Akira Kurosawa"

def film_recommendation():
    # Local variable — only visible inside this function
    recommended_film = "Ran"
    print(f"{favorite_director} recommendation: {recommended_film}")

film_recommendation()
print(f"Favorite director: {favorite_director}")

# Uncommenting this line would raise NameError — recommended_film doesn't exist out here
# print(recommended_film)

Scope keeps your program organized. A function can freely use its own variables without accidentally clobbering something in the rest of the program. As a general rule, prefer passing data in through parameters and getting data out through return values, rather than reaching out to global variables. Functions that only work with their inputs and outputs are much easier to understand and reuse. Here are two alternative designs — pick one:

# ❌ Fragile — relies on a global variable
catalog = []

def add_to_catalog(title):
    catalog.append(title)

# ✅ Clearer — the data dependency is explicit
def add_to_catalog(catalog, title):
    catalog.append(title)
    return catalog

Both patterns work, but the second version is easier to test and reason about because everything the function touches is right there in the signature.

Docstrings: Documenting Your Functions

Python has a built-in convention for documenting functions called a docstring - a triple-quoted string placed on the first line of a function’s body. Tools like editors, help(), and documentation generators can read these strings automatically.

def classify_runtime(minutes):
    """Return a human-readable category for a film's runtime.

    Args:
        minutes: The film's runtime in whole minutes.

    Returns:
        A string describing the runtime category.
    """
    if minutes < 90:
        return "Short Feature"
    elif minutes < 150:
        return "Standard Feature"
    elif minutes < 180:
        return "Long Feature"
    else:
        return "Epic"

print(classify_runtime(87))   # Short Feature
print(classify_runtime(175))  # Long Feature
print(classify_runtime(228))  # Epic

# You can also read the docstring at runtime
help(classify_runtime)

Good docstrings explain what a function does, what its parameters mean, and what it returns. They’re a gift to your future self - and to anyone else who inherits your code.

Lambda Functions: Tiny Anonymous Functions

Sometimes you need a very small function for a single purpose - often to pass to another function. Python lets you write these as lambda functions, which are unnamed and limited to a single expression.

# A list of movies as (title, rating) tuples
movies = [
    ("Citizen Kane", 8.3),
    ("The Godfather", 9.2),
    ("Seven Samurai", 8.6),
    ("Vertigo", 8.3),
    ("Tokyo Story", 8.2),
]

# Sort by rating (highest first) using a lambda as the sort key
sorted_movies = sorted(movies, key=lambda movie: movie[1], reverse=True)

for title, rating in sorted_movies:
    print(f"{rating}{title}")

The lambda here, lambda movie: movie[1], is shorthand for a function that takes one argument called movie and returns its second element (the rating). We could have written a regular def function instead, but for a one-line helper passed directly to sorted(), a lambda keeps the code compact.

Use lambdas for small, obvious expressions. If the logic gets complex, reach for a regular def - the name alone will make the code easier to read.

Common Mistakes and How to Avoid Them

Functions unlock a lot of power, but they also introduce some new ways to trip yourself up. Watch out for these.

Forgetting to Call the Function

def greet_director(name):
    return f"Welcome, {name}!"

# ❌ This just references the function object — no greeting happens
print(greet_director)

# ✅ The parentheses actually call the function
print(greet_director("Sofia Coppola"))

Forgetting to Return a Value

# ❌ This function prints the result but doesn't return it
def double_rating(rating):
    print(rating * 2)

result = double_rating(4.5)  # prints 9.0 as a side effect
print(result)  # None — because we forgot to `return`

# ✅ Return the value so callers can use it
def double_rating(rating):
    return rating * 2

result = double_rating(4.5)
print(result)  # 9.0

Mutable Default Arguments

This one surprises almost everyone the first time they hit it. Default arguments are evaluated once, when the function is defined - not each time it’s called. If the default is a mutable object like a list, every call shares the same list.

# ❌ Dangerous — the default list is shared across calls
def add_to_watchlist(title, watchlist=[]):
    watchlist.append(title)
    return watchlist

print(add_to_watchlist("Rear Window"))   # ['Rear Window']
print(add_to_watchlist("Rashomon"))      # ['Rear Window', 'Rashomon'] — oops!

# ✅ Use None as the default and create a fresh list inside
def add_to_watchlist(title, watchlist=None):
    if watchlist is None:
        watchlist = []
    watchlist.append(title)
    return watchlist

print(add_to_watchlist("Rear Window"))   # ['Rear Window']
print(add_to_watchlist("Rashomon"))      # ['Rashomon']

Remember this rule: never use a mutable default argument. Use None as the sentinel and create the real default inside the function.

Bringing It All Together

Let’s combine everything from this article into a small film analysis tool. We’ll use multiple functions that work together - each with a single, clear job.

def classify_runtime(minutes):
    """Return a human-readable runtime category."""
    if minutes < 90:
        return "Short Feature"
    elif minutes < 150:
        return "Standard Feature"
    elif minutes < 180:
        return "Long Feature"
    else:
        return "Epic"

def years_since_release(release_year, current_year=2026):
    """Return how many years have passed since a film's release."""
    return current_year - release_year

def rating_verdict(rating):
    """Convert a numeric rating into a short critical verdict."""
    if rating >= 9.0:
        return "Masterpiece"
    elif rating >= 8.0:
        return "Highly Recommended"
    elif rating >= 7.0:
        return "Worth Watching"
    else:
        return "Mixed Reception"

def summarize_film(title, director, year, runtime, rating):
    """Print a full analysis line for a single film."""
    age = years_since_release(year)
    category = classify_runtime(runtime)
    verdict = rating_verdict(rating)

    print(f"🎬 {title} ({year}) — directed by {director}")
    print(f"   {category}, {runtime} min | {age} years old")
    print(f"   Rating: {rating}/10 — {verdict}\n")

def summarize_collection(films):
    """Summarize every film in a collection and report the average rating."""
    for film in films:
        summarize_film(**film)

    average = sum(f["rating"] for f in films) / len(films)
    print(f"📊 Average rating across {len(films)} films: {average:.2f}")

# Our curated collection
collection = [
    {
        "title": "Seven Samurai",
        "director": "Akira Kurosawa",
        "year": 1954,
        "runtime": 207,
        "rating": 8.6,
    },
    {
        "title": "The Godfather",
        "director": "Francis Ford Coppola",
        "year": 1972,
        "runtime": 175,
        "rating": 9.2,
    },
    {
        "title": "In the Mood for Love",
        "director": "Wong Kar-wai",
        "year": 2000,
        "runtime": 98,
        "rating": 8.1,
    },
]

summarize_collection(collection)

Notice how each function does one thing and is easy to understand on its own. summarize_film doesn’t need to know how runtimes are classified - it just calls classify_runtime. summarize_collection doesn’t need to know how individual films are formatted - it just calls summarize_film. This kind of layered design is exactly what functions are for, and it’s how real-world Python programs are built.

Also notice the **film syntax in summarize_film(**film). That’s the mirror image of **kwargs: it unpacks a dictionary into keyword arguments. Because the keys in each dictionary match the parameter names of summarize_film, Python can pass them through automatically.

What’s Next?

Functions are the turning point where Python stops feeling like a sequence of commands and starts feeling like a real programming language. With conditionals, loops, and functions in your toolkit, you can build surprisingly sophisticated programs.

In the next installment, Python Kickstart Part 5: Classes and Object-Oriented Programming, we’ll take the next step: bundling data and the functions that operate on it into reusable types of our own. If functions are reusable scenes, classes are reusable characters - complete with their own state and behavior.

Practice Suggestions

Before moving on, try building these small projects to reinforce your understanding of functions:

  • Film Rating Calculator: Write a function that takes a list of ratings and returns the average, highest, and lowest as a tuple. Bonus points for handling empty lists gracefully.
  • Watchlist Manager: Create functions for adding, removing, and listing films in a watchlist. Make sure you avoid the mutable default argument trap!
  • Runtime Formatter: Write a function that converts a runtime in minutes into a string like "3h 27m". Try giving it a default format and a keyword argument for a different style (e.g. "207 minutes").
  • Director Filter: Given a list of film dictionaries, write a function that returns all films by a given director. Use keyword arguments to support optional filters like minimum rating.

Resist the urge to jam everything into one giant function. A good rule of thumb: if you can’t describe what a function does in a single sentence, it’s probably trying to do too much. Break it up.

As always, the best way to internalize these concepts is to write code, break it, and fix it. Every function you write makes the next one easier.

Happy coding, and I’ll see you in Part 5!