Names and Objects: Everything is an Object

In Python, every piece of data is represented by an object. This is a fundamental concept that underpins the entire language. An object is more than just its value; it is a self-contained entity that bundles together the data (its state) and the behaviors (methods) that operate on that data. When we write x = 5, we are not creating a memory box named x and putting the integer 5 inside it. Instead, we are creating an integer object with the value 5 somewhere in memory and then creating a name, or a reference, called x that points to that object.

The Distinction Between Names and Objects

A common misconception is that variables are “containers” for data. In Python, they are not. They are best thought of as labels or tags attached to objects. This distinction is crucial for understanding assignment and mutability. An object can have multiple names (references) attached to it. The id() function returns the object’s unique, constant identity (an integer, often corresponding to its memory address) during its lifetime, which helps us see this relationship.

# Create an integer object with value 10. The name 'a' is bound to it.
a = 10
print(id(a))  # Outputs a number, e.g., 140736200027904

# Bind the name 'b' to the same object that 'a' is pointing to.
b = a
print(id(b))  # Outputs the exact same number as id(a)

# Now create a new integer object with value 20.
# The name 'a' is re-bound to this new object.
a = 20
print(id(a))  # Outputs a new, different number
print(id(b))  # Outputs the original number; 'b' is still attached to the object 10
print(b)      # Output: 10

The assignment b = a does not create a copy of the object 10; it only creates a new name (b) for the existing object. When we reassign a = 20, we are not changing the original object 10; we are making the name a point to a completely new object (20). The name b remains faithfully attached to the original object.

Assignment is Binding, Not Copying

Assignment in Python is always about binding a name to an object. It never implicitly copies the data of an object. This behavior leads to one of the most common pitfalls for newcomers, especially when dealing with mutable objects like lists.

list_1 = [1, 2, 3]  # Create a list object, bind name 'list_1' to it
list_2 = list_1      # Bind name 'list_2' to the SAME list object

list_2.append(4)     # Modify the object itself (the list)

print(list_1)        # Output: [1, 2, 3, 4]
print(list_2)        # Output: [1, 2, 3, 4]
# Both names reflect the change because they reference the same mutable object.

To actually create a separate copy of a mutable object, you must do so explicitly.

list_1 = [1, 2, 3]
list_2 = list_1.copy()  # Or list_2 = list_1[:]
list_2.append(4)

print(list_1)  # Output: [1, 2, 3]
print(list_2)  # Output: [1, 2, 3, 4]

Identity vs. Equality (is vs. ==)

This object model clarifies the critical difference between the is operator and the == operator. The is operator checks for identity: it returns True if two names reference the exact same object (i.e., their id() values are identical). The == operator checks for equality: it returns True if the values of the objects the names point to are the same, according to the object’s __eq__ method.

a = [1, 2, 3]
b = [1, 2, 3]  # A new list object with the same values
c = a          # Another name for the 'a' object

print(a == b)  # True, because their contents are equal
print(a is b)  # False, because they are distinct objects
print(a is c)  # True, because 'a' and 'c' are names for the same object

A common use of is is to check for the singleton None, as there is only one None object in the entire Python runtime.

if my_var is None:
    # Do something

Implications of Immutability

An object whose state cannot be changed after creation is called immutable. Examples include integers, floats, strings, and tuples. This immutability interacts directly with the name-object model. Operations that seem to “change” an immutable object actually create a new object.

s1 = "Hello"
print(id(s1))  # e.g., 2106039623088

s1 += " World!"  # This does NOT modify the original string.
# It creates a new string object and rebinds the name 's1' to it.
print(id(s1))  # A new, different number

This is why operations like string concatenation in a loop can be inefficient, as they create a new object in each iteration. For such cases, using a mutable object like a list and then ''.join()-ing it is a recognized best practice.

Assignment Semantics: Binding Names, Not Copying Values

In Python, assignment is the process of binding a name (variable) to an object. This is a fundamentally different concept from the “copying values” model found in many other programming languages. Understanding this distinction is critical to avoiding subtle bugs and writing correct Python code.

