Python Functions Explained: Syntax, Parameters & Best Practices

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.

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.

def function_name(parameters):
"""Optional docstring describing the function."""
# function body
return value # optional

def greet(name):
"""Return a personalised greeting string."""
return f"Hello, {name}! Welcome to Python."
# Call the function
message = greet("Priya")
print(message) # Hello, Priya! Welcome to Python.
# Functions without a return statement return None implicitly
def 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.

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 TypeSyntaxDescription
Positionaldef f(a, b)Required; matched by position
Defaultdef f(a, b=10)Optional; uses default if not supplied
Keyword-onlydef f(a, *, kw)Must be passed by name after *
Arbitrary positionaldef f(*args)Collects extra positionals into a tuple
Arbitrary keyworddef f(**kwargs)Collects extra keyword args into a dict
def power(base, exponent=2):
"""Raise base to exponent. Default exponent is 2 (square)."""
return base ** exponent
print(power(3)) # 9 — uses default exponent
print(power(3, 3)) # 27 — overrides default
print(power(2, 10)) # 1024

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 matter
p = create_profile(age=28, name="Alice", role="Admin")
print(p) # {'name': 'Alice', 'age': 28, 'city': 'Unknown', 'role': 'Admin'}

def total(*numbers):
"""Sum any number of arguments."""
return sum(numbers)
print(total(1, 2, 3)) # 6
print(total(10, 20, 30, 40)) # 100
# Unpack a list into *args at the call site
values = [5, 15, 25]
print(total(*values)) # 45

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 site
config = {"host": "localhost", "port": 5432}
display_info(**config)

# Order rule: positional, *args, keyword-only, **kwargs
def 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}

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.

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 tuple
low, high = min_max([4, 1, 9, 2, 7])
print(f"Min: {low}, Max: {high}") # Min: 1, Max: 9

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 / b
print(divide(10, 2)) # 5.0
print(divide(10, 0)) # None

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, Union
def 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)) # 10
print(clamp(-5, 0, 10)) # 0
print(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].

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.

ScopeWhereExample
Local (L)Inside the current functionx = 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 namespacelen, print, range, etc.
x = "global" # Global scope
def outer():
x = "enclosing" # Enclosing scope
def inner():
x = "local" # Local scope
print(x) # local
inner()
print(x) # enclosing
outer()
print(x) # global

counter = 0
def increment():
global counter # declare intent to modify the global
counter += 1
increment()
increment()
print(counter) # 2
# nonlocal — modify an enclosing (but not global) variable
def make_counter():
count = 0
def step():
nonlocal count
count += 1
return count
return step
c = 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.

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.

# lambda parameters: expression
square = lambda x: x ** 2
print(square(5)) # 25
add = lambda a, b: a + b
print(add(3, 7)) # 10

students = [
{"name": "Alice", "grade": 88},
{"name": "Bob", "grade": 95},
{"name": "Carol", "grade": 72},
]
# Sort by grade descending
ranked = sorted(students, key=lambda s: s["grade"], reverse=True)
for s in ranked:
print(s["name"], s["grade"])
# Bob 95 / Alice 88 / Carol 72
numbers = [1, 2, 3, 4, 5, 6, 7, 8]
# map — apply function to each item
squares = list(map(lambda n: n**2, numbers))
print(squares) # [1, 4, 9, 16, 25, 36, 49, 64]
# filter — keep items where function returns True
evens = 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.

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.

prices = [100, 250, 75, 400, 30]
# Apply a 10% discount to all prices
discounted = 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 100
expensive = list(filter(lambda p: p > 100, prices))
print(expensive) # [250, 400]

from functools import reduce
# Multiply all numbers together
product = reduce(lambda acc, x: acc * x, [1, 2, 3, 4, 5])
print(product) # 120
# Flatten a list of lists
nested = [[1, 2], [3, 4], [5, 6]]
flat = reduce(lambda acc, lst: acc + lst, nested, [])
print(flat) # [1, 2, 3, 4, 5, 6]

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 function
double = make_multiplier(2)
triple = make_multiplier(3)
print(double(5)) # 10
print(triple(5)) # 15

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 adder
add5 = make_adder(5)
add10 = make_adder(10)
print(add5(3)) # 8
print(add10(3)) # 13
# Each closure has its own independent copy of 'n'
print(add5.__closure__[0].cell_contents) # 5
print(add10.__closure__[0].cell_contents) # 10

