A function is a mapping of zero or more input parameters to zero or more output parameters.
The advantages of using functions are:
- code organization
- reducing duplication of code
- decomposing complex problems into simpler pieces
- improving clarity of the code
- reuse of code
- information hiding
Functions in Python are first-class citizens. It means that functions have equal
status with other objects in Python. Functions can be assigned to variables, stored
in collections, or passed as arguments. This brings additional flexibility to the language.
Function definition
Kinds of functions
Third-party functions
Docstrings
Instance, class, plain, inner functions
Functions are objects
Function scope
Implicit arg value
Unpacking
Passing by reference
Global variables
The pass keyword
Returning values
Arbitrary number of args
Nested functions
Passing functions as parameters
Function redefinition
Function overloading
No function hoisting
Collection of functions
Annotations
Functions are defined with the def keyword.
# fahrenheit.py
def cel_to_fahr(c):
return c * 9/5 + 32
print(cel_to_fahr(100))
print(cel_to_fahr(0))
print(cel_to_fahr(30))- built-in readily available
- standard in modules
- custom
from math import sqrt
def cube(x):
return x * x * x
print(abs(-1))
print(cube(9))
print(sqrt(81))Third-party functions defined in external modules.
For instance, the numpy module.
$ pip install numpy
import numpy as np
# generate 10 random integers from 1 to 100
r_vals = np.random.randint(1, 100, 10)
print(r_vals)
# generate 1 random integer from 1 to 10
r_vals2 = np.random.randint(1, 10)
print(r_vals2)
# generate an array of random integers
r_vals3 = np.random.randint(5, size=(2, 4))
print(r_vals3)A docstring is a string literal placed as the first statement inside a function,
class, or module. It describes what the function does, its parameters, its return
value, and any exceptions it may raise. The conventions follow PEP 257.
# docstrings.py
def celsius_to_fahrenheit(celsius):
"""
Convert temperature from Celsius to Fahrenheit.
Args:
celsius (float): Temperature in degrees Celsius.
Returns:
float: Temperature in degrees Fahrenheit.
"""
return celsius * 9/5 + 32
# Accessing the docstring
print(celsius_to_fahrenheit.__doc__)
# The built-in help() function displays the docstring nicely.
help(celsius_to_fahrenheit)Good docstrings make code more readable and enable tools like pydoc or IDE
hints to provide instant documentation.
Functions defined inside classes are member functions. They are
often called methods.
class Info:
def say(self):
print('This is Info class')
class Some:
@staticmethod
def f():
print ("f() static method")
def f():
print ("f() plain function")
def g():
def f():
print ("f() inner function")
f()
i = Info()
i.say()
Some.f()
f()
g()self is a reference to the current instance of a class. It's how an
object refers to itself, allowing each instance to access its own data
and methods.
class Circle:
def __init__(self, radius):
self.radius = radius
def area(self):
return 3.14 * self.radius ** 2
def circumference(self):
return 2 * 3.14 * self.radius
c = Circle(5)
print("Area:", c.area())
print("Circumference:", c.circumference())
c2 = Circle(10)
print("Area of c2:", c2.area())
print("Circumference of c2:", c2.circumference())self must be the first parameter in every instance method — it's a
Python convention (you could name it anything, but self is universal)
You never pass it manually — Python injects it automatically when you call c.area()
It's not a keyword — it's just a strongly followed naming convention.
Object attributes are accessed with the dot operator.
The object is the mother of all objects in Python; every Python
object implicitly derives from object.
# functions in Python are objects
def f():
"""This function prints a message """
return 'f() function'
print(isinstance(f, object))
print(id(f))
print(f())
print(f.__doc__)
print(f.__name__)In Python, everything is an object.
#!/usr/bin/env python
import sys
def f():
pass
print(type(1))
print(type(""))
print(type([]))
print(type({}))
print(type(()))
print(type(object))
print(type(f))
print(type(sys))Variables defined inside a function have a function scope. They are also
called local variables.
# a local variable is valid in the
# function scope
name = "Jack"
def f():
name = "Robert"
print("Within function", name)
print("Outside function", name)
f()def power(x, y=2):
r = 1
for i in range(y):
r = r * x
return r
print(power(3))
print(power(3, 3))
print(power(5, 5))# print(1, end=' ')
# print(2, end=' ')
# print(3, end=' ')
# print(4, end=' ')
# print(5, end=' ')
# print(6, end=' ')
print(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, sep=', ')
print('sky', 'blue', 'sink', 'pen', 'rock', sep='|')Unpacking is cutting an object (such as a list) into its elements.
It is also called destructuring. The _ operator is used to ignore the
value. The * operator eagerly takes all elements until the next
argument.
Unpacking function return values
def fn():
return [1, 2, 3, 4, 5, 6]
a, b, c, d, e, f = fn()
print(a, b, c, d, e, f)
a, *mid, b = fn()
print(a, mid, b)
a, b, c, _, _, _ = fn()
print(a, b, c)
a, b, *_ = fn()
print(a, b)
a, b, c, *d = fn()
print(a, b, c, d)
*a, b, c, d = fn()
print(a, b, c, d)Unpacking function arguments
After the star operator, only keyword arguments can be used.
def fn(a, b, c, d, e, f):
print(a, b, c, d, e, f)
def fn2(a, b, c, *d):
print(a, b, c, d)
def fn3(a, b, c, *d, e, f):
print(a, b, c, d, e, f)
fn(1, 2, 3, 4, 5, 6)
fn2(1, 2, 3, 4, 5, 6)
fn3(1, 2, 3, 4, e=5, f=6)A list can be destructured into elements and passed to the function.
def fn(a, b, c, d, e, f):
print(a, b, c, d, e, f)
def fn2(a, b, c, *d):
print(a, b, c, d)
def fn3(a, b, c, *d, e, f):
print(a, b, c, d, e, f)
vals = [1, 2, 3, 4, 5, 6]
fn(*vals)
fn2(*vals)
fn3(*vals, e=7, f=8)The **kwargs defines a keyworded, variable-length argument list
def display(**user):
for k, v in user.items():
print(f'{k}: {v}')
display(name='Lary Jones', age=43, sex='M')
display(name='Jone Doe', occupation='gardener', age=35)In Python, a function defined with only **kwargs like def display(**user)
accepts any number of keyword arguments and stores them as a dictionary.
Inside the function, user.items() is used to iterate over the dictionary,
returning each key-value pair as k and v, which are then printed using an f-string.
The real power here is flexibility — notice that the two calls to display() don't
need to pass the same arguments. The first call passes name, age, and sex, while
the second passes name, occupation, and age. The function handles both without
any changes, because **kwargs doesn't care how many or which keyword arguments are
passed — it simply captures whatever is given into a dictionary and works with it dynamically.
def show(a, *args, **kwargs):
print(a)
print(args)
print(kwargs)
show(1, 2, 3, 4, 5, 6, name='John Doe', occupation='gardener', age=34)In Python, functions can be made highly flexible using *args and **kwargs.
When defining a function like def show(a, *args, **kwargs), the regular parameter a
captures the first positional argument, *args collects any additional positional
arguments into a tuple, and **kwargs collects any keyword (named) arguments
into a dictionary. So calling show(1, 2, 3, name='John') would
give a=1, args=(2, 3), and kwargs={'name': 'John'}.
When it comes to positioning, the order strictly matters — regular parameters
must come first, followed by *args, and finally **kwargs must always be
last. Writing them in any other order, such as placing **kwargs before
*args, will raise a SyntaxError. This ordering rule ensures Python can
unambiguously separate plain values, extra positional values, and named values
from one another when the function is called.
This code demonstrates using ** to unpack a dictionary directly into
a function call. The display function expects three named parameters —
name, occupation, and age — and when called with **u1, Python unpacks
the dictionary so that each key maps to its matching parameter by name.
def display(name, occupation, age):
print(f'{name}, {occupation}, {age}')
u1 = {'name': 'Lary Jones', 'occupation': 'driver', 'age': 45}
u2 = {'name': 'Jone Doe', 'occupation': 'gardener', 'age': 35}
display(**u1)
display(**u2)This is essentially the reverse of **kwargs — instead of collecting keyword
arguments into a dictionary inside the function, here you're spreading
a dictionary out into keyword arguments at the call site. It's a clean and
practical pattern when your data is already stored in a dictionary and matches
the function's parameter names, saving you from having to write display(name=u1['name'], occupation=u1['occupation'], age=u1['age']) manually.
Python 3.8 introduced the / parameter to specify that all parameters before it
must be passed positionally. Similarly, a single * marks the beginning of
keyword-only parameters. This allows API designers to enforce a strict
calling convention.
# position_only_example.py
def describe_person(first, last, /, title='', *, age, city):
"""
Print a person's description.
first, last -> positional-only (before /)
title -> optional, positional or keyword (between / and *)
age, city -> keyword-only, required (after *)
"""
full = f"{title + ' ' if title else ''}{first} {last}"
print(f"{full}, age {age}, from {city}")
# Valid calls
describe_person("Jane", "Doe", "Dr.", age=30, city="New York") # title passed positionally
describe_person("John", "Smith", age=45, city="London") # title omitted, uses default ''
# Invalid calls
# describe_person(first="Jane", last="Doe", age=30, city="NY") # Error: first and last are positional-only
# describe_person("Jane", "Doe", 30, "NY") # Error: age and city must be passed as keywordsThe function signature def describe_person(first, last, /, title='', *, age, city)
demonstrates all three parameter zones in one definition. first and last sit
before the /, making them positional-only — they cannot be passed by name. title
sits between / and *, meaning it's flexible and can be passed either positionally
or as a keyword, and since it has a default value of '' it's also optional.
age and city come after the bare *, making them keyword-only — they must always
be passed by name like age=30, and since they have no defaults, they are required.
Using / and * together gives API designers precise control over how a function
must be called, prevents accidental reliance on parameter names that might change
in future versions, and makes the intended calling convention immediately visible
from the signature itself.
In Python, mutable objects are passed by reference as function arguments.
A list is passed by reference to the function. Therefore, the original list is
modified inside a function.
n = [1, 2, 3, 4, 5]
print('Original list:', n)
def f(x):
x.pop()
x.pop()
x.insert(0, 0)
print('Inside f():', x)
f(n)
print('After function call:', n)Global variables are defined in module.
Global variables are automatically valid in functions.
name = "Jack"
def f():
print("Within function", name)
print("Outside function", name)
f()The global keyword allows us to modify the variable outside
of the current scope.
name = "Jack"
def f():
global name
name = "Robert"
print ("Within function", name)
print("Outside function", name)
f()
print("Outside function", name)The pass keyword is used to define functions that are not yet implemented.
def f():
pass
def g():
pass
def h():
return 'h fun'
print(f())
print(g())
print(h())
print(f.__name__)
print(g.__name__)
print(h.__name__)The return keyword is used to return values from functions.
A function returns None if no keyword is defined.
# returning.py
def showMessage(msg):
print(msg)
def cube(x):
return x * x * x
x = cube(3)
print(x)
showMessage("Computation finished.")
print(showMessage("Ready."))We can return multiple values with tuples.
n = [1, 2, 3, 4, 5]
def stats(x):
mx = max(x)
mn = min(x)
ln = len(x)
sm = sum(x)
return mx, mn, ln, sm
mx, mn, ln, sm = stats(n) # deconstruction
print(stats(n))
print(mx, mn, ln, sm)With * operator, function can accept arbitrary number of arguments.
def do_sum(*args):
'''Function returns the sum
of all values'''
r = 0
for i in args:
r += i
return r
print(do_sum.__doc__)
print(do_sum(1, 2, 3))
print(do_sum(1, 2, 3, 4, 5))Nested/inner functions are functions defined inside other functions.
# nested functions are functions defined inside other
# functions
def myfun():
print("inside myfun()")
def greet():
return "greeting message"
def welcome():
return "welcoming message"
print(greet())
print(welcome())
print("inside myfun()")
myfun()Functions can be passed to other functions as parameters.
def inc(x):
return x + 1
def dec(x):
return x - 1
def operate(fun, x):
res = fun(x)
return res
x = 2
print(operate(inc, x))
print(x)
print(operate(dec, x))
print(x)Python allows to redefine existing function definitions.
# redefinition.py
from time import gmtime, strftime
def showMessage(msg):
print(msg)
showMessage('Ready.')
def showMessage(msg):
print(strftime('%H:%M:%S', gmtime()))
print(msg)
showMessage('Processing.')Python does not support traditional method overloading as found in Java or C#. In those
languages, you can define multiple methods with the same name as long as their parameter
types or counts differ, and the compiler picks the right one at call time. Python, being
dynamically typed and interpreted, takes a different approach: the last definition wins.
class A:
def foo(self, x):
print("one argument")
def foo(self, x, y): # silently replaces the first foo
print("two arguments")
a = A()
a.foo(1) # TypeError: foo() missing 1 required positional argument: 'y'
a.foo(1, 2) # "two arguments"The class body is just a namespace dict. Assigning foo twice simply overwrites the key.
There is no signature table to dispatch from.
The simplest and most Pythonic solution. Mark optional parameters with a sentinel
default (usually None) and branch inside the method.
class Formatter:
def render(self, text, width=None, align="left"):
if width is None:
return text
return f"{text:{align}{width}}"
f = Formatter()
print(f.render("hello")) # "hello"
print(f.render("hello", 10)) # "hello "
print(f.render("hello", 10, ">")) # " hello"When to use: whenever callers share a common "simple" case and a richer "extended" case.
Accept any number of positional or keyword arguments and dispatch on their count or content.
class Vector:
def __init__(self, *args):
if len(args) == 2:
self.x, self.y, self.z = *args, 0
elif len(args) == 3:
self.x, self.y, self.z = args
else:
raise TypeError(f"Expected 2 or 3 arguments, got {len(args)}")
def __repr__(self):
return f"Vector({self.x}, {self.y}, {self.z})"
print(Vector(1, 2)) # Vector(1, 2, 0)
print(Vector(1, 2, 3)) # Vector(1, 2, 3)When to use: when argument count varies naturally, or when you are wrapping another
callable. Avoid overusing it — it erases type information and makes signatures harder to understand.
The @overload decorator lets you declare multiple typed signatures for type checkers
(mypy, Pyright) while keeping a single runtime implementation. It provides no dispatch
logic — it is purely a static-analysis tool.
from typing import overload
class Parser:
@overload
def parse(self, data: str) -> list[str]: ...
@overload
def parse(self, data: bytes) -> list[bytes]: ...
def parse(self, data): # actual implementation — no decorator
if isinstance(data, bytes):
return data.split(b",")
return data.split(",")
p = Parser()
print(p.parse("a,b,c")) # ['a', 'b', 'c']
print(p.parse(b"a,b,c")) # [b'a', b'b', b'c']The overloaded stubs (ending in ...) are invisible at runtime — only the final, undecorated
definition executes. Type checkers use the stubs to validate call sites and infer return types correctly.
When to use: any time the return type depends on argument types. This is the standard
approach in well-typed library code.
singledispatch registers separate implementations per argument type and selects
the right one at call time based on the type of the first argument.
from functools import singledispatch
@singledispatch
def serialize(value):
raise NotImplementedError(f"No serializer for {type(value)}")
@serialize.register
def _(value: int) -> str:
return str(value)
@serialize.register
def _(value: list) -> str:
return "[" + ", ".join(serialize(v) for v in value) + "]"
@serialize.register
def _(value: str) -> str:
return f'"{value}"'
print(serialize(42)) # "42"
print(serialize("hi")) # '"hi"'
print(serialize([1, "two", 3])) # '[1, "two", 3]'New types can be registered from outside the module, making this pattern ideal for extensible
plugin-style APIs. When to use: library or framework code where callers need to register
handlers for their own types.
singledispatch does not work on methods directly because the dispatcher sees self as the
first argument. singledispatchmethod handles this correctly.
from functools import singledispatchmethod
class Renderer:
@singledispatchmethod
def draw(self, shape):
raise NotImplementedError(f"Cannot draw {type(shape)}")
@draw.register
def _(self, shape: int): # stand-in for a Circle radius
print(f"Drawing circle with radius {shape}")
@draw.register
def _(self, shape: str): # stand-in for a named shape
print(f"Drawing shape: {shape}")
r = Renderer()
r.draw(5) # Drawing circle with radius 5
r.draw("star") # Drawing shape: starWhen to use: the same scenarios as singledispatch, but inside a class. Still relatively
uncommon — prefer @overload + isinstance branching for simpler cases.
| Technique | Runtime dispatch | Type checker support | Extensible externally | Best for |
|---|---|---|---|---|
| Default arguments | No | ✓ (with @overload) |
No | Simple optional params |
*args / **kwargs |
Manual | Partial | No | Variable arity |
typing.overload |
No | ✓✓ | No | Typed signatures |
singledispatch |
Yes | ✓ | Yes | Library / plugin APIs |
singledispatchmethod |
Yes | ✓ | Yes | Same, inside a class |
In practice, most Python code reaches for default arguments first, because:
- The signature stays readable.
- IDEs and type checkers handle it well.
- There is no indirection to trace through.
@overload is the right next step when the return type changes with the input type and
you care about static correctness. singledispatch earns its place in extensible library
code. *args/**kwargs are valuable but should come with clear documentation, since
they erase the signature.
The deeper Python philosophy is duck typing: rather than dispatching on a type,
write code that works with any object that supports the needed protocol. A function that
calls .read() on its argument does not need to dispatch on File vs BytesIO vs StringIO — it just
calls .read() and lets the object sort it out.
Functions must be defined before being called. Python does not support
function hoisting like JavaScript.
def f1():
print("f1()")
f1()
# f2()
def f2():
print("f2()")Python is flexible, it allows to store functions in collections.
def f():
pass
def g():
pass
def h(f):
print (id(f))
a = (f, g, h)
for i in a:
print(i)
h(f)
h(g)Python allows you to annotate the parameters and return value of a function with
arbitrary expressions. When used to specify expected data types, they are called
type hints (standardised in PEP 484). The Python runtime does not enforce
type hints, but they serve as live documentation and are checked by static
analysers like mypy.
Enforce type checking in VS Code with "python.analysis.typeCheckingMode": "basic".
# annotations.py
def greet(name: str, times: int = 1) -> str:
"""Return a greeting repeated *times* times."""
return (f"Hello, {name}!\n" * times).rstrip()
print(greet("Alice", 2))
print(greet("Bob")) # uses default times=1
# Annotations are stored in the __annotations__ attribute
print(greet.__annotations__) Annotations can be any Python expression, not just types, though type hints are
by far the most common use case. They improve code clarity and enable better
autocompletion and linting in modern editors.