1. Introduction
Functions are the single most important building block in Python. A function is a named, reusable block of code that performs a specific task, accepts input through parameters, and returns output. By wrapping logic in a function, you give it a name, make it testable in isolation, and allow it to be called from anywhere in your program.
Python treats functions as first-class objects, meaning they can be stored in variables, passed as arguments, returned from other functions, and even defined inside other functions. This makes Python exceptionally well-suited to functional programming patterns alongside its OOP capabilities.
This blog covers every dimension of Python functions: syntax, parameters, return values, scope, lambdas, higher-order functions, closures, decorators, generators, and best practices — with clear, working examples throughout.
2. Defining and Calling a Function
A function is defined using the def keyword, followed by the function name, parentheses for parameters, and a colon. The body is indented. Call a function by writing its name followed by parentheses.
Basic Syntax
def function_name(parameters): """Optional docstring describing the function.""" # function body return value # optional
A Simple Example
def greet(name): """Return a personalised greeting string.""" return f"Hello, {name}! Welcome to Python."# Call the functionmessage = greet("Priya")print(message) # Hello, Priya! Welcome to Python.# Functions without a return statement return None implicitlydef say_hello(): print("Hello!")result = say_hello() # prints Hello!print(result) # None
Naming convention: function names should be lowercase with underscores — calculate_tax, not CalculateTax or calculateTax. PEP 8 is the Python style standard.
3. Parameters and Arguments
Python offers five kinds of function parameters, giving you enormous flexibility in how functions are called. Understanding all five is key to writing idiomatic Python.
| Parameter Type | Syntax | Description |
| Positional | def f(a, b) | Required; matched by position |
| Default | def f(a, b=10) | Optional; uses default if not supplied |
| Keyword-only | def f(a, *, kw) | Must be passed by name after * |
| Arbitrary positional | def f(*args) | Collects extra positionals into a tuple |
| Arbitrary keyword | def f(**kwargs) | Collects extra keyword args into a dict |
3.1 Positional and Default Parameters
def power(base, exponent=2): """Raise base to exponent. Default exponent is 2 (square).""" return base ** exponentprint(power(3)) # 9 — uses default exponentprint(power(3, 3)) # 27 — overrides defaultprint(power(2, 10)) # 1024
3.2 Keyword Arguments
When calling a function, you can pass arguments by name in any order. This dramatically improves readability for functions with multiple parameters.
def create_profile(name, age, city="Unknown", role="User"): return {"name": name, "age": age, "city": city, "role": role}# Keyword arguments — order does not matterp = create_profile(age=28, name="Alice", role="Admin")print(p) # {'name': 'Alice', 'age': 28, 'city': 'Unknown', 'role': 'Admin'}
3.3 *args — Arbitrary Positional Arguments
def total(*numbers): """Sum any number of arguments.""" return sum(numbers)print(total(1, 2, 3)) # 6print(total(10, 20, 30, 40)) # 100# Unpack a list into *args at the call sitevalues = [5, 15, 25]print(total(*values)) # 45
3.4 **kwargs — Arbitrary Keyword Arguments
def display_info(**details): """Print any number of key-value pairs.""" for key, value in details.items(): print(f" {key}: {value}")display_info(name="Bob", language="Python", version=3.12)# name: Bob# language: Python# version: 3.12# Unpack a dict into **kwargs at the call siteconfig = {"host": "localhost", "port": 5432}display_info(**config)
3.5 Combining All Parameter Types
# Order rule: positional, *args, keyword-only, **kwargsdef full_example(a, b, *args, flag=False, **kwargs): print(f"a={a}, b={b}") print(f"extra positionals: {args}") print(f"flag={flag}") print(f"keyword extras: {kwargs}")full_example(1, 2, 3, 4, flag=True, x=10, y=20)# a=1, b=2# extra positionals: (3, 4)# flag=True# keyword extras: {'x': 10, 'y': 20}
4. Return Values
The return statement exits the function and sends a value back to the caller. A function can return any Python object: a number, string, list, tuple, dictionary, another function, or None.
Returning Multiple Values
Python functions can return multiple values by packing them into a tuple. The caller can unpack them directly.
def min_max(numbers): """Return both the minimum and maximum of a list.""" return min(numbers), max(numbers) # returns a tuplelow, high = min_max([4, 1, 9, 2, 7])print(f"Min: {low}, Max: {high}") # Min: 1, Max: 9
Early Returns for Guard Clauses
Use early return statements to handle edge cases at the top of a function, keeping the main logic clean and avoiding deep nesting.
def divide(a: float, b: float) -> float: if b == 0: return None # early return — guard clause return a / bprint(divide(10, 2)) # 5.0print(divide(10, 0)) # None
5. Type Hints and Annotations
Python 3.5+ supports type hints — optional annotations that document the expected types of parameters and return values. They do not enforce types at runtime but are used by linters, IDEs, and static analysis tools like mypy to catch bugs early.
from typing import Optional, List, Dict, Tuple, Uniondef calculate_average(numbers: List[float]) -> Optional[float]: """Return the mean of a list, or None if the list is empty.""" if not numbers: return None return sum(numbers) / len(numbers)def parse_record(raw: str) -> Dict[str, str]: """Split 'key=value,...' string into a dictionary.""" return dict(pair.split('=') for pair in raw.split(','))def clamp(value: float, lo: float, hi: float) -> float: """Clamp value between lo and hi inclusive.""" return max(lo, min(value, hi))print(clamp(15, 0, 10)) # 10print(clamp(-5, 0, 10)) # 0print(clamp(7, 0, 10)) # 7
Python 3.10+ allows X | Y union syntax instead of Union[X, Y], e.g. int | None instead of Optional[int].
6. Variable Scope — LEGB Rule
When Python looks up a variable name, it searches four scopes in order: Local, Enclosing, Global, and Built-in. This is called the LEGB rule.
| Scope | Where | Example |
| Local (L) | Inside the current function | x = 5 inside def f() |
| Enclosing (E) | Inside outer enclosing function(s) | x in outer() for inner() |
| Global (G) | Module-level (top of the file) | x = 5 at module level |
| Built-in (B) | Python’s built-in namespace | len, print, range, etc. |
x = "global" # Global scopedef outer(): x = "enclosing" # Enclosing scope def inner(): x = "local" # Local scope print(x) # local inner() print(x) # enclosingouter()print(x) # global
global and nonlocal Keywords
counter = 0def increment(): global counter # declare intent to modify the global counter += 1increment()increment()print(counter) # 2# nonlocal — modify an enclosing (but not global) variabledef make_counter(): count = 0 def step(): nonlocal count count += 1 return count return stepc = make_counter()print(c(), c(), c()) # 1 2 3
Best practice: avoid global and nonlocal where possible. Prefer passing values as arguments and returning results — it keeps functions pure and testable.
7. Lambda Functions
A lambda is an anonymous, single-expression function. It is written on one line with the lambda keyword and is often used as a short callback where a full def would be verbose.
Syntax
# lambda parameters: expressionsquare = lambda x: x ** 2print(square(5)) # 25add = lambda a, b: a + bprint(add(3, 7)) # 10
Common Use Cases with sorted(), map(), filter()
students = [ {"name": "Alice", "grade": 88}, {"name": "Bob", "grade": 95}, {"name": "Carol", "grade": 72},]# Sort by grade descendingranked = sorted(students, key=lambda s: s["grade"], reverse=True)for s in ranked: print(s["name"], s["grade"])# Bob 95 / Alice 88 / Carol 72numbers = [1, 2, 3, 4, 5, 6, 7, 8]# map — apply function to each itemsquares = list(map(lambda n: n**2, numbers))print(squares) # [1, 4, 9, 16, 25, 36, 49, 64]# filter — keep items where function returns Trueevens = list(filter(lambda n: n % 2 == 0, numbers))print(evens) # [2, 4, 6, 8]
Use lambdas for short, throw-away functions. If the logic exceeds one expression or needs a docstring, define a proper named function instead.
8. Higher-Order Functions
A higher-order function either accepts a function as an argument, returns a function, or both. Python’s built-in higher-order functions are map(), filter(), sorted(), and functools.reduce(). You can also write your own.
8.1 map() and filter() (and list comprehension equivalents)
prices = [100, 250, 75, 400, 30]# Apply a 10% discount to all pricesdiscounted = list(map(lambda p: round(p * 0.90, 2), prices))print(discounted) # [90.0, 225.0, 67.5, 360.0, 27.0]# Equivalent list comprehension (often preferred for readability)discounted = [round(p * 0.90, 2) for p in prices]# Keep only items over 100expensive = list(filter(lambda p: p > 100, prices))print(expensive) # [250, 400]
8.2 functools.reduce()
from functools import reduce# Multiply all numbers togetherproduct = reduce(lambda acc, x: acc * x, [1, 2, 3, 4, 5])print(product) # 120# Flatten a list of listsnested = [[1, 2], [3, 4], [5, 6]]flat = reduce(lambda acc, lst: acc + lst, nested, [])print(flat) # [1, 2, 3, 4, 5, 6]
8.3 Writing Your Own Higher-Order Functions
def apply_twice(func, value): """Apply func to value, then apply func to the result.""" return func(func(value))print(apply_twice(lambda x: x * 2, 3)) # 12 (3 -> 6 -> 12)print(apply_twice(lambda x: x + 10, 5)) # 25 (5 -> 15 -> 25)def make_multiplier(factor): """Return a function that multiplies its argument by factor.""" def multiplier(x): return x * factor return multiplier # return a functiondouble = make_multiplier(2)triple = make_multiplier(3)print(double(5)) # 10print(triple(5)) # 15
9. Closures
A closure is a function that remembers the variables from its enclosing scope even after the outer function has returned. Closures are created when an inner function references a variable from the outer function.
def make_adder(n): """Return a function that adds n to its argument.""" def adder(x): return x + n # 'n' is captured from the enclosing scope return adderadd5 = make_adder(5)add10 = make_adder(10)print(add5(3)) # 8print(add10(3)) # 13# Each closure has its own independent copy of 'n'print(add5.__closure__[0].cell_contents) # 5print(add10.__closure__[0].cell_contents) # 10
Practical Closure — Memoisation
def make_memoised(func): cache = {} # captured in closure def wrapper(*args): if args not in cache: cache[args] = func(*args) return cache[args] return wrapperdef slow_square(n): import time; time.sleep(0.01) # simulate slow computation return n * nfast_square = make_memoised(slow_square)print(fast_square(9)) # computed -> 81print(fast_square(9)) # from cache -> 81 (instant)
10. Decorators
A decorator is a higher-order function that wraps another function to add behaviour before and/or after it runs — without modifying the original function’s source code. Decorators are applied using the @ syntax.
10.1 Writing a Basic Decorator
import timeimport functoolsdef timer(func): """Measure and print how long func takes to run.""" functools.wraps(func) # preserves metadata of the wrapped function def wrapper(*args, **kwargs): start = time.perf_counter() result = func(*args, **kwargs) end = time.perf_counter() print(f"{func.__name__} took {end - start:.4f}s") return result return wrappertimerdef compute_sum(n): return sum(range(n))# @timer is equivalent to: compute_sum = timer(compute_sum)print(compute_sum(1_000_000))# compute_sum took 0.0312s# 499999500000
10.2 Decorator with Arguments
def repeat(times): """Call the decorated function 'times' times.""" def decorator(func): functools.wraps(func) def wrapper(*args, **kwargs): for _ in range(times): result = func(*args, **kwargs) return result return wrapper return decoratorrepeat(times=3)def greet(name): print(f"Hello, {name}!")greet("World")# Hello, World!# Hello, World!# Hello, World!
10.3 Common Built-in Decorators
| Decorator | Module | Purpose |
| @staticmethod | (built-in) | Method with no self or cls — pure utility |
| @classmethod | (built-in) | Method receives class as first argument (cls) |
| @property | (built-in) | Expose method as a read-only attribute |
| @functools.lru_cache | functools | Automatic memoisation with size-limited cache |
| @functools.wraps | functools | Preserve __name__, __doc__ of wrapped function |
| @dataclasses.dataclass | dataclasses | Auto-generate __init__, __repr__, __eq__ |
11. Generator Functions
A generator is a function that uses yield instead of return. When called, it returns a generator object that produces values lazily — one at a time on demand. Generators are memory-efficient for large or infinite sequences because they never build the entire sequence in memory at once.
Basic Generator
def countdown(n): """Yield numbers from n down to 1.""" while n > 0: yield n # pause here and send n to caller n -= 1for num in countdown(5): print(num, end=" ") # 5 4 3 2 1# Generators are lazy — values are produced only when requestedgen = countdown(3)print(next(gen)) # 3print(next(gen)) # 2print(next(gen)) # 1# print(next(gen)) # StopIteration — exhausted
Infinite Generator
def fibonacci(): """Yield Fibonacci numbers indefinitely.""" a, b = 0, 1 while True: yield a a, b = b, a + b# Take only the first 10fib = fibonacci()first_10 = [next(fib) for _ in range(10)]print(first_10) # [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]
Generator vs List — Memory Comparison
import sys# List — holds all values in memorysquares_list = [x**2 for x in range(1_000_000)]print(sys.getsizeof(squares_list), "bytes") # ~8 MB# Generator — holds only the current statesquares_gen = (x**2 for x in range(1_000_000)) # generator expressionprint(sys.getsizeof(squares_gen), "bytes") # ~112 bytes
12. Recursive Functions
A recursive function is one that calls itself. Every recursive function needs a base case (the stopping condition) and a recursive case (a smaller version of the same problem). Python’s default recursion limit is 1000 calls.
def factorial(n: int) -> int: """Return n! recursively.""" if n <= 1: # base case return 1 return n * factorial(n - 1) # recursive caseprint(factorial(5)) # 120print(factorial(10)) # 3628800def flatten(lst): """Recursively flatten a nested list of any depth.""" result = [] for item in lst: if isinstance(item, list): result.extend(flatten(item)) else: result.append(item) return resultnested = [1, [2, [3, 4], 5], [6, 7]]print(flatten(nested)) # [1, 2, 3, 4, 5, 6, 7]
For deep recursion or performance-critical code, prefer iterative solutions or use functools.lru_cache to memoize recursive calls (e.g., Fibonacci).
13. Best Practices for Python Functions
- Write a docstring for every function — at minimum one line explaining what it does.
- Keep functions small and focused: one function, one responsibility (Single Responsibility Principle).
- Use type hints for all parameters and return values to improve readability and catch bugs early.
- Prefer returning values over modifying mutable arguments — pure functions are easier to test.
- Use default parameter values for optional arguments, but never use mutable defaults like [] or {}.
- Prefer named/keyword arguments at the call site when a function has more than three parameters.
- Use @functools.lru_cache or @functools.cache for expensive recursive or repeated computations.
- Always use @functools.wraps inside decorators to preserve the wrapped function’s metadata.
- Name functions with verbs: calculate_tax, send_email, not tax or email.
- Limit function length to ~25-30 lines; if it grows longer, split it into helper functions.
Mutable default pitfall: def f(lst=[]) is dangerous because the list is shared across all calls. Use def f(lst=None): if lst is None: lst = [] instead.
14. Quick Reference Cheat Sheet
| Concept | Syntax / Pattern |
| Define function | def greet(name): return f’Hi {name}’ |
| Default parameter | def f(x, y=10): … |
| *args | def f(*args): sum(args) |
| **kwargs | def f(**kw): kw.get(‘key’) |
| Type hints | def f(x: int) -> str: … |
| Return multiple values | return a, b (returns tuple) |
| Lambda | fn = lambda x: x * 2 |
| map() | list(map(fn, iterable)) |
| filter() | list(filter(pred, iterable)) |
| sorted() with key | sorted(lst, key=lambda x: x.age) |
| Closure | def outer(n): def inner(x): return x+n; return inner |
| Basic decorator | @my_decorator def func(): … |
| Decorator w/ args | @decorator(arg) def func(): … |
| Generator | def gen(): yield value |
| Generator expression | (x**2 for x in range(10)) |
| lru_cache | @functools.lru_cache(maxsize=128) |
| Global variable | global x (inside function) |
| Nonlocal variable | nonlocal x (inside nested function) |
15. Conclusion
Functions are the fundamental unit of reusable code in Python. From a simple def with a few parameters to closures, decorators, and generators, every concept you have learned here is immediately applicable in real-world Python development.
The progression from basic to advanced is natural: start with clear, well-named functions with docstrings and type hints. Then reach for default parameters and *args/**kwargs when you need flexibility. Use lambdas for concise callbacks, closures when you need to capture state, decorators to add cross-cutting concerns, and generators to process large data efficiently.
Master these concepts and Python’s function toolkit will feel not just powerful, but intuitive.
Happy Coding!
Discover more from DataSangyan
Subscribe to get the latest posts sent to your email.