def make_memoised(func):
cache = {} # captured in closure
def wrapper(*args):
if args not in cache:
cache[args] = func(*args)
return cache[args]
return wrapper
def slow_square(n):
import time; time.sleep(0.01) # simulate slow computation
return n * n
fast_square = make_memoised(slow_square)
print(fast_square(9)) # computed -> 81
print(fast_square(9)) # from cache -> 81 (instant)

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.

import time
import functools
def 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 wrapper
@timer
def 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

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 decorator
@repeat(times=3)
def greet(name):
print(f"Hello, {name}!")
greet("World")
# Hello, World!
# Hello, World!
# Hello, World!

DecoratorModulePurpose
@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_cachefunctoolsAutomatic memoisation with size-limited cache
@functools.wrapsfunctoolsPreserve __name__, __doc__ of wrapped function
@dataclasses.dataclassdataclassesAuto-generate __init__, __repr__, __eq__

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.

def countdown(n):
"""Yield numbers from n down to 1."""
while n > 0:
yield n # pause here and send n to caller
n -= 1
for num in countdown(5):
print(num, end=" ") # 5 4 3 2 1
# Generators are lazy — values are produced only when requested
gen = countdown(3)
print(next(gen)) # 3
print(next(gen)) # 2
print(next(gen)) # 1
# print(next(gen)) # StopIteration — exhausted

def fibonacci():
"""Yield Fibonacci numbers indefinitely."""
a, b = 0, 1
while True:
yield a
a, b = b, a + b
# Take only the first 10
fib = fibonacci()
first_10 = [next(fib) for _ in range(10)]
print(first_10) # [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]

import sys
# List — holds all values in memory
squares_list = [x**2 for x in range(1_000_000)]
print(sys.getsizeof(squares_list), "bytes") # ~8 MB
# Generator — holds only the current state
squares_gen = (x**2 for x in range(1_000_000)) # generator expression
print(sys.getsizeof(squares_gen), "bytes") # ~112 bytes

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 case
print(factorial(5)) # 120
print(factorial(10)) # 3628800
def 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 result
nested = [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).

  1. Write a docstring for every function — at minimum one line explaining what it does.
  2. Keep functions small and focused: one function, one responsibility (Single Responsibility Principle).
  3. Use type hints for all parameters and return values to improve readability and catch bugs early.
  4. Prefer returning values over modifying mutable arguments — pure functions are easier to test.
  5. Use default parameter values for optional arguments, but never use mutable defaults like [] or {}.
  6. Prefer named/keyword arguments at the call site when a function has more than three parameters.
  7. Use @functools.lru_cache or @functools.cache for expensive recursive or repeated computations.
  8. Always use @functools.wraps inside decorators to preserve the wrapped function’s metadata.
  9. Name functions with verbs: calculate_tax, send_email, not tax or email.
  10. 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.

ConceptSyntax / Pattern
Define functiondef greet(name): return f’Hi {name}’
Default parameterdef f(x, y=10): …
*argsdef f(*args): sum(args)
**kwargsdef f(**kw): kw.get(‘key’)
Type hintsdef f(x: int) -> str: …
Return multiple valuesreturn a, b  (returns tuple)
Lambdafn = lambda x: x * 2
map()list(map(fn, iterable))
filter()list(filter(pred, iterable))
sorted() with keysorted(lst, key=lambda x: x.age)
Closuredef outer(n): def inner(x): return x+n; return inner
Basic decorator@my_decorator  def func(): …
Decorator w/ args@decorator(arg)  def func(): …
Generatordef gen(): yield value
Generator expression(x**2 for x in range(10))
lru_cache@functools.lru_cache(maxsize=128)
Global variableglobal x  (inside function)
Nonlocal variablenonlocal x  (inside nested function)

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.

Leave a Reply