The Name-Object Model

When you write x = 10, you are not creating a box named x and putting the integer 10 inside it. Instead, you are creating an integer object 10 (if it doesn’t already exist) and then creating a name tag x that is attached to that object. The variable x is a reference to the object. This model means that multiple names can be bound to the same underlying object.

a = [1, 2, 3]  # Create a list object, bind name 'a' to it
b = a          # Bind name 'b' to the SAME list object

print(a is b)  # Output: True. They are the same object in memory.
print(id(a) == id(b))  # Output: True. Their memory addresses are identical.

a.append(4)    # Modify the object through name 'a'
print(b)       # Output: [1, 2, 3, 4]. The change is visible through name 'b'

This behavior occurs because the assignment b = a copies the reference to the object, not the object itself. Both a and b become different names for the same list.

Implications for Mutable vs. Immutable Objects

The practical consequences of this binding semantics depend heavily on whether the object being referenced is mutable (can be changed in-place) or immutable (cannot be changed after creation).

With immutable objects like integers, strings, and tuples, the distinction is often less noticeable because operations on them create new objects rather than modifying existing ones.

x = 5      # Name 'x' bound to integer object '5'
y = x      # Name 'y' is now also bound to the same object '5'
print(x is y)  # Output: True

x = x + 1  # This operation creates a NEW integer object '6'
            # and rebinds the name 'x' to it.
print(x)    # Output: 6
print(y)    # Output: 5 (still bound to the original object '5')
print(x is y)  # Output: False

However, with mutable objects like lists, dictionaries, and sets, the effects are more pronounced and are a common source of errors for newcomers.

# A common pitfall: "copying" a list by assignment
list_original = [['a', 'b'], [1, 2]]
list_copy = list_original  # This does NOT create a copy!

list_original[0].append('c')
print(list_copy)  # Output: [['a', 'b', 'c'], [1, 2]]
# The nested list was modified in both variables because they reference the same object.

How to Actually Create a Copy

To avoid the pitfalls of shared references, you must explicitly create a copy when you want a new, independent object. For flat lists, the .copy() method or the list() constructor are sufficient.

original = [1, 2, 3]
true_copy = original.copy()  # or list(original)

original.append(4)
print(original)  # Output: [1, 2, 3, 4]
print(true_copy) # Output: [1, 2, 3] (unchanged)

For compound objects containing other mutable objects (e.g., a list of lists), a shallow copy may not be enough. A shallow copy creates a new outer container but populates it with references to the same inner objects. In these cases, you need a deep copy.

import copy

matrix_orig = [[1, 2], [3, 4]]
matrix_shallow = copy.copy(matrix_orig)   # Shallow copy
matrix_deep = copy.deepcopy(matrix_orig)  # Deep copy

matrix_orig[0].append(99)
print(matrix_shallow)  # Output: [[1, 2, 99], [3, 4]] (inner list is shared)
print(matrix_deep)     # Output: [[1, 2], [3, 4]]     (completely independent)

Best Practices and Common Pitfalls

  1. Be Explicit About Intent: If you need a copy, always use explicit copying methods (.copy(), copy.copy(), copy.deepcopy()). Never rely on assignment for duplication.
  2. Function Arguments are References: When you pass a mutable object to a function, you are passing a reference to the original object. Modifying it inside the function will modify the original. This is often desirable for efficiency but can be unexpected.
    def add_to_list(item, target_list=[]): # Warning: Mutable default argument!
        target_list.append(item)
        return target_list
    
    # The default list is created once when the function is defined, not each time it's called.
    print(add_to_list(1)) # Output: [1]
    print(add_to_list(2)) # Output: [1, 2]  (It reused the same list from the first call)
    
    The correct pattern is to use an immutable default (like None) and create the mutable object inside the function:
    def add_to_list(item, target_list=None):
        if target_list is None:
            target_list = []  # Create a new list each time
        target_list.append(item)
        return target_list
    
  3. Identity vs. Equality Checks: Use is to check if two names refer to the exact same object (identity). Use == to check if two objects have the same value (equality).
    a = [1, 2, 3]
    b = [1, 2, 3]
    c = a
    
    print(a == b) # True (same value)
    print(a is b) # False (different objects)
    print(a is c) # True (same object)
    

