Why Functions Exist — The Core Idea
A function is that coffee machine. Write the logic once, name it, and call it whenever you need it. The rest of your code stops caring how it works — only what it returns.
In Python, a function is a named, reusable block of code that takes
zero or more inputs (called arguments), does some work, and optionally
gives back a result using return. Functions are the smallest unit of
abstraction in Python — every serious program is a tree of them.
Don't Repeat Yourself. If you find yourself copy-pasting the same three lines in five places, that's a function waiting to be born. Functions are how you turn "code" into "software" — they make change cheap and bugs rare.
The Anatomy of a Function — def, Parameters, return
Every Python function has the same skeleton. Learn this once and you know all of them.
Minimal Example — Greeting a User
def greet(name):
# Body indented with 4 spaces
message = f"Hello, {name}!"
return message
# Calling the function
result = greet("Aarav")
print(result)
A parameter is the variable name inside the function definition
(name above). An argument is the actual value you pass
when calling it ("Aarav"). People use these interchangeably in casual speech,
but interviewers love this distinction.
Functions Without return — What Actually Happens
def shout(word):
print(word.upper() + "!")
# No return statement
result = shout("hello")
print("result is:", result)
print("type is:", type(result))
print shows something on screen. return hands a value back
to the caller so it can be stored, passed, or used. Beginners frequently confuse these —
a function that only prints cannot be chained or tested.
Positional Arguments — Order Is Everything
When you call a function with values but no names, Python matches them by position — first argument to first parameter, second to second, and so on. Swap the order and you get a bug, not an error.
def divide(numerator, denominator):
return numerator / denominator
# Positional — order matters
print(divide(10, 2)) # 10 / 2 = 5.0 correct
print(divide(2, 10)) # 2 / 10 = 0.2 silent bug!
Python does not check whether the values you passed make sense — only that the count matches. Wrong-order positional arguments produce wrong answers with zero warnings. This is why keyword arguments exist.
Keyword Arguments — Name Them and Sleep Well
You can pass arguments by name instead of position using the
parameter=value syntax. Keyword arguments are self-documenting and
immune to the order-swap bug above.
def divide(numerator, denominator):
return numerator / denominator
# Keyword calls — order does not matter
print(divide(numerator=10, denominator=2)) # 5.0
print(divide(denominator=2, numerator=10)) # 5.0 — same!
# Mix positional then keyword (positional MUST come first)
print(divide(10, denominator=2)) # 5.0 OK
# This raises SyntaxError — keyword before positional
# print(divide(numerator=10, 2))
If a function takes more than two arguments, or any of the arguments
are booleans/flags, always use keyword arguments at the call site. Compare
send_email("a@b", "hi", True, False) versus
send_email(to="a@b", subject="hi", html=True, urgent=False). Only the second
is readable six months later.
Default Arguments — Sensible Fallbacks
You can give a parameter a default value in the function definition using
parameter=default. If the caller doesn't supply it, Python uses the default.
This makes functions flexible without forcing callers to specify every option.
def connect(host, port=8080, timeout=30, use_ssl=True):
print(f"Connecting to {host}:{port}")
print(f" timeout={timeout}s, ssl={use_ssl}")
# Only the required argument — rest use defaults
connect("api.example.com")
# Override just the port
connect("api.example.com", 9000)
# Override port and timeout, keep ssl=True
connect("api.example.com", 9000, 60)
def f(a=1, b) is a SyntaxError.
Calling Default Arguments Using Keywords — The Skip Trick
Here's where defaults and keyword calls combine into a superpower. What if you want to
keep the default for port and timeout, but change only use_ssl?
Without keyword arguments, you'd have to re-specify every earlier value. With them, you skip
straight to the one you care about.
| Call | What Happened |
|---|---|
connect("h", 8080, 30, False) | Had to repeat every default |
| Fragile | Change one default → break every call site |
| Unreadable | What does the False mean? |
| Call | What Happened |
|---|---|
connect("h", use_ssl=False) | Only override what changes |
| Robust | Adding new params never breaks callers |
| Self-documenting | The False has a name |
def connect(host, port=8080, timeout=30, use_ssl=True):
print(f"{host}:{port} ssl={use_ssl} t={timeout}")
# Keep port & timeout defaults, only change ssl
connect("api.example.com", use_ssl=False)
# Skip port, change timeout only
connect("api.example.com", timeout=60)
# Any combination — order does not matter
connect("api.example.com", use_ssl=False, timeout=60, port=443)
Look at any function in pandas, requests, or sklearn
— they take 10 to 30 parameters, almost all with defaults. You never pass them positionally.
You override two or three by name and let the rest sit at their defaults. This pattern
makes big APIs usable.
Variable-Length Arguments — *args
sum_all function. Some callers pass 2 numbers.
Some pass 5. Some pass 200. You cannot list 200 parameters. You need a bag that
catches "however many numbers they threw at me" and lets you loop through them.
That bag is *args.
Prefixing a parameter with a single asterisk * tells Python: "catch all
remaining positional arguments and pack them into a tuple". The name
args is convention — you could call it *numbers or anything else.
The magic is the *.
def sum_all(*args):
print("args is:", args)
print("type is:", type(args))
total = 0
for n in args:
total += n
return total
print(sum_all(1, 2, 3))
print(sum_all(10, 20, 30, 40, 50))
print(sum_all()) # zero args is fine — empty tuple
Mixing Regular Parameters with *args
def log_event(level, *messages):
for msg in messages:
print(f"[{level}] {msg}")
log_event("INFO", "server started", "port 8080 open", "ready")
*
The same * can be used at the call site to unpack a list or tuple
back into positional arguments: sum_all(*[1, 2, 3]) is identical to
sum_all(1, 2, 3). Defining and calling are mirror images.
Keyworded Arguments — **kwargs
Double-asterisk ** is *args's cousin — it catches all remaining
keyword arguments and packs them into a dictionary. This
lets your function accept an open-ended set of named options without you having to declare
each one.
def create_user(username, **kwargs):
print(f"Creating user: {username}")
print("kwargs is:", kwargs)
print("type is: ", type(kwargs))
for key, value in kwargs.items():
print(f" {key} = {value}")
create_user(
"priya_92",
email="priya@example.com",
age=28,
country="India",
is_admin=False
)
| Feature | *args |
**kwargs |
|---|---|---|
| Catches | Extra positional arguments | Extra keyword arguments |
| Packed into | tuple | dict |
| Common name | args (convention) |
kwargs (convention) |
| Access pattern | for x in args: |
for k, v in kwargs.items(): |
Order in def |
Comes before **kwargs |
Must be last parameter |
The Full Parameter Order — Putting It All Together
When a function uses every kind of parameter at once, Python enforces a strict order in the definition. Memorize this sequence — every Python function you'll ever write follows it.
*args — variable positional**kwargs — variable keyworddef register(username, email, role="user", *permissions, **profile):
print(f"username : {username}")
print(f"email : {email}")
print(f"role : {role}")
print(f"permissions : {permissions}")
print(f"profile : {profile}")
register(
"rahul_dev", # username (positional)
"rahul@example.com", # email (positional)
"admin", # role (overrides default)
"read", "write", "delete", # *permissions -> tuple
country="India", # **profile -> dict
joined="2024-01-15"
)
Try to write def f(**kw, x) or def f(*args, x, y) and Python
refuses to even parse the file. The order is not a style guideline — it's grammar.
Argument Pass by Reference — How Python Actually Works
Python does neither pure "pass by value" nor pure "pass by reference". It does something called pass-by-object-reference. The name inside the function points to the same object the caller passed. Whether changes leak out depends entirely on whether that object is mutable or immutable.
Python passes the reference (address) of the object, not a copy of it. What happens next depends on whether you mutate that object in place, or rebind the local name to a new object.
Case 1 — Immutable Objects (int, str, tuple, float, bool)
def try_change(x):
print(f" inside before: x = {x}, id = {id(x)}")
x = x + 100 # rebinds LOCAL x to a new int
print(f" inside after : x = {x}, id = {id(x)}")
number = 5
print(f"before call: number = {number}, id = {id(number)}")
try_change(number)
print(f"after call : number = {number}, id = {id(number)}")
Notice the ids. The parameter x started pointing at the very same integer
as number. But x = x + 100 created a new integer and
rebound the local name x to it. The caller's number was never touched
because integers are immutable — you can't modify 5 in place; you can only
create a new object.
Case 2 — Mutable Objects (list, dict, set, custom classes)
def add_item(cart):
print(f" inside before: cart = {cart}, id = {id(cart)}")
cart.append("laptop") # MUTATES the object in place
print(f" inside after : cart = {cart}, id = {id(cart)}")
my_cart = ["book", "pen"]
print(f"before call: cart = {my_cart}, id = {id(my_cart)}")
add_item(my_cart)
print(f"after call : cart = {my_cart}, id = {id(my_cart)}")
A function that takes a list and .append()s to it silently modifies the
caller's list. There is no warning, no return, no signal — the mutation just happens.
Two weeks later, someone can't figure out why their list has extra items.
Case 3 — Mutable Object, but Rebinding Instead of Mutating
def replace_cart(cart):
cart = ["phone", "charger"] # REBINDS local name to a NEW list
print(f" inside: cart = {cart}")
my_cart = ["book", "pen"]
replace_cart(my_cart)
print(f"after : cart = {my_cart}") # unchanged!
Same object type (list, which is mutable), but a completely different outcome. The
assignment cart = [...] did not modify the caller's list —
it just made the local name cart point at a brand-new list. The original
survived untouched.
| Object Type | Operation Inside Function | Does Caller See the Change? |
|---|---|---|
| int, str, tuple, float, bool | Any (they can't be mutated) | No |
| list, dict, set | .append(), .pop(), [i] = v, .update() | Yes — mutation leaks |
| list, dict, set | cart = [...] or d = {...} (rebinding) | No — new local name only |
| Custom class instance | Attribute assignment: obj.attr = value | Yes |
If a function takes a list or dict and you don't want caller-visible mutation,
copy it at the top: data = data.copy() for a shallow copy, or
copy.deepcopy(data) if it contains nested mutables. Alternatively,
accept a tuple instead of a list — tuples can't be mutated at all.
The Mutable Default Argument Trap
This is the single most infamous Python gotcha. Every Python developer meets it at least once — usually in production, at 2 AM.
# The trap:
def append_to(item, target=[]): # mutable default!
target.append(item)
return target
print(append_to(1))
print(append_to(2))
print(append_to(3))
Each call was expected to return a fresh [item]. Instead, the same list is
reused and grows across calls. Why? Because default values are evaluated
once, at function-definition time. That one list object is bound as the
default forever — every call that doesn't supply target mutates the same object.
None as Sentinel
Set the default to None and create a fresh mutable inside the body.
This creates a new list on every call and completely sidesteps the trap.
# The correct pattern:
def append_to(item, target=None):
if target is None:
target = [] # fresh list every call
target.append(item)
return target
print(append_to(1))
print(append_to(2))
print(append_to(3))
A Complete Real-World Example — Building a Flexible Logger
Let's combine everything: required parameters, defaults, keyword calls, *args,
**kwargs, and safe handling of mutable inputs.
import datetime
def log(message, *tags, level="INFO", timestamp=True, **metadata):
"""
Log a message with optional tags, level, timestamp, and metadata.
- message : required, the log body
- *tags : any number of string tags
- level : keyword-only defaulted string (INFO, WARN, ERROR)
- timestamp : whether to prepend a timestamp
- **metadata: any extra key=value context (user_id, request_id, ...)
"""
parts = []
if timestamp:
now = datetime.datetime.now().strftime("%H:%M:%S")
parts.append(f"[{now}]")
parts.append(f"[{level}]")
if tags:
parts.append("#" + " #".join(tags))
parts.append(message)
if metadata:
meta_str = " ".join(f"{k}={v}" for k, v in metadata.items())
parts.append(f"| {meta_str}")
print(" ".join(parts))
# Simple call — only the required argument
log("Server started")
# With tags (variable positional)
log("Cache miss", "redis", "user-profile")
# Override defaults using KEYWORDS — skip timestamp change
log("Login failed", level="WARN")
# Everything at once
log(
"Payment declined",
"stripe", "billing", # *tags
level="ERROR", # overrides default
timestamp=True, # keeps default
user_id=4821, # **metadata
request_id="req-9f3a",
amount=2500
)
Not one call passes True or "WARN" as a mystery positional
argument. Every optional value has a name at the call site. This is the pattern used
by every mature Python library — learn it and copy it.
Golden Rules
[],
not {}, not set(). Use None as sentinel and
create the mutable inside the body. This one rule kills an entire class of bugs.
f(x, y, True, False, None) is unreadable
six months later. f(x, y, save=True, verbose=False, on_error=None) is not.
prints
cannot be composed, tested, or reused. return makes your function a tool.
print makes it a dead end.
def is fixed:
positional → defaults → *args → **kwargs. Violating this order
raises SyntaxError. There are no exceptions.
*args only when the count really is unbounded (like
sum, print, or max). If you always expect 3 arguments,
declare 3 parameters — clearer signatures make better APIs.
**kwargs for pass-through and extensibility — for example,
to forward arbitrary options to a lower-level function. Don't overuse it as a substitute
for a well-designed signature; it hides what the function actually accepts.