DEV Community

Deepika Pusala
Deepika Pusala

Posted on

Python Scope, First-Class Functions, *args,**kwargs & Mutable Default Arguments

Python has several concepts that look difficult at first, but they become simple 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

1. 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()
Enter fullscreen mode Exit fullscreen mode

Here, name is created inside greet(), so it belongs to the local scope of that function.

Python follows a specific rule to find variables. This rule is called the LEGB rule.

  • L → Local
  • E → Enclosing
  • G → Global
  • B → Built-in

Python searches for a variable in this order:

Local
   ↓
Enclosing
   ↓
Global
   ↓
Built-in
Enter fullscreen mode Exit fullscreen mode

Python stops searching as soon as it finds the variable.


2. L → Local Scope

A variable created inside a function is usually a local variable.

Example

def greet():
    name = "Deepika"
    print(name)

greet()
Enter fullscreen mode Exit fullscreen mode

Here, name = "Deepika" is local to greet().

It cannot normally be accessed outside the function:

def greet():
    name = "Deepika"

print(name)
Enter fullscreen mode Exit fullscreen mode

This gives a NameError because name only exists inside greet().

Simple Definition: Local scope is the scope inside the current function.


3. 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()
Enter fullscreen mode Exit fullscreen mode

Here, 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)
Enter fullscreen mode Exit fullscreen mode

Simple Definition: Enclosing scope is the scope of an outer function surrounding the current inner function.

This concept is especially important when learning closures.


4. G → Global Scope

A variable created outside all functions is generally in the global scope.

Example

name = "Deepika"

def greet():
    print(name)

greet()
Enter fullscreen mode Exit fullscreen mode

Python cannot find name inside greet(), so it looks in the global scope and finds it.

Local      → Not found
Enclosing  → Not found
Global     → Found!
Enter fullscreen mode Exit fullscreen mode

Output:

Deepika
Enter fullscreen mode Exit fullscreen mode

Simple Definition: Global scope is the scope outside functions and classes at the module level.


5. B → Built-in Scope

Python already provides many names that we can use directly.

Examples: print(), len(), sum(), max(), min(), type()

These names belong to Python's built-in scope.

Example

numbers = [10, 20, 30]

print(len(numbers))
Enter fullscreen mode Exit fullscreen mode

For len, Python searches:

Local      → Not found
Enclosing  → Not found
Global     → Not found
Built-in   → Found!
Enter fullscreen mode Exit fullscreen mode

Output:

3
Enter fullscreen mode Exit fullscreen mode

Simple Definition: Built-in scope contains names that are provided by Python itself.


6. Complete LEGB Example

x = "Global"

def outer():
    x = "Enclosing"

    def inner():
        x = "Local"
        print(x)

    inner()

outer()
Enter fullscreen mode Exit fullscreen mode

Output:

Local
Enter fullscreen mode Exit fullscreen mode

Why? Because Python searches Local first and finds it there. It stops immediately and does not continue searching the enclosing, global, or built-in scopes.


7. Another LEGB Example

x = "Global"

def outer():
    x = "Enclosing"

    def inner():
        print(x)

    inner()

outer()
Enter fullscreen mode Exit fullscreen mode

Output:

Enclosing
Enter fullscreen mode Exit fullscreen mode

Why? Python searches:

Local      → Not found
Enclosing  → Found!
Enter fullscreen mode Exit fullscreen mode

So it uses "Enclosing".


8. 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()
Enter fullscreen mode Exit fullscreen mode

Output:

1
Enter fullscreen mode Exit fullscreen mode

Here, nonlocal count tells Python: "count belongs to the enclosing function. I want to modify that variable."

Simple Definition: nonlocal tells Python to use a variable from the enclosing function's scope instead of creating a new local variable.

When is nonlocal useful? It is commonly used with:

  • Nested functions
  • Closures
  • Functions that need to remember and update state

9. First-Class Functions

One of the most important things about Python is:

Functions are objects too.

Because functions are objects, they can be treated like other values. A function can be:

  • Assigned to a variable
  • Passed as an argument
  • Returned from another function
  • Stored in a list
  • Stored in a dictionary
  • Used later

This is called first-class functions.


10. Assigning a Function to a Variable

def greet():
    print("Hello!")

x = greet

x()
Enter fullscreen mode Exit fullscreen mode

Output:

Hello!
Enter fullscreen mode Exit fullscreen mode

Here, x = greet means x now refers to the greet function.

Important Difference

  • greet means: the function itself.
  • greet() means: call or execute the function.