id(), is, and ==: Identity vs Equality

In Python, every object has an identity, a type, and a value. The identity of an object is a unique, constant integer (or long integer) that acts as its address in memory. This identity remains unchanged throughout the object’s lifetime and is retrieved using the built-in id() function. Understanding the distinction between an object’s identity and its value is fundamental to grasping Python’s object model and is crucial for writing correct and efficient code.

The id() Function and Object Identity

The id() function returns the “identity” of an object. This integer is guaranteed to be unique and constant for the object during its lifetime. While it often corresponds to the object’s memory address in the CPython implementation, this is an implementation detail. The Python language specification only requires that it be a unique, unchanging number. Two objects with non-overlapping lifetimes can have the same id() value.

x = 1000
y = 1000
print(f"id(x): {id(x)}")
print(f"id(y): {id(y)}")
# Output will likely show two different IDs, e.g.:
# id(x): 139716374495664
# id(y): 139716374495632

The is Operator: Identity Comparison

The is operator checks if two variables reference the exact same object; that is, it compares the identity of the objects (id(a) == id(b)). It does not compare the values or contents of the objects. You should use is when you care about object identity, such as checking if a variable is bound to the singleton None.

list_a = [1, 2, 3]
list_b = [1, 2, 3] # A new list with the same values
list_c = list_a    # Another reference to the *same* list object

print(list_a is list_b) # False - Different objects in memory
print(list_a is list_c) # True - Same object

# The correct way to check for None:
value = None
if value is None:
    print("The value is None")

The == Operator: Equality Comparison

The == operator checks for value equality. It evaluates to True if the objects referred to by the variables have the same value or content, as defined by the __eq__() method of the object on the left. This method can be overridden by custom classes to define what equality means for their instances. == is used when the logical content of the objects is what matters, not their specific identity.

list_a = [1, 2, 3]
list_b = [1, 2, 3]

print(list_a == list_b) # True - The contents are the same
print(list_a is list_b) # False - But they are distinct objects

dict_a = {'a': 1}
dict_b = {'a': 1}
print(dict_a == dict_b) # True

The Interning Pitfall: Small Integers and Strings

A major source of confusion arises from Python’s interning optimization. For performance reasons, CPython caches and reuses certain immutable objects. This includes small integers (typically between -5 and 256) and short strings that look like identifiers. This can make the is operator return True for separate expressions that logically should create new objects, leading to subtle bugs if is is mistakenly used for value comparison.

# Due to interning:
a = 256
b = 256
print(a is b)  # True - Same cached integer object

c = 257
d = 257
print(c is d)  # False - This is outside the common range (in most contexts)
print(c == d)  # True - The values are equal

s1 = "hello"
s2 = "hello"
print(s1 is s2) # True - Interned string

s3 = "hello world!"
s4 = "hello world!"
print(s3 is s4) # False - Longer string, not interned by default

Best Practices and When to Use Which

  1. Use is for Singletons: Always use is (and is not) to compare against the singletons None, True, and False. This is faster and unequivocally correct.

    # Correct
    if my_var is None:
        ...
    
    # Potentially error-prone if my_var defines __eq__
    if my_var == None:
        ...
    
  2. Use == for Value Checks: For all other value-based comparisons—numbers, strings, lists, dictionaries, or custom objects—use the == operator. This ensures your code checks for logical equivalence as intended.

  3. Avoid is for Non-Singletons: Never use is for comparing integers, strings, or other types where you care about the value. Relying on interning behavior is non-portable and will break with objects that aren’t interned.

  4. Custom Classes: When defining your own classes, you can control value equality by implementing the __eq__ method. Identity comparison (is) will always work based on the object’s unique id.

    class Book:
        def __init__(self, title):
            self.title = title
    
        def __eq__(self, other):
            # Define equality based on the title attribute
            if isinstance(other, Book):
                return self.title == other.title
            return False
    
    book1 = Book("Python 101")
    book2 = Book("Python 101")
    book3 = book1
    
    print(book1 == book2) # True - Same title
    print(book1 is book2) # False - Different objects
    print(book1 is book3) # True - Same object
    

