The Function In Python: Complete Tutorial And Best Practices

Writing effective functions is a crucial skill to master, in Python or in any programming language. Learning to write and use Python functions is like playing the game of Go or Chess. Understanding the game’s basic rules is simple enough, and you can quickly master these after a short introduction. However, learning to play well can be a challenging, lifetime adventure.

If you’re a new programmer, learning to write functions is the first leap you’ll take away from treating Python as a “quick and dirty” scripting language to a sophisticated general-purpose language that lets you write robust, maintainable code.

Part I: Python Function Tutorial

What Is a Function in Python?

As with everything in programming, the best way to understand functions is by example, but before we get to those, let’s begin with a formal definition.

A Python function is named, callable group of statements. Functions accept zero or more arguments and may return a result. This very broad definition captures what the language allows.

More strictly speaking, a function is a subroutine that returns a result. Still, Python is like most other languages, in that it doesn’t distinguish between “subroutines” (or procedures), which don’t return a result, and functions, which do return a result. They are defined the same in Python and called in roughly the same way.

As in most programming languages, therefore, the definition of a function in Python is quite broad, and certainly a good deal broader than it is in mathematics, where a function maps a value to a result in a deterministic way.

In addition to functions as defined here, Python also supports unnamed, anonymous lambda functions and callable objects that can serve as functions but are defined differently. There are also different types of methods that one can define on Python clases. (See Python Classes Zero To Expert: A Tutorial With Exercises, for more on this topic). For the purposes of this article, however, we’ll focus primarily on simple functions declared outside of a class.

Python Functions: Some Examples

You define a function in Python with the following syntax:

def function_name(optional_parameter_1, ..., optional_parameter_n):
    statement_1
    ...
    statement_N
    optional return value

The items you pass to a function are called parameters when they appear in the function’s definition. They are called arguments when passed as part of a call to a function. To call a function, you type the function name followed by the arguments you want to pass to the function in parentheses. You already know how to do this:

# Calling a function

print("Hello world")

Multiple parameters, if any, are separated by commas.

The print function is a good example of the “looseness” of the definition of a function because it doesn’t return a result. We call print for to get the “side effect” of displaying a result on the screen.

Let’s look at some simple examples of function definitions and how we might call them. We first define a function like print that we call for its side effects, and then we define another that fits a stricter, mathematical idea of what a function does.

def log_info(message):
    """A function with one parameter called for side effects only.  No return value."""
    print(f"INFO: {message}")
    
def add(n1, n2):
    """A function with two parameters and a return value"""
    return n1 + n2

log_info("A function with side effects")

# Save a return value to a variable:
result = add(2,2)
log_info("2 plus 2 equals " + str(result))

# What about the side-effects only function. What does it "return"?
log_result = log_info("What if a function returns no result?")
log_info(f"log_result is None? {log_result is None}")

Output:

INFO: A function with side effects
INFO: Two plus two equals 4
INFO: What if a function returns no result?
INFO: log_result is None? True

Keyword Arguments and Positional Arguments

Python has a very rich and flexible syntax for passing arguments to a function. In fact, it’s so rich and flexible that we’ve published a whole separate article on Python Function Arguments that goes over all the rules and special cases.

To briefly summarize some of the main points without getting into all the detailed syntax and special cases, if you have a plain function with a set of parameters, you can in general specify those parameters either by keyword or by position.

Let’s suppose for example that you’re tired of looking up string formatting codes for displaying different numbers in base 2, base 16, and so forth. You decide to write a function that takes a decimal number and a string representing a base you want to display it as, and returns a string. The bases you want to be able to pass to it are “hex” for hexadecimal, “bin” for binary, “oct” for octal, and “dec” for decimal.

def int_to_string(number: int, target_base: str) -> str: 
    """Format an integer as a string, in one of the bases given by the keys in supported"""
    supported = {"hex": "{0:0x}", "bin": "{:0b}", "dec": "{0:0d}", "oct": "{0:0o}"}
    assert target_base in supported.keys()
    return supported[target_base].format(number)

The implementation details are unimportant for now. The main point is that, given a simple Python function like this, we can pass a number in the first position, and a string in the second position:

# Call a function using positional arguments
result = int_to_string(10, "hex")
print(result)

Output:

a

Being able to call a function by position like this is common to many, many programming languages. In Python, we can alternatively call the function by keyword, specifying the name of the parameter, an equals sign and a value. If we do this we can pass the arguments out of order if we like:

# Call a function using keyword arguments
result = int_to_string(target_base="bin", number=10)
print(result)

