Functions
Args, kwargs, defaults, keyword-only, type hints — and the mutable-default bug that has made it onto every interview-questions list.
What you'll learn
- Positional vs keyword arguments, *args, **kwargs, and when each fits
- The mutable-default-argument trap and the one-line fix
- Keyword-only and positional-only parameters, and why APIs use them
- Type hints, docstrings, and returning multiple values cleanly
Before you start
A function in Python is more than a named block of code — it is a first-class object, something you can pass to another function, store in a list, and wrap with a decorator. The basic syntax takes a minute to learn. The depth, and the one notorious trap, all live in how Python handles arguments — and getting comfortable there is what lets you write APIs that read like plain English at the call site.
The basics
Read this small function for its anatomy as much as its behaviour:
def normalize_email(email: str) -> str:
"""Trim whitespace and lowercase an email address."""
return email.strip().lower()
print(normalize_email(" Aarav@Example.COM "))
aarav@example.com
Three pieces of that signature are worth naming. email: str is a type hint saying the parameter is meant to be a string; -> str is the return-type hint; and the triple-quoted line is the docstring, reachable at runtime as normalize_email.__doc__. One thing to be clear about: type hints are not enforced when the program runs — Python will happily pass a number in. They exist for tools (your editor, mypy, pyright) and for the humans reading the code, and modern codebases hint every public function precisely because that documentation pays for itself.
Positional versus keyword arguments
You may pass arguments by position or by name, and naming them at the call site turns the call into its own documentation:
def http_get(url, timeout=10, retries=3, verify_ssl=True):
return f"GET {url} (timeout={timeout}s, retries={retries}, ssl={verify_ssl})"
# Positional — order is everything.
print(http_get("https://example.com", 30, 5, False))
# Keyword — order is free, and the intent is explicit.
print(http_get("https://example.com", retries=5, timeout=30, verify_ssl=False))
# Mixed — positional first, then keyword.
print(http_get("https://example.com", 30, verify_ssl=False))
GET https://example.com (timeout=30s, retries=5, ssl=False)
GET https://example.com (timeout=30s, retries=5, ssl=False)
GET https://example.com (timeout=30s, retries=3, ssl=False)
Look at the first two lines: the same call, written positionally and by keyword, produces the same result — but only one of them tells the reader what 30, 5, and False mean without opening the docs. The third call shows the common middle ground: pass the obvious argument by position and the options by name. A good rule is exactly that — positional for the one or two arguments everybody recognises, keyword for the rest.
Default arguments — and the mutable trap
Here is the single most-asked Python interview question, and it follows from one rule: default values are evaluated once, when the function is defined — not afresh on each call. Someone who writes def f(x=[]) imagines a brand-new list every time, but there is only ever one list, created at definition and shared by every call that does not override it:
# BROKEN — the default list is created once and shared across calls.
def add_item(item, basket=[]):
basket.append(item)
return basket
print(add_item("milk"))
print(add_item("bread"))
print(add_item("eggs"))
print()
# FIXED — default to None, then build a fresh list inside.
def add_item_fixed(item, basket=None):
if basket is None:
basket = []
basket.append(item)
return basket
print(add_item_fixed("milk"))
print(add_item_fixed("bread"))
['milk']
['milk', 'bread']
['milk', 'bread', 'eggs']
['milk']
['bread']
The broken version accumulates because every call without an explicit basket reuses the one list built at definition time. The fix is mechanical — default to None, and construct the real list in the body, so each call gets its own. The picture makes the sharing concrete:
Every call without an explicit basket reuses that one list, so it grows across calls. Fix: default to None, build the list inside.
*args and **kwargs
When a function should take a variable number of arguments, *args gathers the extra positional ones into a tuple and **kwargs gathers the extra keyword ones into a dict. The names are only convention — Python cares about the * and **, not the words:
def log_event(event_type, *tags, **fields):
print(f"event: {event_type}")
print(f"tags: {tags}")
print(f"fields: {fields}")
log_event("login", "web", "mobile", user_id=42, country="IN")
# Forwarding every argument onward — the most common use of all.
def wrap(fn):
def wrapper(*args, **kwargs):
print(f"calling {fn.__name__} with {args}, {kwargs}")
return fn(*args, **kwargs)
return wrapper
@wrap
def add(a, b):
return a + b
print(add(2, 3))
event: login
tags: ('web', 'mobile')
fields: {'user_id': 42, 'country': 'IN'}
calling add with (2, 3), {}
5
See how tags arrived as a tuple and fields as a dict. And notice the second use, which is the one you will meet most: wrapper(*args, **kwargs) accepts anything and forwards it untouched to fn. That is the backbone of decorators, wrappers, and middleware — code that passes arguments along without needing to know what they are.
Keyword-only and positional-only parameters
Python lets you constrain how callers must pass each argument. Anything after a bare * is keyword-only; anything before a / is positional-only:
# amount is positional-only (before /); source and dest are keyword-only (after *).
def transfer(amount, /, *, source, dest):
return f"transfer {amount} from {source} to {dest}"
print(transfer(100, source="checking", dest="savings"))
# Both of these now fail, on purpose:
try:
transfer(amount=100, source="x", dest="y") # amount can't be a keyword
except TypeError as e:
print("TypeError:", e)
try:
transfer(100, "checking", "savings") # source/dest must be named
except TypeError as e:
print("TypeError:", e)
transfer 100 from checking to savings
TypeError: transfer() got some positional-only arguments passed as keyword arguments: 'amount'
TypeError: transfer() takes 1 positional argument but 3 were given
Why impose this? Two reasons, both about safety. Keyword-only stops callers from passing bare numbers and booleans positionally — transfer(100, source="x", dest="y") can never be misread the way transfer(100, "x", "y") could. And positional-only lets you rename a parameter later (its name is an implementation detail) without breaking anyone who passed it by keyword.
Returning multiple values
A function “returns several values” by returning a tuple, and the caller unpacks it. That is clean for two or three values; past that, give the result names:
def parse_url(url: str) -> tuple[str, str, str]:
"""Split a URL into (scheme, host, path) — heavily simplified."""
scheme, rest = url.split("://", 1)
host, _, path = rest.partition("/")
return scheme, host, "/" + path
scheme, host, path = parse_url("https://example.com/api/users")
print(scheme, host, path)
# With many fields, a NamedTuple makes the result self-describing.
from typing import NamedTuple
class ParsedURL(NamedTuple):
scheme: str
host: str
path: str
def parse_url_v2(url: str) -> ParsedURL:
scheme, rest = url.split("://", 1)
host, _, path = rest.partition("/")
return ParsedURL(scheme, host, "/" + path)
result = parse_url_v2("https://example.com/api/users")
print(result.host)
print(result)
https example.com /api/users
example.com
ParsedURL(scheme='https', host='example.com', path='/api/users')
The bare-tuple version works, but the caller has to remember that position 1 is the host. The NamedTuple version says result.host and even prints itself with field labels — far kinder to the next reader when the return has more than a couple of parts.
Two defaults worth knowing
A couple of behaviours surprise newcomers, and both follow from “a function is just an object”:
# A function with no explicit return gives back None.
def noisy(x):
print(x)
print(noisy(1)) # prints 1, then prints the return value: None
# Functions are first-class objects you can put in a list.
def add(a, b):
return a + b
for f in [add, len, str.upper]:
print(f.__name__)
1
None
add
len
upper
The first block is the classic “why did my function print None?” — because a function that never says return returns None, and you printed it. The second block shows functions sitting in a list and being inspected by name, which is exactly the property that makes decorators and callbacks possible.
In one breath
- Type hints (
x: int,-> str) document intent for tools and humans; they are not enforced at runtime. - Pass obvious arguments by position, the rest by keyword.
- Never use a mutable default — it is created once and shared; default to
Noneand build inside. *argscollects extra positionals into a tuple,**kwargsextra keywords into a dict — the forwarding pattern behind decorators.- A bare
*makes following parameters keyword-only;/makes preceding ones positional-only.
Practice
Quick check
What’s next
You now have the building blocks of any Python program. The last lesson of the core block is comprehensions — Python’s most idiomatic way to turn one iterable into a new collection.
Practice this in an interview
All questions*args collects extra positional arguments into a tuple; **kwargs collects extra keyword arguments into a dict. Use *args when the number of positional inputs is unknown, **kwargs when callers should be able to pass named options without modifying the function signature.
A closure is a function that retains access to variables from its enclosing scope even after that scope has finished executing. It is the mechanism behind decorators, factory functions, and stateful callbacks without needing a class.
First-class functions can be stored in variables, passed as arguments, returned from other functions, and placed in data structures — just like any other object. This is the foundation for higher-order functions, decorators, callbacks, and functional programming patterns in Python.
lambda, map, and filter are concise for simple one-liners passed to higher-order functions, but list/generator comprehensions are usually more readable. reduce belongs in functools and is best reserved for cases where the fold operation is non-trivial; an explicit loop is often clearer.
Python resolves names by searching four scopes in order: Local, Enclosing, Global, then Built-in. The first match wins. Assignment in a scope always creates or modifies a name in that scope unless global or nonlocal overrides this.
Naive recursive Fibonacci is O(2^n) because it recomputes the same subproblems exponentially. Memoization caches results of subproblems, reducing time to O(n) with O(n) space. Python's functools.lru_cache makes this a one-line decorator.
Positional arguments are matched by their position in the call; keyword arguments are matched by name and can appear in any order. Python 3 also introduced positional-only (/) and keyword-only (*) separators to enforce calling conventions in function signatures.
Using the csv module with a generator or a running accumulator keeps memory use constant — O(1) space — regardless of file size. This matters when files are larger than available RAM, a common situation in data engineering pipelines.