In summary, is compares object identity (the memory address), while == compares object value (the content). Understanding this distinction is not just academic; it is essential for writing robust, bug-free Python code that behaves predictably across different implementations and contexts.

Augmented Assignment and Mutability

Augmented assignment operators provide a concise syntax for performing an operation and assigning the result back to the variable. While they appear similar to their expanded counterparts, their behavior differs significantly when dealing with mutable objects due to Python’s object model and the distinction between in-place modification and rebinding.

The Basics of Augmented Assignment

Python’s augmented assignment operators include +=, -=, *=, /=, //=, %=, **=, &=, |=, ^=, <<=, and >>=. They combine a binary operation with assignment. For immutable types like integers and strings, x += y is essentially equivalent to x = x + y, creating a new object and rebinding the name x to it.

# Example with immutable integers
x = 5
print(id(x))  # e.g., 139716374718112
x += 3
print(id(x))  # Different address, e.g., 139716374718176

Mutability and In-Place Operations

The critical distinction emerges with mutable objects like lists. For these types, augmented assignment operators attempt to perform the operation in-place if the object supports it. This means the original object is modified rather than creating a new one. The __iadd__() special method (or equivalent for other operators) is called if available; if not, Python falls back to __add__() and assignment.

# With mutable lists
list_a = [1, 2, 3]
print(id(list_a))  # e.g., 140234576209664
list_a += [4, 5]   # In-place modification via __iadd__
print(list_a)      # Output: [1, 2, 3, 4, 5]
print(id(list_a))  # Same address as before, e.g., 140234576209664

# Contrast with standard assignment
list_b = [1, 2, 3]
print(id(list_b))  # e.g., 140234576214144
list_b = list_b + [4, 5]  # Creates a new list via __add__
print(list_b)      # Output: [1, 2, 3, 4, 5]
print(id(list_b))  # Different address, e.g., 140234576209664

This in-place modification has profound implications for any other names that reference the same mutable object. Since the object itself is changed, all references reflect the modification.

# Multiple references to the same mutable object
original_list = [1, 2, 3]
alias = original_list  # Both names point to the same list object

original_list += [4]  # In-place modification
print(original_list)  # Output: [1, 2, 3, 4]
print(alias)          # Output: [1, 2, 3, 4] - alias sees the change

# This does NOT happen with immutable types or standard assignment
num_a = 10
num_b = num_a  # Both point to the same integer 10
num_a += 5     # Rebinds num_a to a new integer 15
print(num_a)   # Output: 15
print(num_b)   # Output: 10 - num_b is unchanged

Common Pitfalls and Subtleties

A frequent pitfall arises when assuming augmented assignment always creates a new object or always modifies in-place. The behavior is type-dependent. Furthermore, the fallback mechanism can lead to unexpected performance issues or errors.

# Pitfall: Using += with tuples (immutable)
# Although tuples are immutable, += is syntactically valid but misleading.
tup = (1, 2, 3)
print(id(tup))  # e.g., 140234576113216
tup += (4,)     # This is NOT in-place. It's tup = tup + (4,)
print(tup)      # Output: (1, 2, 3, 4)
print(id(tup))  # A new address, e.g., 140234576250944

# Pitfall: Operator does not exist
class CustomObj:
    def __init__(self, val):
        self.val = val
    # Note: No __iadd__ method defined

obj = CustomObj(5)
# obj += 10  # This would raise TypeError: unsupported operand type(s) for +=: 'CustomObj' and 'int'
# It falls back to obj = obj + 10, but __add__ is also not defined.