Output:

1010

You can also mix positional arguments and keyword arguments, but the rule for that is that if you do so, any positional arguments must come first. This is as you might expect, since in that case, order matters!

Default Parameters

Later parameters in a function parameter list can be created as default parameters. For example, maybe we designed int_to_string to be flexible, but since we deal with hexadecimal values a lot, we really wanted to make that a default. To do this, place the default value in an equals sign after the parameter definition, for example:

def int_to_string(number: int, target_base: str="hex")

With this change in place, we can still override the default to get octal or other formats, but if we leave off the parameter, we get hex:

# Call a function using a default parameter
result = int_to_string(255)
print(result)

Output:

ff

Return Values

As we saw above in int_to_string, values can be returned from a function using the “return” keyword, followed by a variable or expression. The return value can then be set to a variable or used in another expression. Function calls can also be nested, with the innermost call’s return value passed to the next innermost, and so forth. In practice, you should avoid having too much nesting because after awhile the code starts to become unclear, but the choice of when an intermediate variable improves readability is somewhat arbitrary. Here’s an example where we nest the return of str to the len function, and print that value.

print(len(str(12345))) # Prints 5

Output:

5

If the nesting begins to get confusing, you can clarify the explicit steps by creating variables if you wish.

number_as_string = str(12345)
length = len(number_as_string)
print(length)

When to keep things concise and when to make them explicit is often a judgment call, but usually, if there’s any complexity in the code that would make it difficult to understand otherwise, splitting it out into separate steps is a good idea. When in doubt, err on the side of clarity. The goal always should be to make the code readable, not clever.

Early Returns from a Function

A function can have more than one return statement. There’s some debate as to whether that’s a good idea or not, but many developers feel it makes sense if you already know the answer. Let’s suppose you have a function that takes a list of sales objects and counts those with a total value greater than $100.00. If the list is either null or empty, it makes no sense to continue, since the answer has to be zero. In that case, an early return at the top of the function might make perfect sense:

def count_sales_over_one_hundred(sales):
    """Assert example"""
    if not sales:
        return 0
    # Do count otherwise...

We’ll show another way we could have handled this condition a bit later in our discussion of asserts.

Multiple Return Values from a Python Function

You can easily return multiple values from a Python function by separating them with a comma. Strictly speaking, you’re really returning one Python tuple this way, and the comma serves as an operator that creates the tuple.

Let’s say you wanted to modify your count_sales_over_one_hundered function to return both the total count of sales over one hundred dollars, as well as the count of sales under ten dollars. Once you’ve gone through the list, your return statement might look like this:

return count_high_dollar, count_low_dollar

Functions with No Return Value

When your program calls a function in Python, generally speaking one of three things will happen:

  • The function will return a value to the caller. If it returns a value, that value should (generally) be handled by the caller in some way.
  • The function will not return a value with an explicit return call, and will simply run through the last line of code in the function, then return None (implicitly) to the caller.
  • The function will throw an exception. The caller may handle the exception, for example by logging or otherwise reporting the error. If the exception is unhandled or re-raised by the caller, the program will exit.

Part II: Python Function Best Practices

Now that we understand at least the basics of creating functions in Python, let’s spend some time considering some best practices for writing functions in general, and Python functions specifically. I realize that most beginner function tutorials focus on the basic mechanics of writing functions. For the most part, that’s the right approach since there’s no substitute for trying things out in live code. However, I do think that it also helps to have some basic guidelines for how to write a good function. Remember, however, that these are suggestions — they’re not meant to be hard and fast rules. Wherever any of these standards don’t make sense for a given situation, feel free to adjust them as needed.

Write Functions that Have a Single Responsibility

In general, functions should do a single thing, and it should be clear what that one thing is. One very simple approach to this is to avoid using “and” in a function name. For example, validate_and_save_user can be recast as two separate functions, validate_user and save_user. This second version makes it easy to test the user validation independently of saving a user.

Yes, this may mean that some functions will become smaller, sometimes to the point of being only a few lines or a single line long. This is not necessarily a problem if the function adds value by making it clear what the intent of the code is. See the next point for more on this.

Use Functions to Clarify Intent

It’s sometimes a judgment call as to whether a function will clarify the code’s intent or simply add unnecessary lines of code, but I would suggest that when in doubt, a function may be helpful. Consider the following code:

# ...
if user_is_subscriber and user_subscription_level == 2:
     # do something

Granted, the lack of clarity is really around the variables here. What are the subscription levels, and what is the magic number 2 doing? So far this code is completely mysterious. Consider instead:

def is_platinum_user(is_subscriber: bool, subscription_level: int):
    platinum_level = 2
    return is_subscriber and subscription_level == platinum_level

# ...
if is_platinum_user(user_is_subscriber, user_subscription_level):
    # do something,

Without impacting the surrounding code, we’ve done a safe refactoring that now makes it clear that subscription level #2 is the boundary between the platinum level and the rest of the folks. Moreover, it’s clear by looking at the caller code what it’s trying to do now, and as a side benefit, our code now has a single source of truth for who the cool kids are.

One suggestion that was a favorite of the Ruby community is worth mentioning here. Sometimes a good indicator that you need a function to clarify your intent is if the code is unclear enough that you’re tempted to write a comment to clarify it. Though I don’t agree with the general condemnation of comments, that “all comments are code smells”, I do think that comments may often point the way to a place where a function may make things more clear.

Choose Function and Parameter Names Carefully

Function names should adequately describe the function’s purpose, and should generally consist of at least a noun and verb combination. Take a look at the following function names:

  • load
  • load_user
  • load_user_from_database

It’s very hard to tell what the first one is doing, the second is improved considerably, and by the time we reach the third we know exactly what’s going on just by looking at the name. Granted, if the third one is in the database module already, load_user might be sufficient to describe it for a developer familiar with the codebase. When in doubt, it’s better when naming functions to err on the side of clarity. This is a case where the Zen of Python’s advice that “Explicit is better than implicit.” really makes a lot of sense.

The same thing goes for parameter names. Unless you’re dealing with two-dimensional points or locations for buried pirate treasure, banish “x” and “y” from your code. Consider the difference between:

  • load(x)
  • load_user(email)

You know what the second one is doing, don’t you? What about the first? Is it loading a user by an email address or loading gold that a pirate buried?

Don’t make users guess. Remember, the person reading your code six months from now may be you!

Document Function Parameters With Type Hints

Consider a Python function that takes a user_id as an argument. In a relational database with autoincrement fields, that user_id might be an integer. Otherwise, it might be a string representing a uuid. If we have a function: lookup_user(user_id), we don’t have any guidance whatsoever on what we’re dealing with. If, however, we see a function definition with a type hint: lookup_user(user_id: int) -> User, we know now that we need to pass an integer as an identifier, and we even have a class we might click on to navigate to more information about the return value.

To be sure, we can always look around the code and figure it out, but the goal of all our function best practices should be to make your functions as obvious as possible, and type hints help to do that. In addition, if the consumer of your function use some sort of IDE or linting tool that supports type hints, they can get a warning if they use the function incorrectly.

Use Docstrings to Further Document Functions

Historically, Python had docstrings long before it had type hints. Python docstrings generally begin with a brief statement describing what the function does. For certain short and easy-to-understand functions, this may be all that’s needed. Docstrings can also contain more detailed usage notes and details, as well as information about parameter names, types, meanings, and default values. For more information about Python doc strings, PEP 257 discusses several important docstring conventions.

Prefer Functions that Accept Basic Python Types

When writing a function, it’s often tempting to pass an object to it and then read whatever attributes we need to read off the object. This can certainly make things more convenient when there are many such attributes, but it raises several issues.

In the first place, if the object is not immutable, we need to read through the whole function to understand if the object was changed once we handed it off. Reading the attributes before the call makes this a non-issue.

Secondly, since objects also contain methods, we have the same problems with respect to having to examine whether our function is causing side effects.

Third, having a non-object interface makes the function generally much easier to test.

Finally, if the function in question is tightly coupled to the object, there’s a case to be made that perhaps it makes more sense for at least part of it to be broken out as a method on the object itself.

I realize that this best practice may be a bit controversial, and some developers who are object purists will argue that objects should pass messages to one another, that’s the whole point of object-oriented programming. Having seen how messy things can get when stateful objects are passed around, I disagree with this. I prefer the enhanced testability and simplicity of interfaces that accept simple types.

Prefer Functions with No Side Effects

The easiest functions to reason about are those that map a given set of parameters to an unambiguous, deterministic return value. These are analogous to mathematical functions, and they are what the functional programming folks refer to as pure functions. Pure functions do not relate to any external state and their behavior is easy to test and reason about.

Readers familiar with functional programming will probably have noticed by now that I’ve been heavily influenced by them, even though Python is certainly not a “pure” functional language. It’s worth noting, however, that many of the most ideologically pure languages in this respect are also the least widely adopted, so there’s a great deal to be said for languages like Python that offer greater flexibility.

