Python Functions, Scope, "args", "*kwargs" & Common Pitfalls
Python has a few concepts that look complicated at first, but become very easy once we understand what Python is actually doing.
This guide covers:
- LEGB Scoping Rule
- Local, Enclosing, Global and Built-in scopes
- "nonlocal"
- First-Class Functions
- "*args"
- "**kwargs"
- Packing and Unpacking
- Mutable Default Argument Pitfall
- Safe ways to use default arguments
- LEGB Scoping Rule
What is Scope?
Scope means the area of a program where a variable can be accessed.
For example:
def greet():
name = "Deepika"
print(name)
greet()
Here, "name" is created inside "greet()", so it belongs to the local scope of that function.
Python follows a specific rule to find a variable.
This rule is called LEGB.
L → Local
E → Enclosing
G → Global
B → Built-in
Python searches for a variable in this order:
Local
↓
Enclosing
↓
Global
↓
Built-in
Python stops searching as soon as it finds the variable.
L → Local Scope
A variable created inside a function is usually a local variable.
Example
def greet():
name = "Deepika"
print(name)
greet()
Here:
name = "Deepika"
is local to "greet()".
It cannot normally be accessed outside the function:
def greet():
name = "Deepika"
print(name)
This produces a "NameError" because "name" only exists inside "greet()".
Simple definition
«Local scope is the scope inside the current function.»
- E → Enclosing Scope
The enclosing scope appears when one function is defined inside another function.
Example
def outer():
name = "Deepika"
def inner():
print(name)
inner()
outer()
"name" is not inside "inner()".
It is inside "outer()".
Therefore, from the point of view of "inner()", "name" is in the enclosing scope.
outer()
│
├── name = "Deepika"
│
└── inner()
│
└── print(name)
Simple definition
«Enclosing scope is the scope of an outer function surrounding the current inner function.»
This concept is especially important when learning closures.
- G → Global Scope
A variable created outside all functions is generally in the global scope.
Example
name = "Deepika"
def greet():
print(name)
greet()
Python cannot find "name" inside "greet()", so it looks in the global scope and finds it.
Local → not found
Enclosing → not found
Global → found
Output:
Deepika
Simple definition
«Global scope is the scope outside functions and classes at the module level.»
- B → Built-in Scope
Python has many names that are already available to us.
Examples:
print()
len()
sum()
max()
min()
type()
These belong to Python's built-in scope.
Example
numbers = [10, 20, 30]
print(len(numbers))
Python looks for "len":
Local → not found
Enclosing → not found
Global → not found
Built-in → found
Output:
3
Simple definition
«Built-in scope contains names provided by Python itself.»
- Complete LEGB Example
x = "Global"
def outer():
x = "Enclosing"
def inner():
x = "Local"
print(x)
inner()
outer()
Output:
Local
Why?
Because Python searches:
Local → found!
It stops there.
It does not continue searching the enclosing or global scope.
Another LEGB Example
x = "Global"
def outer():
x = "Enclosing"
def inner():
print(x)
inner()
outer()
Output:
Enclosing
Why?
Local → not found
Enclosing → found!
- "nonlocal"
"nonlocal" is used when an inner function wants to modify a variable belonging to an enclosing function.
Example
def outer():
count = 0
def inner():
nonlocal count
count += 1
inner()
print(count)
outer()
Output:
1
Without "nonlocal", Python would treat an assignment such as:
count += 1
as involving a local "count" inside "inner()".
Simple definition
«"nonlocal" tells Python that a variable belongs to an enclosing function's scope, not the current local scope.»
When is "nonlocal" useful?
It is commonly used with:
- Nested functions
- Closures
- Functions that need to remember and update state
- First-Class Functions
One of the most important things to understand about Python is:
«Functions are objects too.»
Because functions are objects, they can be treated like other values.
They can be:
- Assigned to variables
- Passed as arguments
- Returned from functions
- Stored in lists, dictionaries, etc.
This is called first-class functions.
- Assigning a Function to a Variable
def greet():
print("Hello!")
x = greet
x()
Output:
Hello!
Here:
x = greet
means "x" refers to the function.
Notice the difference:
greet
means the function itself.
greet()
means call the function.
- Passing a Function as an Argument
Because functions are first-class objects, we can pass them to another function.
def greet():
print("Hello!")
def execute(function):
function()
execute(greet)
Output:
Hello!
Here:
execute(greet)
passes the "greet" function to "execute()".
Inside "execute()":
function()
calls that function.
- Returning a Function
A function can also return another function.
def outer():
def inner():
print("Hello!")
return inner
x = outer()
x()
Output:
Hello!
Here:
x = outer()
stores the returned "inner" function in "x".
This idea is important for understanding closures and decorators.
- When Are First-Class Functions Useful?
First-class functions are useful when we want to:
- Pass behavior to another function
- Create callbacks
- Build decorators
- Create closures
- Choose a function dynamically
- Store multiple functions and execute them later
Simple idea
Instead of only passing data:
process(10)
we can also pass behavior:
process(greet)
- "*args"
Sometimes we don't know how many positional arguments a function will receive.
For example:
def add(a, b):
return a + b
This only accepts two arguments:
add(10, 20)
But this won't work:
add(10, 20, 30, 40)
We can use "*args" when we want to accept any number of positional arguments.
Syntax
def function_name(*args):
...
Example
def show(*args):
print(args)
show(10, 20, 30)
Output:
(10, 20, 30)
"args" contains the positional arguments inside a tuple.
Conceptually:
args = (10, 20, 30)
- Example Using "*args"
def add(*args):
total = 0
for number in args:
total += number
return total
print(add(10, 20))
print(add(10, 20, 30))
print(add(10, 20, 30, 40))
Output:
30
60
100
Simple definition
«"*args" allows a function to accept any number of positional arguments and collects them into a tuple.»
- Is "args" a Special Keyword?
No.
The "*" is important.
The name can technically be anything:
def show(*numbers):
print(numbers)
But Python programmers normally use:
*args
because it is the standard convention.
- "**kwargs"
Now let's talk about keyword arguments.
Consider:
def student(name, age):
print(name)
print(age)
We can call it using keyword arguments:
student(name="Deepika", age=21)
But what if we don't know how many keyword arguments will be provided?
We can use:
**kwargs
Syntax
def function_name(**kwargs):
...
Example
def student(**kwargs):
print(kwargs)
student(name="Deepika", age=21, city="Bangalore")
Output:
{'name': 'Deepika', 'age': 21, 'city': 'Bangalore'}
"kwargs" contains the keyword arguments inside a dictionary.
Conceptually:
kwargs = {
"name": "Deepika",
"age": 21,
"city": "Bangalore"
}
- Simple Definition of "**kwargs"
«"**kwargs" allows a function to accept any number of keyword arguments and collects them into a dictionary.»
- "args" vs "*kwargs"
Feature| "args"| "kwargs"
Accepts| Positional arguments| Keyword arguments
Stores data as| Tuple| Dictionary
Example| "10, 20, 30"| "name="Deepika""
Symbol| ""| "**"
The easiest thing to remember:
*args
↓
positional arguments
↓
tuple
**kwargs
↓
keyword arguments
↓
dictionary
- Using "args" and "*kwargs" Together
We can use both in the same function.
def demo(*args, **kwargs):
print(args)
print(kwargs)
demo(
10,
20,
30,
name="Deepika",
age=21
)
Output:
(10, 20, 30)
{'name': 'Deepika', 'age': 21}
Python separates them:
10, 20, 30
↓
*args
↓
tuple
and:
name="Deepika"
age=21
↓
**kwargs
↓
dictionary
- Packing and Unpacking
The "" and "*" symbols can also be used for unpacking.
Packing
When defining a function:
def demo(*args):
print(args)
"*args" collects multiple arguments into one tuple.
This is called packing.
Unpacking
Suppose we have:
numbers = [10, 20, 30]
We can unpack them:
def add(a, b, c):
return a + b + c
print(add(*numbers))
This is equivalent to:
add(10, 20, 30)
The "*" takes the elements from the list and sends them as separate positional arguments.
- Dictionary Unpacking with "**"
Suppose:
student = {
"name": "Deepika",
"age": 21
}
We can do:
def show(name, age):
print(name)
print(age)
show(**student)
This is equivalent to:
show(name="Deepika", age=21)
So:
- → unpack sequence into positional arguments
** → unpack dictionary into keyword arguments
- Mutable Default Argument Pitfall
Now let's look at one of Python's famous pitfalls.
A default argument is a value given to a parameter when the caller doesn't provide one.
Example:
def greet(name="Deepika"):
print("Hello", name)
greet()
Output:
Hello Deepika
- What Is a Mutable Object?
A mutable object is an object whose contents can be changed.
Common mutable types include:
list
dict
set
Example:
numbers = []
numbers.append(10)
print(numbers)
Output:
[10]
The list was modified.
- The Problem with Mutable Default Arguments
Consider this function:
def add_item(item, items=[]):
items.append(item)
return items
Now:
print(add_item("Apple"))
print(add_item("Banana"))
print(add_item("Mango"))
Output:
['Apple']
['Apple', 'Banana']
['Apple', 'Banana', 'Mango']
This can be surprising.
Why does this happen?
Because the default list:
items=[]
is created when the function is defined, not every time the function is called.
The same default list can therefore be reused between calls.
Conceptually:
Function
|
└── default list
|
├── Call 1 → ['Apple']
|
├── Call 2 → ['Apple', 'Banana']
|
└── Call 3 → ['Apple', 'Banana', 'Mango']
- Why Is This Called a Pitfall?
Break the phrase into three parts:
Mutable
The object can be changed.
[]
is mutable.
Default argument
This is the default parameter:
items=[]
Pitfall
The same mutable object can be reused across function calls, causing unexpected results.
Simple definition
«The mutable default argument pitfall occurs when a mutable object such as a list, dictionary, or set is used as a default parameter and is modified, causing its changes to persist between function calls.»
- The Safe Way: Use "None"
Instead of:
def add_item(item, items=[]):
use:
def add_item(item, items=None):
if items is None:
items = []
items.append(item)
return items
Now:
print(add_item("Apple"))
print(add_item("Banana"))
print(add_item("Mango"))
Output:
['Apple']
['Banana']
['Mango']
A new list is created whenever "items" is "None".
- Why Does "None" Fix the Problem?
"None" is used as a signal:
items=None
means:
«"No list was provided."»
Then:
if items is None:
items = []
creates a fresh list.
So instead of:
Call 1 ─┐
Call 2 ─┼──→ SAME LIST
Call 3 ─┘
we get:
Call 1 → NEW LIST
Call 2 → NEW LIST
Call 3 → NEW LIST
- Mutable Default Dictionaries
The same problem can happen with dictionaries.
Avoid:
def add_user(name, users={}):
users[name] = "active"
return users
Prefer:
def add_user(name, users=None):
if users is None:
users = {}
users[name] = "active"
return users
- Mutable Default Sets
The same idea applies to sets.
Avoid:
def add_number(number, numbers=set()):
numbers.add(number)
return numbers
Prefer:
def add_number(number, numbers=None):
if numbers is None:
numbers = set()
numbers.add(number)
return numbers
- Are All Default Arguments Dangerous?
No.
Immutable objects such as:
int
float
str
tuple
None
bool
do not have the same mutable-default problem.
For example:
def counter(count=0):
count += 1
return count
print(counter())
print(counter())
print(counter())
Output:
1
1
1
This happens because integers are immutable.
- The Pattern to Remember
Whenever you want a fresh mutable object for every function call, use "None".
List
def function(data=None):
if data is None:
data = []
Dictionary
def function(data=None):
if data is None:
data = {}
Set
def function(data=None):
if data is None:
data = set()
- Quick Revision
LEGB
L → Local
E → Enclosing
G → Global
B → Built-in
«Python searches for a variable in this order.»
"nonlocal"
nonlocal variable
«Used inside a nested function to modify a variable from the enclosing function.»
First-Class Functions
«Functions are objects and can be assigned to variables, passed as arguments, returned from functions, and stored in collections.»
Example:
def greet():
print("Hello")
x = greet
x()
"*args"
def function(*args):
«Accepts any number of positional arguments and stores them in a tuple.»
Example:
def show(*args):
print(args)
show(10, 20, 30)
Output:
(10, 20, 30)
"**kwargs"
def function(**kwargs):
«Accepts any number of keyword arguments and stores them in a dictionary.»
Example:
def show(**kwargs):
print(kwargs)
show(name="Deepika", age=21)
Output:
{'name': 'Deepika', 'age': 21}
Mutable Default Argument
Avoid:
def function(items=[]):
when you intend to create a fresh list for every call.
Prefer:
def function(items=None):
if items is None:
items = []
«The key reason: default arguments are created when the function is defined, so a mutable default object can be reused between calls.»
⭐ The Most Important Things to Remember
LEGB
↓
Where does Python look for a variable?
L → Local
E → Enclosing
G → Global
B → Built-in
First-class functions
↓
Functions can be treated like objects.
*args
↓
many positional arguments
↓
tuple
**kwargs
↓
many keyword arguments
↓
dictionary
Mutable default argument
↓
Avoid [] / {} / set() as defaults
↓
Use None instead
These concepts are especially important because they form the foundation for understanding closures, decorators, callbacks, function arguments, and Python's scope behavior.
Top comments (0)