Best Practices and Explicit Intent

  1. Use for Readability and Intent: Use augmented assignment for its concise syntax and to clearly signal an in-place update is intended or acceptable, especially with mutable collections.
  2. Be Aware of Aliasing: Always be conscious of other variables that might be aliased to the same mutable object when using in-place operators. If you need to avoid side effects, use the standard form (x = x + y) which always creates a new object for immutable types and typically does for mutable types as well.
  3. Check for Support: If designing a custom class, define the appropriate in-place methods (__iadd__, __imul__, etc.) if efficient in-place modification is possible. If not, be aware that the augmented assignment syntax will still work if __add__ is defined, but it will be less efficient due to the creation of a temporary object.
  4. Prefer In-Place for Large Mutables: For large mutable data structures like lists, preferring += (which uses .extend()) over x = x + y is a performance optimization, as it avoids the creation of an unnecessary copy.
# Best Practice: Using extend() or += for large lists
large_list = list(range(10000))
# Efficient - modifies in-place
large_list += list(range(10000, 20000))
# OR
large_list.extend(range(10000, 20000))

# Less efficient - creates a new large list and rebinds the name
# large_list = large_list + list(range(10000, 20000))

Multiple Assignment and Tuple Unpacking

Multiple assignment, often referred to as tuple unpacking or iterable unpacking, is a powerful and syntactically elegant feature in Python that allows you to assign values to multiple variables in a single, concise statement. At its core, this mechanism leverages Python’s fundamental concept of iterables, providing a clean and readable way to handle data structures without cumbersome indexing.

The Basic Syntax and Mechanism

The most common form of multiple assignment involves a tuple on the left-hand side of the assignment operator (=) and an iterable of the same length on the right-hand side. Python evaluates the entire right-hand side first, creating a tuple of the resulting values. It then assigns each value from this tuple to the corresponding variable on the left, in order.

# Basic multiple assignment
a, b, c = 1, 2, 3
print(a, b, c)  # Output: 1 2 3

# The right-hand side can be any iterable: a list, a string, a range, etc.
name, age, department = ["Alice", 30, "Engineering"]
print(name)  # Output: Alice

first, second, third = "XYZ"
print(second)  # Output: Y

x, y = (10, 20)
print(y)  # Output: 20

This works because the right-hand side expressions are automatically packed into a tuple. The statement a, b, c = 1, 2, 3 is functionally identical to a, b, c = (1, 2, 3).

Swapping Variables

One of the most celebrated applications of multiple assignment is swapping the values of two variables without needing a temporary variable. This is not only concise but also highly efficient and a clear demonstration of Python’s elegance.

# The classic, verbose way with a temporary variable
a = 5
b = 10
temp = a
a = b
b = temp
print(a, b)  # Output: 10 5

# The Pythonic way with multiple assignment
a = 5
b = 10
a, b = b, a  # The right-hand side (b, a) is evaluated first, creating (10, 5)
print(a, b)  # Output: 10 5

The right-hand side (b, a) is evaluated first, creating a temporary tuple (10, 5). This tuple is then unpacked and its elements are assigned to a and b respectively. This avoids any potential for data loss during the swap.

Extended Unpacking with the Star Operator (*)

A common pitfall with basic unpacking is the requirement for an exact match in the number of items. Python solves this with the star operator (*), which allows you to capture a variable number of elements into a list during unpacking. This is invaluable for handling iterables of unknown or varying length.

# Without extended unpacking - this will fail if the list has more than 3 items
# first, middle, last = [1, 2, 3, 4, 5]  # ValueError: too many values to unpack

# Using the star operator to capture "the rest"
first, *middle, last = [1, 2, 3, 4, 5]
print(first)   # Output: 1
print(middle)  # Output: [2, 3, 4]  (a list)
print(last)    # Output: 5

# The star operator can be used in different positions
*beginning, final = range(10)
print(beginning)  # Output: [0, 1, 2, 3, 4, 5, 6, 7, 8]
print(final)      # Output: 9

