Skip to content

Functions

Functions are one of the most important things you’ll learn in programming.

A function is simply a named block of code that does a specific job and can be used again and again whenever you need it.

Instead of writing the same code over and over, we write it once inside a function and just call that function whenever we need it.

Functions help you with:

  • Reusing code instead of writing it again and again
  • Keeping your code organized and broken into small parts
  • Making debugging and testing easier
  • Hiding complicated details behind a simple name

You create a function using the def keyword.

def greet(name):
print(f"Hello, {name}!")

Parts of a Function:

  • def -> the keyword that tells Python you’re creating a function
  • greet -> the name of the function (try to use lowercase_with_underscores style)
  • name -> a parameter (this is the input the function takes)
  • Function body -> the indented lines of code that run when the function is called

To actually run the function, you call it like this:

greet("Alice") # Output: Hello, Alice!

Good Habits to Follow:

  • Give your functions clear names that explain what they do
  • Try to make each function do just one job (this is called Single Responsibility)
  • Add docstrings so others know what your function does
  • Use type hints to make things clearer (works from Python 3.5 onward)

How a function call works behind the scenes

Section titled “How a function call works behind the scenes”

When you call a function, Python follows a simple set of steps:

  1. It creates a new frame (a small space in memory) just for that function
  2. It matches the arguments you passed to the function’s parameters
  3. It runs the code inside the function
  4. It sends back a result (if there is one)
  5. It removes that function’s memory space once it’s done
graph TD Start --> Call["Function<br/>Call"] Call --> Frame["Create<br/>Stack Frame"] Frame --> Execute["Execute<br/>Function Body"] Execute --> Return["Return<br/>Value"] Return --> End["✓ Complete"]

Python keeps track of function calls using something called a stack.

  • The function called last -> gets finished first
  • Once a function is done -> it gets removed from the stack
def f1():
print("f1 start")
f2()
print("f1 end")
def f2():
print("f2 running")
f1()
graph TD Start["f1()"] --> f1["f1 executes"] f1 --> f2Call["calls f2()"] f2Call --> f2["f2 executes"] f2 --> f2Return["f2 returns"] f2Return --> f1Continue["f1 resumes"] f1Continue --> End["✓ f1 returns"]
Top of Stack
------------
f2()
f1()
------------
Bottom

After f2() finishes:

Top of Stack
------------
f1()
------------
Bottom

Functions can take inputs (these are called parameters) and give back outputs (these are called return values).

def add(a, b):
return a + b
result = add(5, 3)
print(result) # Output: 8

Key Ideas to Understand:

  • Parameters -> the inputs that a function expects (a, b)
  • Arguments -> the actual values you give to the function when calling it (5, 3)
  • Return -> the output that gets sent back to whoever called the function (a + b)
  • If there’s no return statement -> Python automatically returns None

How Return Values Work:

def divide(a, b):
if b == 0:
return None # Error case
return a / b
result = divide(10, 2)
print(result) # 5.0

Returning More Than One Value:

def get_coordinates():
return 10, 20 # Returns a tuple
x, y = get_coordinates()
print(x, y) # Output: 10 20

You can give a parameter a default value, which makes it optional when calling the function.

def greet(name="Guest"):
print(f"Hello, {name}")
greet() # Uses default: Hello, Guest
greet("Sahil") # Overrides default: Hello, Sahil

Things to Keep in Mind:

  • Parameters with default values must come after the ones without default values
  • If you don’t pass a value -> Python uses the default one
  • If you do pass a value -> the default one gets ignored

Example Using a Mix of Parameters:

def create_user(username, email, role="user"):
print(f"User: {username}, Email: {email}, Role: {role}")
create_user("alice", "alice@example.com") # role="user"
create_user("bob", "bob@example.com", role="admin") # role="admin"

Sometimes you don’t know ahead of time how many arguments someone will pass into your function.

*args (Any Number of Positional Arguments)

Section titled “*args (Any Number of Positional Arguments)”

This lets you pass any number of positional arguments into a function.

def total(*args):
print(f"Args: {args}") # args is a tuple
return sum(args)
print(total(1, 2, 3, 4)) # Output: 10
print(total(5, 10, 15)) # Output: 30

Things to Remember:

  • *args collects all the extra arguments into a tuple
  • It must come after the normal parameters
  • The name args is just a common convention (you could also write *numbers, etc.)

**kwargs (Any Number of Keyword Arguments)

Section titled “**kwargs (Any Number of Keyword Arguments)”

This lets you pass any number of keyword arguments into a function.

def show(**kwargs):
for key, value in kwargs.items():
print(f"{key}: {value}")
show(name="Alice", age=30, city="NYC")
# Output:
# name: Alice
# age: 30
# city: NYC

