Fun with functions in Python
Author: John Kitchin ©2025
In [1]: import sys
print(f'''Python: {[Link]}''')
Python: 3.12.8 (main, Dec 19 2024, 14:22:58) [Clang 18.1.8 ]
Introductory topics in functions
Functions are used extensively in science and engineering computations. When you
want to solve a set of linear equations, you define the matrix and vector in the
A b
equation Ax = b , and then we call a function with arguments to get the solution for
A, b
x . When you solve a nonlinear algebra equation, we have to define a function that is
equal to zero at the solution and use that in a solver function. To solve an ordinary
differential equation, or to integrate an equation, we have to define a function for the
equations or integrand, and use that in an integration function. Functions are used in
optimization, to define constraints, and more.
You simply cannot escape the need for defining functions in Python. This booklet is here
to show you most of what you need to know about doing that. The booklet is organized
in basic, intermediate and advanced sections, and assumes you have some basic
familiarity with Python. When you are done reading this, you will have a more through
understanding of how to define functions in Python.
A function in a program is a reusable piece of code that uses zero or more arguments,
does something with those arguments, and usually returns the result of those actions.
When you use a function, we call it with the arguments.
Defining a function
There are two critical keywords to remember when defining a function: def and
return . You use def to define a function. You must provide a function name, and
zero or more arguments in a parenthesized argument list, and there must be a colon (:)
after that. The body of the function (all the actions) is indented, and the function ends
when the indentation goes back to the same level as the def keyword. Finally you have
to return the value you want.
Here is a small example that computes y(x) = 2x . We use func as the function name.
It takes one mandatory argument , and it returns . The argument is mandatory
x 2x
because there is no default value specified for it.
In [2]: def func(x):
return 2 * x
Let's see it in action:
In [3]: func(2)
Out[3]: 4
Since the function returns a value, we can save it in a variable and use it later.
In [4]: a = func(3)
print(a)
The argument above is called a positional argument, which means the 3 is assigned to x
by its position in the argument list. Here it is the first (and only) argument, so it is
assigned to the first (and only) variable in the argument list.
x
We can also use arguments by keyword. Here we are explicit that argument is .
x = 4
In [5]: func(x=4)
Out[5]: 8
What can go wrong with this simple function?
Forgetting the parentheses
If you forget to put parentheses after the function, you will see this strange looking
output.
In [6]: func
Out[6]: <function __main__.func(x)>
func points to a function, and it can not do anything if you do not provide some
arguments. Functions are considered first-class citizens in Python, which means you can
use their name in places you would normally use a variable, e.g. as an argument to
[Link] or [Link].solve_ivp . Here is an example of a
function that takes a function as an argument and approximates the derivative of that
function at a point. In this example, the function we use is just so the derivative
2x
should be ≈ 2.
In [7]: def deriv(f, x, dx=1e-6):
return (f(x + dx) - f(x)) / dx
deriv(func, 1)
Out[7]: 1.9999999998354667
Wrong number of arguments
It is an error to leave out the argument because there is no default value set for it in the
function definition.
In [8]: func()
---------------------------------------------------------------------------
TypeError Traceback (most recent call last)
Cell In[8], line 1
----> 1 func()
TypeError: func() missing 1 required positional argument: 'x'
Note this error message refers to a positional argument. In the examples above, we did
not say what the name of the argument was, so the argument we use in calling the
function is assigned to the variable by position. This is more important later when
x
there are multiple arguments.
It is also an error to use two arguments or more, because the function is only defined to
use one argument.
In [9]: func(1, 2)
---------------------------------------------------------------------------
TypeError Traceback (most recent call last)
Cell In[9], line 1
----> 1 func(1, 2)
TypeError: func() takes 1 positional argument but 2 were given
It is not ok to use multiple keyword arguments:
In [10]: func(x=1, x=2)
Cell In[10], line 1
func(x=1, x=2)
^
SyntaxError: keyword argument repeated: x
Or keyword arguments that are not defined.
In [11]: func(x=1, y=2)
---------------------------------------------------------------------------
TypeError Traceback (most recent call last)
Cell In[11], line 1
----> 1 func(x=1, y=2)
TypeError: func() got an unexpected keyword argument 'y'
Using different types of arguments
It is worth noting that we did not specify anything about the type of argument. It is
implied perhaps we mean it should be a number, but it is not necessarily an error to use
strings, or lists here. It is also not necessarily correct or what you want! Note in this case
it is not an error because multiplication of strings and lists by an integer is a defined
operation, which repeats them. In other functions this may in fact lead to an error.
In [12]: func('a')
Out[12]: 'aa'
In [13]: func([1]) # a list
Out[13]: [1, 1]
Error in the function
If you have an error in your function while it is executing, it will lead to an error in your
program. Suppose we define a function that is y(x) = 1/x . It works fine for many values
of .
x
In [14]: def func2(x):
return 1 / x
func2(2)
Out[14]: 0.5
It is an error for x = 0 though.
In [15]: func2(0)
---------------------------------------------------------------------------
ZeroDivisionError Traceback (most recent call last)
Cell In[15], line 1
----> 1 func2(0)
Cell In[14], line 2, in func2(x)
1 def func2(x):
----> 2 return 1 / x
ZeroDivisionError: division by zero
Forgetting to return a value
If you forget to return something, the function will just return None. Here you can see the
function was evaluated because it does print something. However, it returns None, so it
is not possible to do anything useful with the variable a in this case afterwards.
In [16]: def forgot_return(x):
print(x**2)
a = forgot_return(3)
print(a)
9
None
Intermediate topics in functions
Documenting your functions
It is common, and good practice to provide a documentation string in your function. This
is a string in the first line of the function body. It can be a single line or multiline string.
Here is an example.
In [17]: def somefunc(x):
'''Compute some function of x.
x should be a number
returns the function of x.'''
return x + 2 * x**3
These strings are useful because they declare your intention, and expectations in the
function. You can retrieve these later like this.
In [18]: help(somefunc)
Help on function somefunc in module __main__:
somefunc(x)
Compute some function of x.
x should be a number
returns the function of x.
Or in a notebook, you can access the function documentation like this:
In [19]: ?somefunc
There are many styles of documentation strings, see for example, the numpy standard or
the Python standard. These are usually for big libraries of code with many users and
developers. In a notebook environment, where the function is only used in the notebook,
it is less critical that you follow some standard. It is critical that you document what the
function is for somehow though, perhaps in the narrative text around where you use the
function, in a docstring, or in comments. Future you will be grateful for that when they
are trying to figure out what past you meant by the code.
Functions with multiple arguments
A function can have multiple arguments. You simply add them to the argument list in the
definition. For example, here we define a function to add two numbers. Here, each
argument is required when you call it, and they are assigned by position. That means
here the first argument goes into a and the second argument goes into b inside the
function. Note that a,b are not accessible outside the function.
In [20]: def func3(a, b):
print(f'a = {a}, b = {b}')
return a + b
func3(2, 3)
a = 2, b = 3
Out[20]: 5
It is possible to use keyword arguments here. If you use keywords for all the arguments,
you can put them in any order you want.
In [21]: func3(b=3, a=2)
a = 2, b = 3
Out[21]: 5
If you are mixing positional and keyword arguments though, the positional ones must
come first, then the keywords. It is an error to have positional keywords after a keyword
argument.
In [22]: func3(b=3, 2)
Cell In[22], line 1
func3(b=3, 2)
^
SyntaxError: positional argument follows keyword argument
Functions with arrays
When your function takes an array as an argument, you have to consider what the result
of the function should be. For example should the function work elementwise on an
array, returning a new array? This is called a vectorized, or vector function. Or, should it
perform some kind of aggregation operation, e.g. a dot-product, and return a scalar
number. Sometimes, functions can do either one depending on the type of argument
that is provided.
Suppose we need a function that returns the argument squared.
In [23]: def farr(x):
return x**2
If you give this function a number, it does what you expect.
In [24]: farr(4)
Out[24]: 16
If you use an array though, it returns an elementwise squared array. We can call this
function a vectorized function, which means if you give it an array, it outputs an array.
In [25]: import numpy as np
farr([Link]([1, 2, 3]))
Out[25]: array([1, 4, 9])
You have to think through what the output of a function like this should be, and if it is
what you need, since it will output a scalar in some cases, and an array in other cases.
Optional arguments
It is possible to make some arguments optional by providing default values in the
argument list. Then, if you provide a value for that argument, it will be used, and if not,
the default argument will be used. There are many argument signatures you can use
In [26]: def func4(a, b=3):
print(f'a = {a}, b = {b}')
return a + b
There is one required argument, which we provide in this example
In [27]: func4(2)
a = 2, b = 3
Out[27]: 5
Here we provide both arguments by position.
In [28]: func4(2, 2)
a = 2, b = 2
Out[28]: 4
Here we provide both arguments by keyword, and out of order. Of course you can
provide them in order too.
In [29]: func4(b=2, a=6)
a = 6, b = 2
Out[29]: 8
You can write as many arguments into your function definition as you want; some
functions have more than a dozen arguments, although often most of those have default
values.
Arbitrary positional arguments
Some functions do not have a fixed number of arguments. For example, suppose you
want to write a function that can add up any number of numbers? Sometimes you would
call the function with two numbers, other times three, etc. You can specify an arbitrary
number of positional arguments with *varname in the argument list. You can choose
any varname you want, and then in the body all the values used as arguments will be in
that name as a tuple. *args is a common choice for this name. You can then do
something with that tuple like iterate over them, sum them etc. Here is an example to
sum an arbitrary number of numbers.
In [30]: def func5(*numbers):
print(f'you entered {len(numbers)} arguments ({numbers})')
return sum(numbers)
func5(1, 2, 3)
you entered 3 arguments ((1, 2, 3))
Out[30]: 6
It is ok to use no arguments in this case.
In [31]: func5()
you entered 0 arguments (())
Out[31]: 0
You can use any number of arguments.
In [32]: func5(1, 5, 8, 9)
you entered 4 arguments ((1, 5, 8, 9))
Out[32]: 23
It is even possible to unpack an iterable as an argument. Here *b means unpack the list
as a sequence of arguments.
In [33]: b = [2, 4, 6]
func5(*b)
you entered 3 arguments ((2, 4, 6))
Out[33]: 12
Arbitrary keyword arguments
It is also possible to define a function with arbitrary keywords. You use the **kwargs
syntax in the argument list. Then, all the extra keyword arguments are available in the
kwargs variable as a dictionary in the body. This is most often done to pass options to
another function, like a solver or plot. Here is an example where we pass options for the
line width and marker size to the plot function inside another function. You pass the
dictionary to another function with the **kwargs syntax, which means to unpack the
dictionary in kwargs to keyword arguments.
In [34]: import numpy as np
import [Link] as plt
[Link]['[Link]'] = 150 # this makes higher resolution figures tha
[Link]['[Link]'] = 'white'
def func6(**kwargs):
x = [Link](0, 1)
y = [Link](x)
return [Link](x, y, **kwargs)
func6(lw=3, ms=2);
Putting the argument types together
It is possible to combine all these types. Let us write a function that plots multiple values
of as a function of , with a default style, and arbitrary keyword arguments for
y x
customizing the plot. There are some things you have to know here about plotting:
1. To plot multiple curves, they must be arranged as columns in a 2d array.
y
2. When you make an array of multiple curves, each curve is in a row.
y
3. So, we transpose .T the array to convert them from rows to columns.
In [35]: def func7(x, *y, style='.-', **kwargs):
return [Link](x, [Link](y).T, style, **kwargs)
We will use this data for plotting:
In [36]: x = [Link](0, 1)
y1 = [Link](x)
y2 = [Link](0.5 * x)
Here are the default settings.
In [37]: func7(x, y1, y2)
[Link]('x')
[Link]('y');
Here we customize the style (note you must use a keyword for this, so Python knows it is
not another positional argument), the marker size ( ms ) and line width ( lw ).
In [38]: func7(x, y1, y2, style='.--', ms=1, lw=4);
[Link]('x')
[Link]('y');
Multiple return values
A function can return as many results as you specify, and in any form you specify. You
can simply separate the return values by commas, and the result returned will be a tuple
with those values.
In [39]: def func8(x):
return x, x**2, x**3
func8(2)
Out[39]: (2, 4, 8)
If it matters, you can make it a list:
In [40]: def func9(x):
return [x, x**2, x**3]
func9(2)
Out[40]: [2, 4, 8]
Or an array:
In [41]: def func10(x):
return [Link]([x, x**2, x**3])
func10(2)
Out[41]: array([2, 4, 8])
You can also unpack the results returned into variables. If you know how many outputs
there will be, you can define that many variables and unpack the result into them.
In [42]: x1, x2, x3 = func10(2)
x1
Out[42]: np.int64(2)
Advanced topics in functions
Variable scope
We have not talked about the scope of variables yet. The scope means where is the
value of a variable accessible, and what value does it have in that scope.
The value of an argument to a function is only available inside the function, even if there
is another variable by that name outside the function.
This example shows that we have a variable a defined outside the function, and an
argument a in the function. The value of a is temporarily shadowed inside the
function so it has a different value inside the function. Once the function is done though,
the value outside the function has not changed.
In [43]: a = 4
print(f'before a={a}')
def scope1(a):
print(f' in function a={a}')
scope1(2)
print(f'after a={a}')
before a=4
in function a=2
after a=4
If you define a variable outside a function, it is often possible to use the variable inside a
function.
In [44]: a = 4
print(f'before a={a}')
def scope2(x):
print(f' in function a={a}')
scope2(2)
print(f'after a={a}')
before a=4
in function a=4
after a=4
Finally, inside the function, you can temporarily change the value of the variable. When
the function is done though, the value goes back to what it was. This is potentially
confusing, and should probably be avoided.
In [45]: a = 4
print(f'before a={a}')
def scope2(x):
a = 5
print(f' in function a={a}')
scope2(2)
print(f'after a={a}')
before a=4
in function a=5
after a=4
Functions with multiple return statements
A function can have more than one return statement in it. For example, you may want to
return one thing is an argument is even, and another if it is odd. You can put more than
one return statements in a function definition. The first one that is reached will cause
the function to return. You have to be careful that you don't create paths that can never
be reached.
In [46]: def odd_p(x):
if (x % 2) == 1:
return True
else:
return False
# This return will never happen for any value of x
return 42
You can see this in action here:
In [47]: odd_p(1), odd_p(2)
Out[47]: (True, False)
Functions that return functions
It is possible to define functions that create and return functions. You simply define a
function inside the function, then return it by name. Here we define makef that takes
two arguments. It defines an inner function that only uses one argument , and uses the
x
a argument inside it.
In [48]: def makef(x, a):
def inner_func(x):
return a * x
return inner_func
You can assign the returned result to a variable, and that variable practically becomes a
callable function. Here f (x) = 4x.
In [49]: f = makef(x, 4)
f
Out[49]: <function __main__.makef.<locals>.inner_func(x)>
You can call it like any other function.
In [50]: f(2)
Out[50]: 8
Some people would call makef a factory function, as in it is a factory to make
functions.
Advanced unpacking concepts
This is not really a function topic, except that it is useful to unpack the results of
functions that return multiple values.
If you only care about one value that you want to unpack, you can use _ to ignore the
other values. Here, we use _ to ignore the first and last values, and only store the middle
value into a variable.
In [51]: _, x2, _ = [2, 4, 8]
x2
Out[51]: 4
It is possible to use the *var syntax in unpacking. It takes some practice thinking
through how the *var syntax unpacks. Here, we unpack a list of four numbers. Three
go into *x1 and one has to go to x .
In [52]: *x1, x = [1, 2, 3, 4]
x1, x
Out[52]: ([1, 2, 3], 4)
If you move them around, here one number goes to x1 and three go to *x .
In [53]: x1, *x = [1, 2, 3, 4]
x1, x
Out[53]: (1, [2, 3, 4])
You can get the first and last number like this.
In [54]: x1, *x, x4 = [1, 2, 3, 4]
x1, x, x4
Out[54]: (1, [2, 3], 4)
You can also combine this with *_ to ignore the middle numbers.
In [55]: x1, *_, x4 = [10, 2, 3, 40]
x1, x4
Out[55]: (10, 40)
Anonymous functions, aka lambda functions
So far, we have defined functions with a name in a special def block. There is a way to
write a function without a name, also known as an anonymous function. This is most
often done when you want a short function that is used as an argument in another
function, and that is not used anywhere else. It is also possible to save a lambda function
in a variable to use.
I rarely use lambda functions in my work because they are very limited. They do not have
documentation strings, and they can only be one line functions. Still, you are likely to see
them in other code, and they are sometimes useful.
lambda functions are written in the following syntax:
You start with the `lambda` keyword, then specify the arguments for the function,
followed by a colon, and then the body of the function in a single line. Here is an example
of a lambda function of one variable. Here I save these as variables so we can use them
in the examples.
In [56]: f = lambda x: x**2
f(3)
Out[56]: 9
Here is an example of a lambda function with two variables.
In [57]: f2 = lambda x, y: x + y
f2(3, 4)
Out[57]: 7
A typical way you might use a lambda function is like this. Here we use a lambda
function to find solve this equatione
x
. We don't have to define a formal function for
= 2
it and instead use a lambda function.
In [58]: from [Link] import root
import numpy as np
root(lambda x: [Link](x) - 2, 1)
Out[58]: message: The solution converged.
success: True
status: 1
fun: [ 0.000e+00]
x: [ 6.931e-01]
method: hybr
nfev: 10
fjac: [[-1.000e+00]]
r: [-2.000e+00]
qtf: [-5.225e-11]
Partial functions
Suppose you have a function that takes multiple arguments, and you need to use it with
fixed values for some arguments in a scenario that requires a function with just one
argument. Here is an example function to show what we mean.
In [59]: def f(a, x, b):
return b + [Link](a * x) - 2
We can not use this function directly with root for example to find a value of x where
that function is equal to zero when a=1 , because root requires the variable we are
solving to for to be the first one, and the arguments to be second. We can use a
lambda function to create a helper function for this where we fix the value of a=1,
b=0 , and put x as the first and only argument.
In [60]: root(lambda x: f(1, x, 0), 1)
Out[60]: message: The solution converged.
success: True
status: 1
fun: [ 0.000e+00]
x: [ 6.931e-01]
method: hybr
nfev: 10
fjac: [[-1.000e+00]]
r: [-2.000e+00]
qtf: [-5.225e-11]
My preference is to actually make a new helper function like this. I think it is easier to
read, and test, although it is a little more verbose.
In [61]: def helper(x):
return f(1, x, 0)
root(helper, 1)
Out[61]: message: The solution converged.
success: True
status: 1
fun: [ 0.000e+00]
x: [ 6.931e-01]
method: hybr
nfev: 10
fjac: [[-1.000e+00]]
r: [-2.000e+00]
qtf: [-5.225e-11]
It is also possible to use [Link] for this, but it is a little subtle. This function
also serves the same purpose as the lambda function above in creating a new function
from an existing function with fixed argument values. The subtlety here is understanding
how to construct the fixed arguments. Here we have arguments (a, x, b) . In
partial we have to use a positional arg for a , and a keyword arg for b . That way
the argument list to the resulting function is created correctly when a positional
argument is given to it. You cannot use a keyword argument on a because in the partial
function, when you use a positional argument, it things it is the first argument and will try
to assign it to a twice. I know, it is confusing, and I don't use this a lot. I include it here
for completeness, and because eventually you may see it or want to do something more
sophisticated in your work.
In [62]: from functools import partial
g = partial(f, 1, b=0)
g(1)
Out[62]: np.float64(0.7182818284590451)
In [63]: root(partial(f, 1, b=0), 1)
Out[63]: message: The solution converged.
success: True
status: 1
fun: [ 0.000e+00]
x: [ 6.931e-01]
method: hybr
nfev: 10
fjac: [[-1.000e+00]]
r: [-2.000e+00]
qtf: [-5.225e-11]
Function gotchas
There are some ways you can make functions that do not behave like you might expect.
Using mutable default arguments
It is a mistake to use a mutable argument (e.g. a list) as a default. Say we want to have a
function that appends the first argument to the second argument, and we treat the
second argument as an empty list if it is not defined. The first time you run it, it looks ok.
In [64]: def gotcha1(x, a=[]):
a += [x]
return a
gotcha1(1)
Out[64]: [1]
Here is a surprise though. If you call it again, instead of [2] as you might expect here,
you get the result appended to the first result .
In [65]: gotcha1(2)
Out[65]: [1, 2]
In [66]: gotcha1(3)
Out[66]: [1, 2, 3]
The issue here is that only one list is created for the optional second argument, and not
a new list each time.
The correct way to write this function is:
In [67]: def correct(x, a=None):
if a is None:
a = []
a += [x]
return a
Now you can call it as many times as you want and get the right answer.
In [68]: correct(1), correct(2), correct(3)
Out[68]: ([1], [2], [3])
And of course we can provide a value for the second argument too.
In [69]: correct(3, [1, 2])
Out[69]: [1, 2, 3]
About the author
John Kitchin earned a B.S. in Chemistry from North Carolina State University in 1996. He
worked at Lord Corporation in Cary, NC for a year and half before going to the University
of Delaware in 1999 to earn an M.S. in Materials Science (2002) and a PhD in Chemical
Engineering (2004). He did postdoctoral work in theoretical physics at the Fritz Haber
Institut in Berlin, Germany before joining the Department of Chemical Engineering at
Carnegie Mellon University in 2006. Today he is a full professor of Chemical Engineering
at Carnegie Mellon University.
Prof. Kitchin's research has spanned electrochemistry, catalysis, CO2 capture, molecular
simulation, data science and machine learning. He as written many peer-reviewed
publications in these areas. Throughout his career Python and computation has played a
prominent role. He has taught a broad range of courses that integrate Python into
engineering computations and problem solving including undergraduate courses in
reaction engineering, Master's level core courses in mathematical modeling, and
graduate electives in molecular simulation. You can find more about his work at
[Link]
License and use of material in this book
The narrative text in this book is copyrighted by the author. You cannot copy or
distribute it without written permission from the author.
The code in this book is licensed under a Creative Commons Attribution-ShareAlike
license. That means you are free to use the code and copy the code, including for
commercial work but if you distribute it you must do that with a license similar to CC-BY-
SA, and you must provide attribution in the distribution to this work.
Disclaimer
The author has tried to ensure the examples and code in this document are correct. The
author assumes no responsibility for damages resulting from the use of the information
contained in this booklet.