first, *rest = "Hello"
print(rest)  # Output: ['e', 'l', 'l', 'o']

The starred variable will always be a list, even if it captures zero or one elements. This behavior ensures consistency and prevents errors.

Ignoring Unwanted Values

Often, you are only interested in a few specific elements from an iterable. Using the underscore (_) as a variable name is a Python convention to indicate that a value is being intentionally ignored or is unimportant. This significantly improves code readability.

# Unpacking a tuple but only needing the first and last values
data = ("Acme Corp", "123 Main St", "Anytown", "NY", "12345")
company, *_, zip_code = data
print(company)   # Output: Acme Corp
print(zip_code)  # Output: 12345
# The address and state are stored in the _ variable, signaling they are not used.

# You can ignore multiple specific positions
first, _, third, _, fifth = [10, 20, 30, 40, 50]
print(first, third, fifth)  # Output: 10 30 50

Deep Unpacking (Nested Unpacking)

Multiple assignment can be nested to unpack complex, nested data structures in a single, clear statement. This is sometimes called “deep unpacking” and is a powerful tool for destructuring data.

# A list of tuples representing (x, y) coordinates
points = [(1, 2), (3, 4), (5, 6)]

# Iterating with nested unpacking
for (x, y) in points:  # The parentheses are optional but add clarity
    print(f"X: {x}, Y: {y}")

# Deeper nesting
data = ("Alice", (30, "Software Engineer"))
name, (age, profession) = data
print(f"{name} is a {age}-year-old {profession}.")
# Output: Alice is a 30-year-old Software Engineer.

Common Pitfalls and Best Practices

  1. Mismatched Lengths: The most frequent error is attempting to unpack an iterable with a different number of elements than there are targets. Always ensure the structures match or use the star operator to handle variability.

    # This will raise a ValueError
    a, b, c = [1, 2]      # ValueError: not enough values to unpack
    a, b = [1, 2, 3]      # ValueError: too many values to unpack
    
  2. Clarity over Cleverness: While deep unpacking is powerful, avoid overly complex one-liners that become difficult to read. Break down deeply nested structures into multiple steps if it improves clarity.

  3. Use the Underscore (_): Consistently use _ for discarded values. This acts as clear documentation for anyone reading your code, indicating the ignore was intentional.

  4. Remember the Starred Variable is a List: The * operator always collects items into a list, even if the source iterable is a tuple or string. This is important for memory considerations with very large iterables.

Understanding multiple assignment is key to writing idiomatic Python. It reduces lines of code, eliminates error-prone indexing, and explicitly defines the expected structure of your data, leading to programs that are both more robust and more readable.

Constants by Convention and Final Type Hints

In Python, the concept of a “constant” is not enforced by the language’s syntax. Unlike languages such as C++ or Java, Python does not have a built-in const keyword that prevents a variable from being reassigned. This design choice aligns with Python’s philosophy of “we are all consenting adults here,” trusting developers to understand and respect the intended usage of variables defined by the community or project conventions. Instead, Python offers two primary mechanisms to signal the intent of a value that should remain unchanged: a strong naming convention and type hinting for static analysis.

The Naming Convention for Constants

The universally adopted convention to denote a variable as a constant is to use all uppercase letters with underscores separating words. For example, MAX_SPEED, PI, or DATABASE_CONFIG_FILE. This does not create a true constant in the technical sense; the interpreter will not raise an error if you reassign PI = 4 later in your code. Its power is purely semantic and social. It communicates a clear intent to other developers (and your future self) that this variable is meant to be read-only and its value should be considered fixed throughout the program’s execution. Violating this convention is considered poor style and makes code harder to maintain and reason about.

# Conventional declaration of constants
MAX_CONNECTIONS = 100
DEFAULT_TIMEOUT = 30.5
API_BASE_URL = "https://api.example.com"

# This will work, but it is a BAD PRACTICE and confusing.
MAX_CONNECTIONS = 50  # Avoid this! The original intent was for this value to be constant.

