BASIC Python (Beginner Level) 📂 Basic Python Programming · 12 of 15 33 min read

Python Functions — Complete Guide

Master Python functions from the ground up: how def works, positional vs keyword arguments, default values, calling defaults using keywords, variable-length *args, keyworded **kwargs, the strict parameter order, and how Python actually passes arguments (pass-by-object-reference). Includes the infamous mutable-default trap, mutable vs immutable behavior, and eight golden rules every Python developer must know.

Section 01

Why Functions Exist — The Core Idea

The Coffee Machine on Every Desk
Imagine every employee in an office had to grind beans, boil water, measure grams, and steam milk each time they wanted coffee. Chaos. Instead, one machine sits in the corner. You press a button, and out comes coffee. You don't care how — only that giving it inputs (beans, water, milk) produces the right output.

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.

🌱
The DRY Principle

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.


Section 02

The Anatomy of a Function — def, Parameters, return

Every Python function has the same skeleton. Learn this once and you know all of them.

🛠️ The Four Building Blocks
def
The keyword that tells Python "a function definition starts here". Followed by the name.
name
The identifier you'll use to call it later. Use snake_case by convention.
params
The parameters in parentheses — the inputs the function expects. Can be empty.
return
The value the function hands back. Optional — if missing, Python returns None.

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)
OUTPUT
Hello, Aarav!
💡
Parameters vs Arguments — Know the Difference

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))
OUTPUT
HELLO! result is: None type is: <class 'NoneType'>
⚠️
Printing Is Not Returning

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.


Section 03

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!
OUTPUT
5.0 0.2
🚩
The Silent Bug Problem

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.


Section 04

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))
OUTPUT
5.0 5.0 5.0
The Golden Rule of Call-Site Clarity

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.


Section 05

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)
OUTPUT
Connecting to api.example.com:8080 timeout=30s, ssl=True Connecting to api.example.com:9000 timeout=30s, ssl=True Connecting to api.example.com:9000 timeout=60s, ssl=True
📌 Rules for Default Arguments
Rule 1
All defaulted parameters must come after all non-defaulted ones. def f(a=1, b) is a SyntaxError.
Rule 2
Defaults are evaluated once, at function-definition time — not on every call. This causes the famous mutable-default trap (Section 11).
Rule 3
Prefer immutable defaults: numbers, strings, tuples, None. Never [] or {} directly.

Section 06

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.

🚫 The Painful Way (positional only)
CallWhat Happened
connect("h", 8080, 30, False)Had to repeat every default
FragileChange one default → break every call site
UnreadableWhat does the False mean?
✅ The Clean Way (keyword override)
CallWhat Happened
connect("h", use_ssl=False)Only override what changes
RobustAdding new params never breaks callers
Self-documentingThe 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)
OUTPUT
api.example.com:8080 ssl=False t=30 api.example.com:8080 ssl=True t=60 api.example.com:443 ssl=False t=60
🔑
Why This Matters in Real Libraries

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.


Section 07

Variable-Length Arguments — *args

The Bag That Grows to Fit
You are writing a 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
OUTPUT
args is: (1, 2, 3) type is: <class 'tuple'> 6 args is: (10, 20, 30, 40, 50) type is: <class 'tuple'> 150 args is: () type is: <class 'tuple'> 0

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")
OUTPUT
[INFO] server started [INFO] port 8080 open [INFO] ready
📈
Unpacking — the Other Side of *

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.


Section 08

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
)
OUTPUT
Creating user: priya_92 kwargs is: {'email': 'priya@example.com', 'age': 28, 'country': 'India', 'is_admin': False} type is: <class 'dict'> 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

Section 09

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.

01
Regular positional parameters
The required inputs with no defaults. Callers must supply these.
02
Parameters with default values
Optional inputs. Callers can override by position or by keyword.
03
*args — variable positional
Catches any extra positional arguments beyond the named ones.
04
**kwargs — variable keyword
Catches any extra keyword arguments. Always the last parameter.
def 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"
)
OUTPUT
username : rahul_dev email : rahul@example.com role : admin permissions : ('read', 'write', 'delete') profile : {'country': 'India', 'joined': '2024-01-15'}
⚠️
Break the Order → SyntaxError

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.


Section 10

Argument Pass by Reference — How Python Actually Works

Sharing a Google Doc vs Emailing a PDF
When you email a PDF of a report, the recipient's edits don't affect your copy — they got a snapshot. When you share a Google Doc link, both of you look at the same underlying file — one person's edits are instantly visible to the other.

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.
🔒
The One Rule to Remember

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)}")
OUTPUT
before call: number = 5, id = 140234876543216 inside before: x = 5, id = 140234876543216 <- same object! inside after : x = 105, id = 140234876546576 <- new object after call : number = 5, id = 140234876543216 <- unchanged

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)}")
OUTPUT
before call: cart = ['book', 'pen'], id = 140234855432704 inside before: cart = ['book', 'pen'], id = 140234855432704 inside after : cart = ['book', 'pen', 'laptop'], id = 140234855432704 after call : cart = ['book', 'pen', 'laptop'], id = 140234855432704 <- caller sees it!
🚩
This Is Where Beginners Get Burned

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!
OUTPUT
inside: cart = ['phone', 'charger'] after : cart = ['book', 'pen']

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, boolAny (they can't be mutated)No
list, dict, set.append(), .pop(), [i] = v, .update()Yes — mutation leaks
list, dict, setcart = [...] or d = {...} (rebinding)No — new local name only
Custom class instanceAttribute assignment: obj.attr = valueYes
🛡️
How to Defend Against Accidental Mutation

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.


Section 11

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))
OUTPUT (probably not what you expected)
[1] [1, 2] [1, 2, 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.

🛡️
The Fix — Use 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))
OUTPUT (as expected)
[1] [2] [3]

Section 12

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
)
OUTPUT
[14:23:07] [INFO] Server started [14:23:07] [INFO] #redis #user-profile Cache miss [14:23:07] [WARN] Login failed [14:23:07] [ERROR] #stripe #billing Payment declined | user_id=4821 request_id=req-9f3a amount=2500
🏆
Notice How Readable Every Call Is

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.


Section 13

Golden Rules

🌐 Python Functions — Non-Negotiable Rules
1
Never use a mutable object as a default argument. Not [], not {}, not set(). Use None as sentinel and create the mutable inside the body. This one rule kills an entire class of bugs.
2
Use keyword arguments at call sites whenever a function has more than two parameters or any boolean flags. f(x, y, True, False, None) is unreadable six months later. f(x, y, save=True, verbose=False, on_error=None) is not.
3
Return values instead of printing them. A function that prints cannot be composed, tested, or reused. return makes your function a tool. print makes it a dead end.
4
Remember Python is pass-by-object-reference. Mutating a list, dict, or object attribute inside a function affects the caller. If you don't want that, copy the input at the top or accept immutable types.
5
Parameter order in def is fixed: positional → defaults → *args**kwargs. Violating this order raises SyntaxError. There are no exceptions.
6
Use *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.
7
Use **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.
8
Write a docstring for every non-trivial function. Describe what it does, what each parameter means, and what it returns. Future-you will thank present-you.