So x = greet stores the function, but x = greet() calls the function immediately and stores its return value.


11. Passing a Function as an Argument

Because functions are first-class objects, we can pass a function to another function.

def greet():
    print("Hello!")

def execute(function):
    function()

execute(greet)
Enter fullscreen mode Exit fullscreen mode

Output:

Hello!
Enter fullscreen mode Exit fullscreen mode

Here, execute(greet) passes the greet function to execute(). Inside execute(), function() calls the function.

The flow is:

greet
  ↓
passed to execute()
  ↓
stored in parameter "function"
  ↓
function()
  ↓
Hello!
Enter fullscreen mode Exit fullscreen mode

12. Returning a Function

A function can also return another function.

def outer():
    def inner():
        print("Hello!")

    return inner

x = outer()

x()
Enter fullscreen mode Exit fullscreen mode

Output:

Hello!
Enter fullscreen mode Exit fullscreen mode

Here, x = outer() stores the returned inner function in x. Then x() calls inner().

This idea is very important for understanding closures and decorators.


13. 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
  • Execute functions later

Simple Idea

Normally, we pass data:

process(10)
Enter fullscreen mode Exit fullscreen mode

But because functions are objects, we can also pass behavior:

process(greet)
Enter fullscreen mode Exit fullscreen mode

14. *args

Sometimes we don't know how many positional arguments a function will receive.

def add(a, b):
    return a + b
Enter fullscreen mode Exit fullscreen mode

This function expects two arguments: add(10, 20). But this will cause an error:

add(10, 20, 30, 40)
Enter fullscreen mode Exit fullscreen mode

If we want the function to accept any number of positional arguments, we can use *args.

Syntax

def function_name(*args):
    ...
Enter fullscreen mode Exit fullscreen mode

Example

def show(*args):
    print(args)

show(10, 20, 30)
Enter fullscreen mode Exit fullscreen mode

Output:

(10, 20, 30)
Enter fullscreen mode Exit fullscreen mode

The arguments are collected into a tuple:

args = (10, 20, 30)
Enter fullscreen mode Exit fullscreen mode

15. 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))
Enter fullscreen mode Exit fullscreen mode

Output:

30
60
100
Enter fullscreen mode Exit fullscreen mode

Simple Definition: *args allows a function to accept any number of positional arguments and collects them into a tuple.


16. Is args a Special Keyword?

No. The * is what matters. The name can technically be anything:

def show(*numbers):
    print(numbers)
Enter fullscreen mode Exit fullscreen mode

But Python programmers normally use *args because it is the standard convention.


17. **kwargs

Now let's talk about keyword arguments.

def student(name, age):
    print(name)
    print(age)
Enter fullscreen mode Exit fullscreen mode

We can call this function using keyword arguments:

student(name="Deepika", age=21)
Enter fullscreen mode Exit fullscreen mode

But what if we don't know how many keyword arguments will be provided? We can use **kwargs.

Syntax

def function_name(**kwargs):
    ...
Enter fullscreen mode Exit fullscreen mode

Example

def student(**kwargs):
    print(kwargs)

student(name="Deepika", age=21, city="Bangalore")
Enter fullscreen mode Exit fullscreen mode

Output:

{'name': 'Deepika', 'age': 21, 'city': 'Bangalore'}
Enter fullscreen mode Exit fullscreen mode

The keyword arguments are collected into a dictionary:

kwargs = {
    "name": "Deepika",
    "age": 21,
    "city": "Bangalore"
}
Enter fullscreen mode Exit fullscreen mode

18. Simple Definition of **kwargs

**kwargs allows a function to accept any number of keyword arguments and collects them into a dictionary.


19. *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 way to remember:

*args → Positional arguments → Tuple
**kwargs → Keyword arguments → Dictionary
Enter fullscreen mode Exit fullscreen mode

20. 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
)
Enter fullscreen mode Exit fullscreen mode

Output:

(10, 20, 30)
{'name': 'Deepika', 'age': 21}
Enter fullscreen mode Exit fullscreen mode

Python separates them: 10, 20, 30*args → Tuple, and name="Deepika", age=21**kwargs → Dictionary.


21. Packing and Unpacking

The * and ** symbols can also be used for unpacking. There are two different ideas: Packing and Unpacking.


22. Packing

When defining a function:

def demo(*args):
    print(args)
Enter fullscreen mode Exit fullscreen mode

*args collects multiple positional arguments into one tuple. This is called packing.

