Python Functions and Scope Explained (2026)
Python functions and scope hide the single most infamous gotcha in the entire language. A function to add an item to a cart is written as def add_item(item, cart=[]):, relying on the empty list as a sensible default. Call it three times without passing a cart, expecting three separate one-item carts — get one shared cart with three items instead. The default list wasn't created fresh each call; it was created exactly once, when the function was defined, and every call that skipped the argument kept mutating that same original list. This guide covers functions, that exact trap, and the scope rules behind it.
Quick Answer: Python Functions and Scope
A Python function packages reusable logic behind def name(parameters): and hands a result back with return. Scope determines where a variable is visible: local (inside the function only) or global (module-wide). The single most damaging beginner mistake in this area is using a mutable object — a list or dict — as a default argument value, since Python creates that default exactly once at definition time, not fresh on every call.
The Cart That Wouldn't Reset
A function adds an item to a shopping cart, defaulting to a fresh empty cart when none is provided:
def add_item(item, cart=[]): cart.append(item) return cart print(add_item("shirt")) # ['shirt'] print(add_item("shoes")) # ['shirt', 'shoes'] — expected ['shoes']!
The second call looks like it should start fresh, since no cart was passed and the default is an empty list. It doesn't. Python evaluates default argument values exactly once — when the function is defined, not each time it's called — so cart=[] creates a single list object that every call sharing the default reuses and mutates. Three calls without an explicit cart don't produce three separate one-item lists; they produce one growing list, shared silently across unrelated calls.
Anatomy of a Function
A function bundles a block of logic behind a name, optionally taking input and optionally handing back a result.
def calculate_total(price, quantity, tax_rate=0.08): subtotal = price * quantity return subtotal * (1 + tax_rate) total = calculate_total(19.99, 3) print(total)
price and quantity are required — call the function without them and Python raises a TypeError. tax_rate has a default, making it optional; callers can override it or leave it as-is. The return statement is what hands a value back to whatever called the function — without one, the call site gets None.
The Mutable Default Argument: The Actual Fix
The standard, idiomatic fix: default to None, then create a genuinely new mutable object inside the function body only when needed.
def add_item(item, cart=None): if cart is None: cart = [] # a brand-new list, created fresh on every call cart.append(item) return cart print(add_item("shirt")) # ['shirt'] print(add_item("shoes")) # ['shoes'] — correct, independent result
Never use a list, dict, or set directly as a default argument value. Immutable defaults (None, numbers, strings, tuples) are always safe, because they can't be mutated in place even if shared — this bug is exclusively about mutable defaults.
*args and **kwargs: Flexible Arguments
Sometimes a function genuinely doesn't know in advance how many arguments it'll receive — *args and **kwargs handle exactly that.
def summarize(*args, **kwargs): print("positional:", args) # a tuple of unnamed arguments print("keyword:", kwargs) # a dict of named arguments summarize(1, 2, 3, name="Maria", active=True) # positional: (1, 2, 3) # keyword: {'name': 'Maria', 'active': True}
*args collects any extra positional arguments into a tuple; **kwargs collects extra named arguments into a dictionary. Both are common in general-purpose utility functions and wrapper functions that need to pass arguments through to something else without knowing their exact shape ahead of time.
Local vs Global Scope
A variable's scope determines where it's visible — and a function can read outer variables freely, but reassigning one takes explicit syntax.
counter = 0 # global scope def increment_broken(): counter = counter + 1 # UnboundLocalError — Python sees this as a new local variable def increment_fixed(): global counter counter = counter + 1 # explicitly modifies the global variable
Any name assigned to inside a function is treated as local to that function by default — even if a global variable with the same name already exists. Without the global keyword, increment_broken() doesn't modify the outer counter; it tries to create and use a local one before it's assigned, which raises an error. Relying on global heavily is generally a sign a function should take a parameter and return a value instead — but the keyword exists for the cases where it's genuinely needed.
return vs print: A Common Confusion
print() displays a value in the console. return hands a value back to whatever called the function, so it can actually be used in the rest of the program.
def double_broken(n): print(n * 2) # only displays it — doesn't hand it back def double_fixed(n): return n * 2 # hands the value back to the caller result = double_broken(5) # prints 10, but result is None result = double_fixed(5) # result is 10, usable in the rest of the program
double_broken looks like it works, because 10 does print — but result is None, since the function never actually returned anything. This is one of the quieter beginner bugs: the output looks right in isolation, and the failure only shows up later, when something tries to use result and gets None instead.
Mistakes That Give Away a Beginner
- Using a list or dict as a default argument. The exact trap from this article's opening — default to None instead.
- Confusing print() with return, then being surprised a variable holds None after calling a function that only printed its result.
- Assigning to a global variable inside a function without the global keyword, and hitting an UnboundLocalError.
- Overusing global state instead of passing values in as parameters and getting results back via return.
- Reaching for *args/**kwargs when a function's arguments are actually known and fixed, adding unnecessary flexibility and losing clarity.
Practice: Fix These Three Yourself
Fix the shared cart
Rewrite add_item so three separate calls without an explicit cart each return their own independent one-item list.
Fix the missing return
Rewrite a function that only prints its result so the caller can actually use the returned value.
Write a flexible logger
Write a function log_event(*args, **kwargs) that prints every positional and keyword argument it receives.
Frequently Asked Questions
Why does a Python function keep remembering values between calls?
This happens when a mutable object like a list or dictionary is used as a default argument value. Default argument values are created exactly once, when the function is defined, not each time it's called, so every call that doesn't supply its own value shares and mutates that same original object.
What is the mutable default argument trap in Python?
It's a common bug where a function is defined with a mutable default value, such as def add_item(item, cart=[]), and every call that relies on the default ends up sharing and modifying the same list across calls. The standard fix is to default to None and create a new mutable object inside the function body when the argument wasn't provided.
What is the difference between local and global scope in Python?
A variable created inside a function has local scope and only exists while that function is running. A variable created outside any function has global scope and is accessible throughout the module. A function can read a global variable directly but needs the global keyword to reassign it from inside the function.
What happens if a Python function doesn't have a return statement?
A function with no return statement, or a bare return with no value, returns None automatically. Assigning the result of such a function to a variable and using it later — for example trying to call a method on it — typically raises an error or produces unexpected None values downstream.
What is the difference between *args and **kwargs in Python?
*args collects any extra positional arguments into a tuple, letting a function accept a variable number of unnamed arguments. **kwargs collects extra keyword arguments into a dictionary, letting a function accept a variable number of named arguments. Both are commonly used together when writing flexible, general-purpose functions.
Can a function modify a variable from outside its scope in Python?
A function can read an outer variable without any special syntax, but reassigning it requires the global keyword (for module-level variables) or nonlocal (for an enclosing function's variable). Without one of these, assigning to a name inside a function creates a new local variable instead of modifying the outer one.
Conclusion: Fundamentals Tier Complete
Functions package logic for reuse, and scope determines what that logic can see and change — the one rule worth carrying forward from this article is to never default a mutable argument to a shared list or dict. Default to None, create fresh inside the function body, keep global state to a minimum, and always return the value you actually intend the caller to use.
That closes out the fundamentals tier: variables and types, lists/tuples/dicts, control flow, and now functions and scope. The next article starts the intermediate tier with NumPy — the foundation everything in Pandas is built on.
Next Up: NumPy Arrays Explained
Fundamentals are done. NumPy is where Python data science actually starts — and where the mutability rules from this tier matter even more.
▶ Read the NumPy Arrays Guide- Python Defining Functions Tutorial — docs.python.org
- Python FAQ: Why Are Default Values Shared — docs.python.org
- Python Naming and Binding (Scope) Reference — docs.python.org
- Review Publically — Python Control Flow: If Statements and Loops
- Review Publically — Python for Data Science: The Complete Guide
Khalid Hussain
Founder of Review Publically. MSc holder and Google Advanced Data Analytics certified. Teaches Python and SQL data analysis with a focus on current, correct, production-ready code rather than outdated conventions.
Related Articles