Things to Remember:

  • **kwargs collects all the extra keyword arguments into a dictionary
  • It must come after *args and the normal parameters
  • The name kwargs is just a common convention (you could also write **options, etc.)
def process(required, default="value", *args, **kwargs):
print(f"Required: {required}")
print(f"Default: {default}")
print(f"Args: {args}")
print(f"Kwargs: {kwargs}")
process(1, 2, 3, 4, name="Alice", role="admin")
# Output:
# Required: 1
# Default: 2
# Args: (3, 4)
# Kwargs: {'name': 'Alice', 'role': 'admin'}
graph TD Input1["Input 1,2,3"] -->|*args| Tuple["Tuple<br/>(1,2,3)"] Input2["Input name=A,age=20"] -->|**kwargs| Dict["Dict<br/>{key:value}"] Tuple --> Process["Process & Use"] Dict --> Process

When a function calls itself, this is called recursion. It’s really important for solving problems using a divide-and-conquer approach.

def factorial(n):
if n == 1:
return 1
return n * factorial(n - 1)
print(factorial(5)) # Output: 120

Parts of Recursion:

  • Base case -> the condition that stops the recursion from continuing (like n == 1)
  • Recursive case -> where the function calls itself again, usually with a slightly different value

How factorial(3) Actually Works:

factorial(3)
= 3 * factorial(2)
= 3 * 2 * factorial(1)
= 3 * 2 * 1
= 6

Things to Keep in Mind:

  • If the recursion goes too deep, you can run into a stack overflow error
  • Python has a limit on how deep recursion can go (you can check it with sys.getrecursionlimit())
  • Recursion is not always faster than just using a loop
  • Use recursion for problems that are naturally recursive in nature (like going through a tree, or divide-and-conquer problems)
graph TD f3["factorial(3)"] --> f2["-> factorial(2)"] f2 --> f1["-> factorial(1)"] f1 --> R1["return 1"] R1 --> R2["2 x 1 = 2"] R2 --> R3["3 x 2 = 6"]
factorial(3)
factorial(2)
factorial(1)

After this, it works its way back step by step.

Lambda functions are tiny, nameless functions that you write in just one line. They’re also called anonymous functions.

add = lambda x, y: x + y
print(add(2, 3)) # Output: 5

How it’s written: lambda parameters: expression

Things That Make Lambda Special:

  • It doesn’t need a name
  • It can only hold a single expression (no multiple statements)
  • It’s mainly used for short and simple operations
  • It automatically returns the result of the expression, without needing a return keyword

Common Places You’ll See Lambda Used:

# With map()
numbers = [1, 2, 3, 4]
squared = list(map(lambda x: x**2, numbers))
print(squared) # [1, 4, 9, 16]
# With filter()
even_numbers = list(filter(lambda x: x % 2 == 0, numbers))
print(even_numbers) # [2, 4]
# With sorted()
students = [("Alice", 90), ("Bob", 85), ("Charlie", 95)]
sorted_by_score = sorted(students, key=lambda x: x[1])

Higher-Order Functions: map(), filter(), reduce()

Section titled “Higher-Order Functions: map(), filter(), reduce()”

These are functions that either take another function as input, or give back a function as output.

This applies a function to every single item in a sequence.

numbers = [1, 2, 3]
result = list(map(lambda x: x * 2, numbers))
print(result) # [2, 4, 6]

When to Use It: When you want to change every item in the same way

This picks out only the items that match a certain condition (the function should return True or False).

numbers = [1, 2, 3, 4, 5]
result = list(filter(lambda x: x % 2 == 0, numbers))
print(result) # [2, 4]

When to Use It: When you want to keep only the items that match a certain condition

This combines all the items into one single result, by applying an operation again and again.

from functools import reduce
numbers = [1, 2, 3, 4]
result = reduce(lambda x, y: x + y, numbers)
print(result) # 10 (1+2+3+4)

When to Use It: When you want to combine values together, like adding them up, multiplying them, or joining them

# Old way
result = list(map(lambda x: x * 2, numbers))
# Modern way (more readable)
result = [x * 2 for x in numbers]

These are values you can pass in from the terminal when you run your script.

import sys
print(sys.argv)

Running it from the Terminal:

Terminal window
python script.py hello world

Output:

['script.py', 'hello', 'world']

How sys.argv Works:

  • sys.argv[0] -> the name of the script (script.py)
  • sys.argv[1] -> the first argument you typed (hello)
  • sys.argv[2] -> the second argument you typed (world)

A Practical Example:

import sys
if len(sys.argv) < 2:
print("Usage: python script.py <name>")
sys.exit(1)
name = sys.argv[1]
print(f"Hello, {name}!")

A Better Way to Do This: You can use the argparse library for more advanced handling of arguments:

import argparse
parser = argparse.ArgumentParser(description='Greeting script')
parser.add_argument('name', help='Name to greet')
parser.add_argument('--greeting', default='Hello', help='Greeting word')
args = parser.parse_args()
print(f"{args.greeting}, {args.name}!")

Python follows something called the LEGB rule to figure out which variable you actually mean:

  1. Local -> Variables that exist inside the current function
  2. Enclosing -> Variables that exist in a function that wraps around the current one (used with nested functions)
  3. Global -> Variables that exist at the top level of your file
  4. Built-in -> Names that already come built into Python itself

Example:

x = "global" # Global scope
def outer():
x = "enclosing" # Enclosing scope
def inner():
x = "local" # Local scope
print(x)
inner()
print(x)
outer()
# Output:
# local
# enclosing

Good to Know:

  • Python looks for variables in this exact order: Local, then Enclosing, then Global, then Built-in
  • It uses whichever one it finds first
  • This helps you understand how variables get accessed and changed

A Common Mistake:

x = 10
def modify():
x = x + 1 # UnboundLocalError!
modify()
# Error: local variable 'x' referenced before assignment
graph TD Local["🔍 Local"] --> Enclosing["🔍 Enclosing"] Enclosing --> Global["🔍 Global"] Global --> Builtin["🔍 Built-in"]

This tells Python that a function wants to use a global variable, and that it’s allowed to change it.

x = 10
def change():
global x # Declare intent to modify global x
x = 20
change()
print(x) # Output: 20

When to Use It: When you need to change a value that exists outside the function, at the global level (though it’s usually better to avoid this if possible)

This tells Python that a function wants to use a variable from a function that wraps around it (an enclosing function).

def outer():
x = 10
def inner():
nonlocal x # Declare intent to modify enclosing x
x = 20
inner()
print(x) # Output: 20
outer()

When to Use It:

  • When you’re working with closures and need to change a variable that was captured from outside
  • When you need to modify a variable that belongs to a function wrapping around your current one

The return statement does two things at once:

  1. It sends a value back to whoever called the function
  2. It immediately stops the function from running any further
def check(n):
if n > 0:
return "Positive"
return "Negative"
print(check(5)) # Output: Positive
print(check(-3)) # Output: Negative

Something Important to Notice:

def example():
print("Start")
return "Result"
print("This won't execute") # Unreachable code
example()
# Output: Start

Using Multiple Return Statements:

def validate(x):
if x < 0:
return "Negative"
if x == 0:
return "Zero"
return "Positive"

Returning Early:

def process(data):
if not data:
return None # Early exit
# Process data...
return result

These are little notes written inside a function that explain what it does. They’re an important part of writing clean, professional code.

def add(a, b):
"""Returns sum of two numbers"""
return a + b

How to View a Docstring:

print(add.__doc__) # Returns: "Returns sum of two numbers"
help(add) # Displays docstring with other info

A More Detailed Docstring:

def calculate_average(numbers):
"""Calculate the average of a list of numbers.
Args:
numbers (list): A list of numeric values.
Returns:
float: The average of the numbers.
Raises:
ValueError: If the list is empty.
Examples:
>>> calculate_average([1, 2, 3])
2.0
"""
if not numbers:
raise ValueError("List cannot be empty")
return sum(numbers) / len(numbers)

Different Styles of Docstrings:

  • Google style (this one is recommended)
  • NumPy style
  • Sphinx style

Good Habit to Build: Every function that others will use should have a docstring that explains what it does, what it needs (parameters), what it gives back (return value), and any errors it might raise.

Knowing the difference between these two helps you write code that’s easier to test and predict.

A function is “pure” when it:

  • Always gives back the same output for the same input, every single time
  • Doesn’t cause any side effects (it doesn’t change anything outside of itself)
  • Same input -> same output, always
  • No side effects at all
  • Doesn’t change anything outside its own scope (meaning it doesn’t touch variables that live outside the function)
def add(x, y):
"""Pure function: always returns same result"""
return x + y
print(add(2, 3)) # Always 5
print(add(2, 3)) # Always 5

Why Pure Functions Are Good:

  • They’re easy to test
  • They behave in a predictable way
  • Their results can be saved and reused (cached)
  • They’re safe to use across multiple threads at once

A function is “impure” when it:

  • Can give back different results even with the same input
  • Changes something outside of itself (this is called a side effect)
  • Changes things outside its own scope
  • Can return different results for the same input
  • Has a side effect here, since it changes the global variable count
count = 0
def increment():
global count
count += 1
return count
print(increment()) # Returns 1
print(increment()) # Returns 2

Examples of Side Effects:

  • Changing global variables
  • Reading or writing files
  • Making network requests
  • Changing data in a database
  • Printing something to the console

Good Habit to Build: Try to keep impure functions to a minimum. Keep side effects separate from your main logic so your code is easier to test.