def configure_network(timeout: float = DEFAULT_TIMEOUT) -> None:
    """Uses the 'constant' as a default value."""
    print(f"Configuring with timeout: {timeout}")

configure_network()

Using the Final and Literal Type Hints

While the naming convention is effective for human readers, static type checkers like mypy can enforce constancy more rigorously using the Final and Literal type hints from the typing module. A Final annotation declares that a variable should not be reassigned or overridden. It can also be used for class and instance attributes. When a type checker analyzes the code, it will flag any attempt to reassign a Final variable as an error, catching potential bugs before runtime.

from typing import Final, Literal

# Final for a simple constant
MAX_USERS: Final[int] = 1024
# This next line would cause a static type checking error (e.g., in mypy, PyCharm)
# MAX_USERS = 512  # Error: Cannot assign to final name "MAX_USERS"

# Final can also be used without an explicit type annotation
DATABASE_NAME: Final = "primary_db"

# Literal for a value that must be exactly one of a set of specific values
VALID_STATUS: Literal["pending", "active", "inactive"] = "pending"
# This would also be a type error:
# VALID_STATUS = "invalid"  # Error: ...is not a valid literal member

It is crucial to understand that Final is a static type checking tool, not a runtime enforcement mechanism. The Python interpreter itself will still allow the reassignment. The error is only raised by the external type checker during development.

Combining Convention and Type Hints

The most robust and Pythonic approach is to combine both the naming convention and type hints. The uppercase name immediately signals the intent to any human reader, while the Final annotation enables automated tools to verify that intent is being followed consistently across the codebase.

from typing import Final

# The ideal way to define a constant: clear to humans and machines.
CONFIG_PATH: Final[str] = "/etc/app/config.ini"

Constants Within Functions and the nonlocal Pitfall

Constants are typically defined at the module level. However, the concept can be applied within functions or classes. A common pitfall arises when a “constant” is defined in an enclosing scope and you try to use it within a nested function. If you assign to a variable in a nested function, Python treats it as a local variable by default. To read a value from an outer scope, you simply use it. But to rebind it (even though you shouldn’t), you would need the nonlocal keyword. This highlights the difference between mutating an object and reassigning a variable name.

MODULE_LEVEL_CONSTANT: Final = "I'm a module constant"

def outer_function():
    FUNCTION_CONSTANT = "I'm in a function"  # This is also a convention.

    def inner_function():
        # This works fine - we are only reading the value.
        print(FUNCTION_CONSTANT)

        # This would be a bad practice, but to do it, you'd need 'nonlocal'.
        # Without the next line, this would create a new local variable and shadow the outer one.
        nonlocal FUNCTION_CONSTANT
        FUNCTION_CONSTANT = "I've been changed!"  # Very bad practice for a "constant".

    inner_function()
    print(FUNCTION_CONSTANT)  # Output: "I've been changed!"

# The module-level constant is accessible here.
print(MODULE_LEVEL_CONSTANT)

Best Practices and Summary

  1. Use Uppercase Naming: Always use UPPER_CASE_WITH_UNDERSCORES for variables intended to be constants.
  2. Add Final Type Hints: For critical values, use the Final type hint to enable static checking and make the intent unambiguous.
  3. Define at the Module Level: Constants should be defined at the top of a module so they are easily discoverable.
  4. Understand the Limitation: Remember that these are not true constants. They can be overridden, but doing so is a clear violation of best practices and should be avoided.
  5. For Object Contents: Final only prevents reassignment of the variable name itself. If the constant is a mutable object (like a list or dict), its contents can still be modified. To prevent this, consider using immutable types (e.g., tuple instead of list) or a frozenset.
from typing import Final

IMMUTABLE_CONST: Final[tuple[int, ...]] = (1, 2, 3)
# IMMUTABLE_CONST[0] = 99  # This will cause a runtime TypeError: 'tuple' object does not support item assignment

MUTABLE_CONST: Final[list[int]] = [1, 2, 3]
MUTABLE_CONST[0] = 99  # This is allowed! Final only protects the reference, not the object's state.
# MUTABLE_CONST = [4, 5, 6]  # This would be a type error from Final.