Along the same lines of being flexible, one very important exception I would make to the general rule of avoiding side effects is in the area of logging. For any application meant to run in a production environment, logging is critical to ensure you can troubleshoot issues. Strictly speaking, logging is a side-effect, but my position is that the advantages of having logs available clearly outweigh whatever benefits one would get by being a functional programming purist.

Prefer Returning a Copy to Mutating a Parameter

Unless a section of code is extremely sensitive, it’s generally a good idea to avoid functions that mutate variables that are passed in. If you need to add values to a list based on some condition, for example, you could return a copy of the list with the new element added.

Another approach if the condition is non-trivial is to isolate it to a function and modify the list in the caller instead. This avoids the performance penalty of cloning the list, but at the same time avoids having a function that operates on its parameters rather than returning a value.

The point here is not to add extra clock cycles but to think about following the general principle of having a clear path from inputs to outputs, keeping side effects to a minimum.

If You Do Mutate a Parameter, Make It Obvious

There are times when returning a copy of an existing list or sequence may not be feasible or desirable, and it may be that we have enough places we want to do this task that we need to break it down into component functions.

Once again, being pragmatic trumps being an ideological zealot. In such cases, we can fall back upon the general rule of not making a user guess what a function is doing, but instead documenting it as best we can in both the name of the function and the docstring.

Use Asserts to Document the Function Contract

I am indebted to Steve Macguire’s book, Writing Solid Code, both for the inspiration that led to the name of this website and to for the current best practice. Macguire recommends using asserts to check a function’s “invariants”, which is a fancy word for something we hold to be true. So when “we hold these truths to be self-evident, that all [persons] are created equal”, that’s an invariant.

Like C, the language that was the topic of Macguire’s book and that had an assert macro, Python has an “assert” function that can be used to check if preconditions are met. One of the best places to put such checks is at the start of a function, to check that the arguments passed to the function makes sense.

When we discussed returning early from a function, we had an example that checked to see if a list were passed as None or empty, and returned early from the function.

def count_sales_over_one_hundred(sales):
    """An early return example"""    
    if not sales:
        return 0

    # Do count otherwise...

Though this code works just fine, it could be improved somewhat, since generally, passing None where we’re expecting a valid list or another type should be handled as a programmer error. Since an empty list is safe to iterate over if we’re counting things (i.e., we won’t iterate at all, and the count will be zero, as expected), we can treat None separately here as a programmer error by using an assert:

def count_sales_over_one_hundred(sales):
    """Assert example"""
    assert sales is not None
    # Do count otherwise...

Note that in production, asserts may be optimized out of your code for example, by running it as shown here:

python -O progam_name.py

Because of this, it’s important when using asserts not to check errors that may be real runtime exceptions this way (files that fail to open, for example). Also, never use assert “inline” to check the return value of a function. The following code does something important when run with assertions on (the default). Using the -O or -OO switch, something_important is never called!

def something_important():
    print("Doing something important here...")
    return True

# Don't do this!  Something_important may not be called.
assert something_important()

I learned not to do this the hard way somewhat early on in my career.

Closing Thoughts: Be Mindful of State

Perhaps as long as we’ve programmed computers, and certainly for as long as they’ve had sufficient memory to hold a decent amount of data, managing state has been an issue. By now almost all programmers have heard the timeworn advice to avoid global data, but much of the history of programming best practices since then can be seen as just moving that data around, without solving the fundamental problem. Beginning around 1972 in the case of Smalltalk, and 1979 for C++, object-oriented languages promised to free us from global state by encapsulating code and data together as one unit.

However, anyone who shares my experience of having to debug troublesome state management in a large-scale class may be inclined to agree that object orientation didn’t quite solve the problem, it just localized it. To be sure, having things narrowed down from “all of the software” to “this messy class” was an improvement, but managing any level of complexity in state is still problematic.

I’m inclined to agree with the functional programming folks that the solution to this issue lies in small, stateless functions passing immutable data around, such that such state as we need is even further segmented and localized. Yet the languages that force one to do this are — let’s admit — far less popular than the ideologically “messy” languages we know and love like Python. However, with some thoughtful attention and lots of practice, we can write programs that are as easy to maintain as they are to understand.

You May Also Enjoy

Python Format Strings: From Beginner to Expert

Python Dataclass: Easily Automate Class Best Practices

Python Indexing and Slicing

Python Operators: The Building Blocks of Successful Code

Leave a Comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.