Part 6 of a beginner-friendly series on learning Python from scratch.
In Part 5, we learned to organize data with lists, dictionaries, and other collections. Now it's time to organize our code itself.
A function is a reusable block of code that performs a specific task. Instead of writing the same code multiple times, you write it once in a function, then call that function whenever you need it. This is the foundation of writing clean, maintainable programs.
Defining and Calling Functions
The basics
def greet():
print("Hello, World!")
greet() # Call the function
Use def to define a function. The function name is followed by parentheses and a colon. The indented block below is the function's body.
When you call the function (by typing its name with parentheses), Python runs the code inside it.
Functions with parameters
Most functions need information to work with. That's what parameters are for:
def greet(name):
print(f"Hello, {name}!")
greet("Ramesh") # Hello, Ramesh!
greet("Priya") # Hello, Priya!
name is a parameter (placeholder). When you call greet("Ramesh"), name becomes "Ramesh" inside the function.
Multiple parameters:
def add(x, y):
print(x + y)
add(5, 3) # 8
add(10, 20) # 30
Return values
A function can calculate something and give the result back to you with return:
def add(x, y):
return x + y
result = add(5, 3)
print(result) # 8
The return statement stops the function and sends a value back. The caller can then use that value.
def greet(name):
message = f"Hello, {name}!"
return message
greeting = greet("Ramesh")
print(greeting) # Hello, Ramesh!
A function can return multiple values as a tuple:
def get_user_info():
return "Ramesh", 25, "Chennai"
name, age, city = get_user_info()
print(name, age, city) # Ramesh 25 Chennai
Arguments: Positional vs Keyword
There are two ways to pass values to a function:
Positional arguments
Arguments are matched by position:
def describe(name, age):
print(f"{name} is {age} years old")
describe("Ramesh", 25) # Ramesh is 25 years old
describe(25, "Ramesh") # 25 is Ramesh years old (wrong order, confusing)
Order matters.
Keyword arguments
You can use parameter names to be explicit:
def describe(name, age):
print(f"{name} is {age} years old")
describe(age=25, name="Ramesh") # Ramesh is 25 years old
describe(name="Ramesh", age=25) # Ramesh is 25 years old (order doesn't matter)
Keyword arguments make code clearer and are less error-prone. Mix them freely:
describe("Ramesh", age=25) # First positional, then keyword
Default Arguments
You can give parameters default values — they're optional:
def greet(name, greeting="Hello"):
return f"{greeting}, {name}!"
print(greet("Ramesh")) # Hello, Ramesh!
print(greet("Ramesh", "Hi")) # Hi, Ramesh!
print(greet("Ramesh", greeting="Hey")) # Hey, Ramesh!
If the caller doesn't provide greeting, it defaults to "Hello".
Important rule: Parameters with defaults must come after parameters without defaults:
# Correct
def add(x, y, verbose=False):
result = x + y
if verbose:
print(f"{x} + {y} = {result}")
return result
# Wrong — SyntaxError
def add(x=0, y):
return x + y
*args — Variable Number of Arguments
Sometimes you want a function to accept any number of arguments. Use *args:
def add_many(*numbers):
total = 0
for num in numbers:
total += num
return total
print(add_many(1, 2, 3)) # 6
print(add_many(1, 2, 3, 4, 5)) # 15
print(add_many()) # 0 (no arguments)
*args (the name args is conventional) collects all extra arguments into a tuple. The * unpacks them.
def print_all(*items):
for item in items:
print(item)
print_all("apple", "banana", "cherry", 42)
# Prints each on its own line
**kwargs — Keyword Arguments as Dictionary
Similar to *args, use **kwargs to accept any number of keyword arguments:
def make_profile(**info):
for key, value in info.items():
print(f"{key}: {value}")
make_profile(name="Ramesh", age=25, city="Chennai")
# Output:
# name: Ramesh
# age: 25
# city: Chennai
**kwargs (convention) collects keyword arguments into a dictionary.
Combining everything
You can use *args and **kwargs together:
def my_function(required, *args, default_arg="default", **kwargs):
print(f"Required: {required}")
print(f"Args: {args}")
print(f"Default: {default_arg}")
print(f"Kwargs: {kwargs}")
my_function(1, 2, 3, 4, default_arg="custom", extra1="value1", extra2="value2")
# Output:
# Required: 1
# Args: (2, 3, 4)
# Default: custom
# Kwargs: {'extra1': 'value1', 'extra2': 'value2'}
Order: positional, *args, keyword defaults, **kwargs.
Scope and Variable Lifetime
Variables have scope — the region of code where they exist and are accessible.
Local scope
Variables created inside a function only exist inside that function:
def greet():
message = "Hello" # Local variable
print(message)
greet() # Hello
print(message) # NameError — message doesn't exist here
Global scope
Variables created outside functions are global:
message = "Hello" # Global variable
def greet():
print(message) # Can access global variables
greet() # Hello
print(message) # Hello
Modifying global variables
Be careful — inside a function, assignment creates a local variable:
count = 0
def increment():
count = count + 1 # UnboundLocalError — trying to use local before assignment
return count
increment()
To modify a global variable inside a function, use global:
count = 0
def increment():
global count
count = count + 1
return count
increment()
print(count) # 1
Tip: Generally, avoid modifying global variables. It makes code hard to understand. Instead, pass values in and return values out.
Lambda Functions — Quick Throwaway Functions
A lambda is a tiny anonymous function, useful for one-liners:
square = lambda x: x ** 2
print(square(5)) # 25
Syntax: lambda parameters: expression
Lambda functions are usually passed to other functions. For example, sorted() can take a function that determines the sort order:
people = [
{"name": "Ramesh", "age": 25},
{"name": "Priya", "age": 20},
{"name": "Arun", "age": 30}
]
# Sort by age
sorted_by_age = sorted(people, key=lambda person: person["age"])
print(sorted_by_age)
# [{'name': 'Priya', 'age': 20}, {'name': 'Ramesh', 'age': 25}, {'name': 'Arun', 'age': 30}]
Or with map() to transform a list:
numbers = [1, 2, 3, 4, 5]
squared = list(map(lambda x: x ** 2, numbers))
print(squared) # [1, 4, 9, 16, 25]
Lambda is elegant for simple operations. For anything complex, write a proper function.
Recursion — Functions Calling Themselves
A recursive function calls itself. It's powerful for solving problems that repeat the same pattern at smaller scales.
Every recursive function needs a base case (when to stop) and a recursive case (how to shrink the problem):
def countdown(n):
# Base case
if n == 0:
print("Blastoff!")
return
# Recursive case
print(n)
countdown(n - 1)
countdown(5)
# Output:
# 5
# 4
# 3
# 2
# 1
# Blastoff!
Without a base case, the function recurses infinitely and crashes:
def bad_countdown(n):
print(n)
bad_countdown(n - 1) # Never stops!
bad_countdown(5) # RecursionError: maximum recursion depth exceeded
Classic example: Factorial
def factorial(n):
# Base case
if n == 0 or n == 1:
return 1
# Recursive case
return n * factorial(n - 1)
print(factorial(5)) # 120 (5 * 4 * 3 * 2 * 1)
Recursion is elegant but can be slow and memory-intensive. Use it when the problem naturally breaks into recursive patterns (like tree structures), not for simple loops.
Generators and Iterators
A generator is a function that yields values one at a time, instead of returning them all at once. Use yield:
def count_up_to(n):
i = 1
while i <= n:
yield i
i += 1
for num in count_up_to(5):
print(num)
# Output:
# 1
# 2
# 3
# 4
# 5
The function pauses at yield and resumes where it left off. This is memory-efficient for large datasets:
# Without generator — creates entire list in memory
def range_list(n):
result = []
for i in range(n):
result.append(i)
return result
# With generator — creates values on-demand
def range_gen(n):
for i in range(n):
yield i
# Both work with for loops, but generator is more efficient
for num in range_list(1000000): # Creates 1 million item list
print(num)
for num in range_gen(1000000): # Creates values as needed
print(num)
Generators are a more advanced topic — understand them conceptually now; you'll use them more as you progress.
Practical Examples
Example 1: Simple calculator
def add(x, y):
return x + y
def subtract(x, y):
return x - y
def multiply(x, y):
return x * y
def divide(x, y):
if y == 0:
return "Cannot divide by zero"
return x / y
print(add(10, 5)) # 15
print(divide(10, 0)) # Cannot divide by zero
Example 2: Processing collections
def find_max(numbers):
if len(numbers) == 0:
return None
max_num = numbers[0]
for num in numbers:
if num > max_num:
max_num = num
return max_num
print(find_max([3, 7, 2, 9, 1])) # 9
Example 3: Flexible greeting
def create_greeting(*names, greeting="Hello", punctuation="!"):
message = f"{greeting}, {', '.join(names)}{punctuation}"
return message
print(create_greeting("Ramesh", "Priya"))
# Hello, Ramesh, Priya!
print(create_greeting("Alice", "Bob", greeting="Hi", punctuation="?"))
# Hi, Alice, Bob?
Why This Matters
Functions are how you stop repeating yourself. Instead of copying the same code 10 times, you write it once, call it 10 times. This saves time, prevents bugs, and makes changes easier.
The most common beginner mistakes:
- Forgetting parentheses when calling a function
- Using
returnto print (they're different — one sends a value back, one displays it) - Trying to use variables defined inside a function outside of it
- Infinite recursion (forgetting the base case)
- Confusing
*argswith*unpacking operator
What's Next
In Part 7, we explored with modules, errors, and files — using other people's code, handling problems gracefully, and reading/writing data to disk. Functions you write will often read or write files.
This is Part 6 of an 8-part beginner Python series. Catch up on Part 1: Getting Started & Syntax, Part 2: Variables, Data Types & Numbers, Part 3: Strings & Booleans, Part 4: Operators & Control Flow, and Part 5: Collections.
Top comments (1)
This is a really interesting claim, but it’s worth reading it with a bit of skepticism and context.
A 3B parameter model “beating Opus 4.5 on reasoning” is almost certainly not a general capability comparison — it usually means a very narrow benchmark, curated tasks, or specific evaluation setup where distillation, prompting, or dataset bias can heavily influence results.
That said, the underlying idea is still important: smaller models can absolutely punch above their weight when they’re:
fine-tuned on tightly scoped reasoning tasks
paired with strong inference-time techniques (self-consistency, tool use, chain-of-thought scaffolding)
or optimized for specific domains instead of general intelligence
The real takeaway isn’t “small model beats frontier model,” but rather that reasoning performance is increasingly shaped by system design, not just raw parameter count.
If anything, this trend reinforces where the field is heading: better orchestration and training strategy often matter more than scaling alone 🤝