demo(10, 20, 30)
Enter fullscreen mode Exit fullscreen mode

becomes conceptually:

args = (10, 20, 30)
Enter fullscreen mode Exit fullscreen mode

23. Unpacking with *

Suppose we have a list:

numbers = [10, 20, 30]

def add(a, b, c):
    return a + b + c
Enter fullscreen mode Exit fullscreen mode

We can do:

print(add(*numbers))
Enter fullscreen mode Exit fullscreen mode

This is equivalent to:

print(add(10, 20, 30))
Enter fullscreen mode Exit fullscreen mode

The * takes the elements from the list and passes them as separate positional arguments. This is called unpacking.


24. Dictionary Unpacking with **

Suppose we have:

student = {
    "name": "Deepika",
    "age": 21
}

def show(name, age):
    print(name)
    print(age)
Enter fullscreen mode Exit fullscreen mode

We can do:

show(**student)
Enter fullscreen mode Exit fullscreen mode

This is equivalent to:

show(name="Deepika", age=21)
Enter fullscreen mode Exit fullscreen mode

The ** unpacks the dictionary into keyword arguments.


25. Packing vs Unpacking

PACKING
↓
Many values → One variable

Example: def show(*args):

UNPACKING
↓
One collection → Many arguments

Example: show(*numbers)
Enter fullscreen mode Exit fullscreen mode

For dictionaries:

  • **kwargs packs keyword arguments into a dictionary.
  • function(**data) unpacks a dictionary into keyword arguments.

26. 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.

def greet(name="Deepika"):
    print("Hello", name)

greet()
Enter fullscreen mode Exit fullscreen mode

Output:

Hello Deepika
Enter fullscreen mode Exit fullscreen mode

Here, name="Deepika" is the default value.


27. What Is a Mutable Object?

A mutable object is an object whose contents can be changed. Common mutable types include list, dict, set.

numbers = []

numbers.append(10)

print(numbers)
Enter fullscreen mode Exit fullscreen mode

Output:

[10]
Enter fullscreen mode Exit fullscreen mode

The list was modified.


28. The Mutable Default Argument Problem

Consider this function:

def add_item(item, items=[]):
    items.append(item)
    return items

print(add_item("Apple"))
print(add_item("Banana"))
print(add_item("Mango"))
Enter fullscreen mode Exit fullscreen mode

Output:

['Apple']
['Apple', 'Banana']
['Apple', 'Banana', 'Mango']
Enter fullscreen mode Exit fullscreen mode

This may be surprising. We might expect:

['Apple']
['Banana']
['Mango']
Enter fullscreen mode Exit fullscreen mode

But that's not what happens.


29. Why Does This Happen?

The important rule is:

Python creates default argument objects when the function is defined, not every time the function is called.

So def add_item(item, items=[]): creates one default list.

Function
   |
   └── Default list
          |
          ├── First call → ['Apple']
          |
          ├── Second call → ['Apple', 'Banana']
          |
          └── Third call → ['Apple', 'Banana', 'Mango']
Enter fullscreen mode Exit fullscreen mode

The same list is being reused. Because lists are mutable, changes made to that list remain there.


30. Why Is It Called a "Mutable Default Argument Pitfall"?

  • Mutable — the object can be changed. [] is a mutable list.
  • Default Argument — this is the default parameter: items=[]
  • Pitfall — the same mutable object can be reused across function calls.

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 modified, causing its changes to persist between function calls.


31. The Wrong Way

Avoid this when you want a fresh list for every function call:

def add_item(item, items=[]):
    items.append(item)
    return items
Enter fullscreen mode Exit fullscreen mode

32. The Safe Way: Use None

Instead, use:

def add_item(item, items=None):
    if items is None:
        items = []

    items.append(item)
    return items

print(add_item("Apple"))
print(add_item("Banana"))
print(add_item("Mango"))
Enter fullscreen mode Exit fullscreen mode

Output:

['Apple']
['Banana']
['Mango']
Enter fullscreen mode Exit fullscreen mode

Perfect!


33. Why Does None Fix the Problem?

We use items=None as a signal meaning: "The caller did not provide a list."

Then if items is None: items = [] creates a new list when needed.

So instead of:

Call 1 ─┐
Call 2 ─┼──→ SAME LIST
Call 3 ─┘
Enter fullscreen mode Exit fullscreen mode

we get:

Call 1 → NEW LIST
Call 2 → NEW LIST
Call 3 → NEW LIST
Enter fullscreen mode Exit fullscreen mode

34. Mutable Default Dictionaries

