DEV Community

Deepika Pusala
Deepika Pusala

Posted on

The LEGB scoping rule, first-class functions, *args,**kwargs(key-word args),mutable-default-argument pitfall

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

  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()

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


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

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.


  1. 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.»


  1. 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.»


  1. Complete LEGB Example

x = "Global"

def outer():

x = "Enclosing"

def inner():

    x = "Local"

    print(x)

inner()
Enter fullscreen mode Exit fullscreen mode

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

outer()

Output:

Enclosing

Why?

Local → not found
Enclosing → found!


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

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

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


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


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


  1. Returning a Function

A function can also return another function.

def outer():

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

return inner
Enter fullscreen mode Exit fullscreen mode

x = outer()

x()

Output:

Hello!

Here:

x = outer()

stores the returned "inner" function in "x".

This idea is important for understanding closures and decorators.


  1. 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)


  1. "*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)


  1. Example Using "*args"

def add(*args):

total = 0

for number in args:
    total += number

return total
Enter fullscreen mode Exit fullscreen mode

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


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


  1. "**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"
}


  1. Simple Definition of "**kwargs"

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


  1. "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


  1. Using "args" and "*kwargs" Together

We can use both in the same function.

def demo(*args, **kwargs):

print(args)
print(kwargs)
Enter fullscreen mode Exit fullscreen mode

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


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


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


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


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


  1. The Problem with Mutable Default Arguments

Consider this function:

def add_item(item, items=[]):

items.append(item)

return items
Enter fullscreen mode Exit fullscreen mode

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']


  1. 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.»


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

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


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


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

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

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


  1. 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 = []
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

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

«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)