Del and Reference Counting

In Python, memory management is primarily handled through a system of automatic reference counting. When an object’s reference count drops to zero, the memory allocated for that object is reclaimed by the garbage collector. The del statement is a core language feature that plays a direct and crucial role in this process. It is not a function but a statement that explicitly deletes a reference to an object, thereby directly decrementing its reference count.

The Mechanics of the del Statement

At its core, the del statement removes a binding between a name (or an item in a container) and the object it references. Its syntax is versatile, allowing you to delete single variables, items from sequences (like lists), or slices.

# Deleting a single variable
x = 10
del x  # The name 'x' is removed from the namespace.
# print(x)  # This would raise a NameError: name 'x' is not defined

# Deleting an item from a list
my_list = [1, 2, 3, 4, 5]
del my_list[1]  # Removes the item at index 1 (the value 2)
print(my_list)  # Output: [1, 3, 4, 5]

# Deleting a slice from a list
del my_list[1:3]  # Removes items from index 1 up to, but not including, index 3
print(my_list)  # Output: [1, 5]

# Deleting a key from a dictionary
my_dict = {'a': 1, 'b': 2, 'c': 3}
del my_dict['b']
print(my_dict)  # Output: {'a': 1, 'c': 3}

It is critical to understand that del deletes the reference, not necessarily the object itself. The object is only destroyed if the reference count reaches zero as a result of the del operation.

Interaction with Reference Counting

Every object in Python has a counter that keeps track of the number of references pointing to it. The del statement directly reduces this count by one. To observe this interaction, we can use the sys.getrefcount() function, keeping in mind that the function itself creates a temporary reference for its argument.

import sys

# Create a new list object; refcount is at least 1 (variable 'a')
a = [1, 2, 3]
print(sys.getrefcount(a))  # Output might be 2 (one for 'a', one for the function argument)

# Create another reference to the same object
b = a
print(sys.getrefcount(a))  # Output should be one higher (e.g., 3)

# Deleting one reference ('b') decrements the count
del b
print(sys.getrefcount(a))  # Output should be one lower (e.g., 2)

# Deleting the final reference ('a') will lead to the object's destruction
del a
# The list object [1, 2, 3] is now eligible for garbage collection.

Common Pitfalls and Misconceptions

A frequent misconception is that del frees memory immediately. This is not true. del only decreases the reference count. The actual memory deallocation is handled by Python’s garbage collector, which may not happen instantly. Furthermore, del is powerless against objects with circular references—where two or more objects reference each other, keeping their counts above zero even if they are unreachable from the main program. For these, the separate garbage collector (gc module) is required to identify and collect them.

Another critical pitfall involves the interaction between del and mutable sequences. Deleting items from a list you are currently iterating over can lead to unexpected behavior and bugs because it shifts the indices of all subsequent elements.

# DANGER: Modifying a list during iteration
numbers = [1, 2, 3, 4, 5]
for i, num in enumerate(numbers):
    if num % 2 == 0:  # if the number is even
        del numbers[i]  # This causes a shift, skipping elements.
        print(f"Deleted {num}. List is now: {numbers}")

The output of this code is unpredictable. A safer pattern is to iterate over a copy of the list or use a list comprehension to create a new list.

Best Practices and When to Use del

The primary use case for del is to explicitly clean up references to large objects when you are certain they are no longer needed, especially within a long-running function or loop. This can help manage memory footprint more predictably. It is also essential for manipulating data structures in-place, such as removing specific keys from a dictionary.

However, for most general-purpose code, explicitly using del is often unnecessary. Python’s scoping rules mean that local variables (including references to large objects) are automatically deleted when a function exits, freeing the associated memory. Therefore, the best practice is to use del judiciously—only when its effect on reference counting and memory management is clearly understood and needed for a specific optimization or data structure operation. Relying on Python’s automatic garbage collection for the majority of cleanup is typically the most robust and error-free approach.