The same problem can happen with dictionaries.

Avoid:

def add_user(name, users={}):
    users[name] = "active"
    return users
Enter fullscreen mode Exit fullscreen mode

Prefer:

def add_user(name, users=None):
    if users is None:
        users = {}

    users[name] = "active"
    return users
Enter fullscreen mode Exit fullscreen mode

35. Mutable Default Sets

The same idea applies to sets.

Avoid:

def add_number(number, numbers=set()):
    numbers.add(number)
    return numbers
Enter fullscreen mode Exit fullscreen mode

Prefer:

def add_number(number, numbers=None):
    if numbers is None:
        numbers = set()

    numbers.add(number)
    return numbers
Enter fullscreen mode Exit fullscreen mode

36. Are All Default Arguments Dangerous?

No. The problem specifically concerns mutable objects that are modified.

Common immutable types include: int, float, str, tuple, bool, None.

def counter(count=0):
    count += 1
    return count

print(counter())
print(counter())
print(counter())
Enter fullscreen mode Exit fullscreen mode

Output:

1
1
1
Enter fullscreen mode Exit fullscreen mode

This is fine because integers are immutable.


37. The Safe 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 = []
Enter fullscreen mode Exit fullscreen mode

Dictionary

def function(data=None):
    if data is None:
        data = {}
Enter fullscreen mode Exit fullscreen mode

Set

def function(data=None):
    if data is None:
        data = set()
Enter fullscreen mode Exit fullscreen mode

This pattern is extremely common in real Python code.


38. Quick Revision

LEGB

L → Local
E → Enclosing
G → Global
B → Built-in
Enter fullscreen mode Exit fullscreen mode

LEGB is Python's rule for searching for a variable name.

nonlocal

nonlocal variable
Enter fullscreen mode Exit fullscreen mode

nonlocal tells an inner function to use and modify a variable from its enclosing function.

First-Class Functions

Functions are objects in Python, so they can be assigned to variables, passed as arguments, returned from functions, and stored in collections.

def greet():
    print("Hello")

x = greet
x()
Enter fullscreen mode Exit fullscreen mode

*args

def function(*args):
Enter fullscreen mode Exit fullscreen mode

*args accepts any number of positional arguments and stores them in a tuple.

def show(*args):
    print(args)

show(10, 20, 30)
Enter fullscreen mode Exit fullscreen mode

Output: (10, 20, 30)

`kwargs`**

def function(**kwargs):
Enter fullscreen mode Exit fullscreen mode

**kwargs accepts any number of keyword arguments and stores them in a dictionary.

def show(**kwargs):
    print(kwargs)

show(name="Deepika", age=21)
Enter fullscreen mode Exit fullscreen mode

Output: {'name': 'Deepika', 'age': 21}

Mutable Default Argument

Avoid:

def function(items=[]):
Enter fullscreen mode Exit fullscreen mode

Prefer:

def function(items=None):
    if items is None:
        items = []
Enter fullscreen mode Exit fullscreen mode

A mutable default argument can cause changes to persist between function calls because the default object is created when the function is defined and can be reused.


39. Final Cheat Sheet

LEGB

L → Local
E → Enclosing
G → Global
B → Built-in

Python searches in this order.
Enter fullscreen mode Exit fullscreen mode

First-Class Functions

Functions are objects.

They can be:
• Assigned to variables
• Passed as arguments
• Returned from functions
• Stored in collections
Enter fullscreen mode Exit fullscreen mode

*args

Many positional arguments
        ↓
      Tuple
Enter fullscreen mode Exit fullscreen mode

`kwargs`**

Many keyword arguments
        ↓
    Dictionary
Enter fullscreen mode Exit fullscreen mode

Mutable Default Argument

Avoid:
def func(items=[]):

Prefer:
def func(items=None):
    if items is None:
        items = []
Enter fullscreen mode Exit fullscreen mode

One-Minute Memory

LEGB → Where does Python search for a variable?
Local → Enclosing → Global → Built-in

First-Class Function → Function can be treated like an object.

*args → Many positional arguments → Tuple

**kwargs → Many keyword arguments → Dictionary

*list → Unpack list/sequence → Positional arguments

**dictionary → Unpack dictionary → Keyword arguments

Mutable Default → Avoid [] / {} / set() as defaults → Use None → Create a fresh object inside the function
Enter fullscreen mode Exit fullscreen mode

These concepts are important foundations for understanding closures, decorators, callbacks, scope, function arguments, and advanced Python programming.

Top comments (0)