Python Variables: Flexible Data Containers

Welcome to an in-depth exploration of variables and expressions in Python! This journey will take you through the versatile world of Python's variable system, complete with duck-typing, None values, and practical applications that will serve you throughout your programming career.

By the end of this tutorial, you'll confidently be able to:

The Variable Warehouse: How Python Stores Data

Think of Python variables as labeled storage containers in a vast warehouse of computer memory. Unlike some warehouses that only allow specific items in specific containers, Python's warehouse is remarkably flexible.

graph TD A[Memory Space] --> B[Variable: name_var] B --> C["Value: Hello World"] A --> D[Variable: age] D --> E["Value: 25"] A --> F[Variable: items] F --> G["Value: [1, 2, 3]"]

Each container (variable) has a name tag (the variable name) and can store virtually any type of content (the value). What makes Python special is how easily you can change both what's in the container and even what type of content it holds.

Duck-typing: "If It Quacks Like a Duck..."

Python embraces a philosophy called duck-typing, derived from the saying: "If it walks like a duck and quacks like a duck, then it probably is a duck." In programming terms, this means Python cares more about what an object can do rather than what it is.

graph TD A[Object] --> B{Can it be iterated over?} B -->|Yes| C[Use it in a for loop] B -->|No| D[TypeError] A --> E{Can it be added?} E -->|Yes| F[Use + operator] E -->|No| G[TypeError]

Duck-typing allows Python to be remarkably flexible. Consider this example:

def print_all_items(collection):
    for item in collection:
        print(item)

# Works with a list
print_all_items([1, 2, 3])

# Also works with a tuple
print_all_items((4, 5, 6))

# Even works with a string
print_all_items("Python")

# And works with a dictionary (iterates through keys)
print_all_items({"a": 1, "b": 2})

The print_all_items function doesn't care what type collection is. It only cares that it can iterate over it. This is duck-typing in action: "If it can be iterated over like a collection, we'll treat it as a collection."

EAFP: Python's Forgiveness-Seeking Approach

Python programmers often follow a principle called EAFP: "Easier to Ask for Forgiveness than Permission." This means rather than checking if an operation is possible beforehand, we attempt the operation and handle any exceptions that arise.

# Non-Pythonic way (LBYL - Look Before You Leap)
if isinstance(obj, list) and len(obj) > 0:
    first_item = obj[0]
else:
    first_item = None

# Pythonic way (EAFP)
try:
    first_item = obj[0]
except (IndexError, TypeError):
    first_item = None

The EAFP approach is generally more concise and handles edge cases better, especially in a language like Python with duck-typing.

Real-world Duck-typing Example: File-like Objects

In Python's standard library, many functions that work with files don't actually require a file object—they just need something that behaves like one:

import io

# A string buffer that acts like a file
fake_file = io.StringIO("Hello, duck-typing world!")

# Reading from our fake file
content = fake_file.read()
print(content)  # Outputs: Hello, duck-typing world!

# This function works with real files AND file-like objects
def read_first_line(file_obj):
    original_position = file_obj.tell()  # Save position
    file_obj.seek(0)  # Go to start
    first_line = file_obj.readline()
    file_obj.seek(original_position)  # Restore position
    return first_line

# Works with our fake file
print(read_first_line(fake_file))  # Outputs: Hello, duck-typing world!

# Would also work with a real file
with open("real_file.txt", "w+") as real_file:
    real_file.write("This is a real file.\nWith multiple lines.")
    print(read_first_line(real_file))  # Outputs: This is a real file.

This flexibility is powerful: it means you can create custom objects that work with existing functions, as long as they implement the necessary methods.

Creating Variables: Python's No-Declaration Approach

Unlike languages like JavaScript or Java, Python doesn't use keywords like var, let, or const to declare variables. Instead, a variable is created the moment you assign a value to it.

# Creating variables with simple assignment
username = "pythonista"
score = 95
is_active = True

# Python automatically determines the appropriate type
print(type(username))  # <class 'str'>
print(type(score))     # <class 'int'>
print(type(is_active)) # <class 'bool'>

Think of variable assignment as labeling a box with contents. When you write score = 95, you're telling Python: "Take this value 95 and put a label called 'score' on it."

flowchart LR A["score = 95"] --> B["Memory"] B --> C["score: 95"]

Multiple Assignment Techniques

Python offers several elegant ways to assign multiple variables at once:

# Assigning same value to multiple variables
x = y = z = 0
print(x, y, z)  # 0 0 0

# Unpacking values
coordinates = (10, 20)
x, y = coordinates
print(x, y)  # 10 20

# Swapping values (without temporary variable)
a, b = 5, 10
a, b = b, a
print(a, b)  # 10 5

# Extended unpacking (Python 3+)
first, *middle, last = [1, 2, 3, 4, 5]
print(first)   # 1
print(middle)  # [2, 3, 4]
print(last)    # 5

The unpacking technique is particularly useful when working with functions that return multiple values:

def get_user_info():
    return "Alice", 28, "Developer"

# Unpack the returned tuple into separate variables
name, age, occupation = get_user_info()
print(f"{name} is a {age}-year-old {occupation}")
# Outputs: Alice is a 28-year-old Developer

Variable Flexibility: Type Reassignment

One of Python's most distinctive features is how it allows variables to change types. While this flexibility is powerful, it also requires careful attention to your code's logic.

counter = 0       # counter is an integer
print(counter)    # 0

counter = "Zero"  # Now counter is a string
print(counter)    # Zero

counter = [0, 1]  # Now counter is a list
print(counter)    # [0, 1]

Imagine a variable as a name tag that can be moved from one object to another. When you reassign a variable, you're not changing the original object—you're simply moving the name tag to a different object.

sequenceDiagram participant Var as counter participant Int as int(0) participant Str as str("Zero") participant List as list([0, 1]) Var->>Int: counter = 0 Note over Var,Int: counter refers to an integer Var->>Str: counter = "Zero" Note over Var,Str: counter now refers to a string Var->>List: counter = [0, 1] Note over Var,List: counter now refers to a list

Best Practices for Variable Type Changes

While Python allows type changes, following certain conventions makes your code more maintainable:

# Using type hints for clarity (Python 3.5+)
def calculate_total(prices: list) -> float:
    """Calculate the sum of all prices."""
    return sum(prices)

# Even with type hints, Python still allows flexibility
result = calculate_total([10.99, 24.50, 5.00])  # Works as expected
print(result)  # 40.49

# This will run (but might cause logical errors later)
alternative_result = calculate_total("123")  # Sums ASCII values!
print(alternative_result)  # 150 (ASCII: '1' = 49, '2' = 50, '3' = 51)

Variable Manipulation: Operators and Shortcuts

Python provides a variety of operators and shortcuts to manipulate variables efficiently.

Augmented Assignment Operators

These operators combine an operation with assignment, making your code more concise:

# Starting value
count = 10

# Arithmetic augmented assignments
count += 5      # Addition (count = count + 5)
print(count)    # 15

count -= 3      # Subtraction (count = count - 3)
print(count)    # 12

count *= 2      # Multiplication (count = count * 2)
print(count)    # 24

count /= 4      # Division (count = count / 4)
print(count)    # 6.0 (note: converts to float)

count //= 2     # Floor division (count = count // 2)
print(count)    # 3.0 (still a float)

count **= 2     # Exponentiation (count = count ** 2)
print(count)    # 9.0

count %= 5      # Modulo (count = count % 5)
print(count)    # 4.0

These operators aren't limited to numbers. Many work with other types as well:

# String augmented assignment
greeting = "Hello"
greeting += ", World!"  # String concatenation
print(greeting)        # Hello, World!

# List augmented assignment
numbers = [1, 2, 3]
numbers += [4, 5]      # List concatenation
print(numbers)         # [1, 2, 3, 4, 5]

numbers *= 2           # List replication
print(numbers)         # [1, 2, 3, 4, 5, 1, 2, 3, 4, 5]

Compound Variable Manipulation

Often, you'll need to combine multiple operations. Python's expressions can be as simple or complex as needed:

# Calculate area of a circle
radius = 5
pi = 3.14159
area = pi * (radius ** 2)
print(f"Area of circle: {area}")  # Area of circle: 78.53975

# String formatting with variables
first_name = "Ada"
last_name = "Lovelace"
full_name = f"{first_name} {last_name}"
print(full_name)  # Ada Lovelace

# List comprehension (creating a list from an expression)
squares = [n ** 2 for n in range(1, 6)]
print(squares)  # [1, 4, 9, 16, 25]

The None Value: Python's Representation of Nothing

In Python, None is a special constant that represents the absence of a value or a null value. It serves a similar purpose to null in many other programming languages. However, JavaScript has both null and undefined, while Python just has None.

flowchart TB A[Variable] --> B{Has a value?} B -->|Yes| C[Regular value: int, str, list, etc.] B -->|No| D[None value]

When to Use None

None is particularly useful in several scenarios:

# Function that might not find what it's looking for
def find_user(username, user_database):
    for user in user_database:
        if user.get('username') == username:
            return user
    return None  # Explicitly return None when user not found

# Check for None with 'is' operator (not ==)
result = find_user("alice", users_list)
if result is None:
    print("User not found")
else:
    print(f"Found user: {result}")

The Importance of is None vs == None

When checking for None, always use the is operator rather than ==. The is operator checks for identity (whether two variables point to the same object), while == checks for equality (whether two variables have the same value).

# The correct way to check for None
if variable is None:
    print("Variable is None")

# The correct way to check for not None
if variable is not None:
    print("Variable has a value")

Since None is a singleton (only one instance exists in the entire Python program), identity comparison with is is both more correct and slightly faster.

Default Return Value

If a function doesn't explicitly return a value, it automatically returns None:

def greet(name):
    print(f"Hello, {name}!")
    # No return statement

result = greet("World")
print(result)  # None

This is also true for a bare return statement with no value:

def early_exit_function():
    if error_condition:
        return  # Returns None
    # Rest of function...

Dealing with Errors: When Variables Go Wrong

Unlike JavaScript, which returns NaN ("Not a Number") for many invalid operations, Python prefers to throw explicit errors rather than silently continue with potentially problematic values.

Common Variable-Related Errors

Understanding these errors helps you debug your code more efficiently:

# NameError: Trying to use a variable that doesn't exist
print(undefined_variable)  # NameError: name 'undefined_variable' is not defined

# TypeError: Performing an operation with incompatible types
number = "42"
result = number / 2  # TypeError: unsupported operand type(s) for /: 'str' and 'int'

# ValueError: Valid type but invalid value
int("not a number")  # ValueError: invalid literal for int() with base 10

# IndexError: Accessing an index that doesn't exist
my_list = [1, 2, 3]
print(my_list[10])  # IndexError: list index out of range

# KeyError: Accessing a dictionary key that doesn't exist
my_dict = {"a": 1, "b": 2}
print(my_dict["c"])  # KeyError: 'c'

Handling Variable Errors with Try/Except

Python's exception handling allows you to gracefully manage errors that might occur when working with variables:

# Safer conversion from string to number
def safe_convert_to_int(value):
    try:
        return int(value)
    except ValueError:
        print(f"Warning: '{value}' is not a valid integer")
        return None

# Testing our function
print(safe_convert_to_int("42"))      # 42
print(safe_convert_to_int("3.14"))    # Warning: '3.14' is not a valid integer
                                      # None
print(safe_convert_to_int("Hello"))   # Warning: 'Hello' is not a valid integer
                                      # None

Defensive Programming with Variables

When building robust applications, it's important to anticipate potential variable issues:

# Safe dictionary access
def get_user_preference(user_profile, preference_name, default_value=None):
    """Safely get a user preference or return default if not found."""
    if user_profile is None:
        return default_value
    
    try:
        return user_profile['preferences'][preference_name]
    except (KeyError, TypeError):
        return default_value

# Example usage
user = {
    'name': 'Alex',
    'preferences': {
        'theme': 'dark',
        'notifications': True
    }
}

empty_user = None

print(get_user_preference(user, 'theme'))               # dark
print(get_user_preference(user, 'language', 'en'))      # en (default)
print(get_user_preference(empty_user, 'theme', 'light')) # light (default)

Real-World Applications

Let's explore how these concepts apply in practical, real-world programming scenarios:

Data Processing Pipeline

When processing data from external sources, you often deal with missing or inconsistent values:

def clean_customer_record(record):
    """Process a customer record, filling in missing values appropriately."""
    # Initialize a clean record with defaults
    clean_record = {
        'id': None,
        'name': 'Unknown',
        'email': None,
        'age': None,
        'is_active': False,
        'last_purchase': None
    }
    
    # If no record provided, return defaults
    if record is None:
        return clean_record
    
    # Transfer and validate each field
    if 'id' in record and record['id']:
        clean_record['id'] = str(record['id'])  # Ensure ID is string
    
    if 'name' in record and record['name']:
        clean_record['name'] = record['name'].strip()
    
    if 'email' in record:
        email = record['email']
        if email and '@' in email:
            clean_record['email'] = email.lower().strip()
    
    # Handle age with validation
    if 'age' in record:
        try:
            age = int(record['age'])
            if 0 <= age <= 120:  # Basic age validation
                clean_record['age'] = age
        except (ValueError, TypeError):
            pass  # Keep as None if invalid
    
    # Boolean conversion
    if 'is_active' in record:
        clean_record['is_active'] = bool(record['is_active'])
    
    # Date handling
    if 'last_purchase' in record and record['last_purchase']:
        # Just store as is for this example
        clean_record['last_purchase'] = record['last_purchase']
    
    return clean_record

# Test with various record qualities
perfect_record = {
    'id': 1001,
    'name': 'John Smith',
    'email': 'john@example.com',
    'age': 34,
    'is_active': True,
    'last_purchase': '2025-01-15'
}

partial_record = {
    'id': 1002,
    'name': '  Jane Doe  ',
    'age': 'unknown'
}

print(clean_customer_record(perfect_record))
print(clean_customer_record(partial_record))
print(clean_customer_record(None))

Web Application Configuration

Managing application settings with a mix of defaults, environment variables, and user preferences:

import os

def load_configuration():
    """Load application configuration from multiple sources."""
    # Default configuration
    config = {
        'debug': False,
        'port': 8080,
        'db_host': 'localhost',
        'db_port': 5432,
        'log_level': 'info',
        'max_connections': 100,
        'timeout': 30
    }
    
    # Override with environment variables if they exist
    if 'APP_DEBUG' in os.environ:
        debug_val = os.environ['APP_DEBUG'].lower()
        config['debug'] = debug_val in ('true', 'yes', '1', 'y')
    
    if 'APP_PORT' in os.environ:
        try:
            config['port'] = int(os.environ['APP_PORT'])
        except ValueError:
            print(f"Warning: Invalid APP_PORT value: {os.environ['APP_PORT']}")
    
    # Database configuration
    for key in ('db_host', 'db_port'):
        env_key = f"APP_{key.upper()}"
        if env_key in os.environ:
            if key == 'db_port':
                try:
                    config[key] = int(os.environ[env_key])
                except ValueError:
                    print(f"Warning: Invalid {env_key} value")
            else:
                config[key] = os.environ[env_key]
    
    # Try to load from configuration file
    config_file = os.environ.get('CONFIG_FILE', 'config.json')
    try:
        with open(config_file, 'r') as f:
            import json
            file_config = json.load(f)
            # Update our config with file values
            config.update(file_config)
    except (FileNotFoundError, json.JSONDecodeError):
        # It's okay if the file doesn't exist or isn't valid JSON
        pass
    
    return config

# Usage
app_config = load_configuration()
print(f"Starting server on port {app_config['port']} with debug={app_config['debug']}")

Machine Learning Feature Processing

Using flexible variable handling for data preprocessing:

def preprocess_features(features, normalize=True, fill_missing=True):
    """Preprocess feature data for machine learning."""
    import statistics
    
    if features is None or len(features) == 0:
        return []
    
    processed_features = []
    
    # Calculate statistics for each feature column
    column_stats = {}
    for i in range(len(features[0])):
        # Extract column values, filtering out None
        column_values = [row[i] for row in features if i < len(row) and row[i] is not None]
        
        if column_values:
            column_stats[i] = {
                'mean': statistics.mean(column_values),
                'median': statistics.median(column_values),
                'min': min(column_values),
                'max': max(column_values),
                'range': max(column_values) - min(column_values) if len(column_values) > 1 else 1
            }
        else:
            # Default stats if no valid values
            column_stats[i] = {
                'mean': 0,
                'median': 0,
                'min': 0,
                'max': 0,
                'range': 1
            }
    
    # Process each row
    for row in features:
        processed_row = []
        
        for i in range(len(row)):
            value = row[i]
            
            # Handle missing values
            if value is None and fill_missing:
                value = column_stats[i]['median']  # Use median for missing values
            
            # Normalize if requested
            if normalize and value is not None:
                stats = column_stats[i]
                # Min-max normalization
                if stats['range'] != 0:
                    value = (value - stats['min']) / stats['range']
                else:
                    value = 0.5  # Default when all values are the same
            
            processed_row.append(value)
        
        processed_features.append(processed_row)
    
    return processed_features

# Example usage
raw_features = [
    [23.1, 5.2, 10.0],
    [19.7, None, 8.5],
    [25.3, 4.8, 11.2],
    [22.0, 5.0, None]
]

processed = preprocess_features(raw_features)
print("Processed features:")
for row in processed:
    print(row)

Practice Activities

Let's reinforce your understanding with some hands-on practice activities:

Activity 1: Variable Explorer

Create a function that takes any Python object and explores its properties, returning a report about the variable.

def explore_variable(variable):
    """
    Explore and report on the properties of a Python variable.
    
    Returns a dictionary with information about the variable.
    """
    report = {
        'type': type(variable).__name__,
        'memory_address': id(variable),
        'is_none': variable is None,
    }
    
    # Add type-specific information
    if isinstance(variable, (int, float, complex)):
        report['category'] = 'numeric'
        if isinstance(variable, int):
            report['is_even'] = variable % 2 == 0
            report['is_positive'] = variable > 0
    elif isinstance(variable, str):
        report['category'] = 'string'
        report['length'] = len(variable)
        report['is_empty'] = len(variable) == 0
        report['is_alphanumeric'] = variable.isalnum() if variable else False
    elif isinstance(variable, (list, tuple, set)):
        report['category'] = 'collection'
        report['length'] = len(variable)
        report['is_empty'] = len(variable) == 0
        if variable:  # If not empty
            # Check if all elements are of the same type
            first_type = type(next(iter(variable)))
            report['uniform_type'] = all(isinstance(item, first_type) for item in variable)
            report['element_types'] = list(set(type(item).__name__ for item in variable))
    elif isinstance(variable, dict):
        report['category'] = 'mapping'
        report['length'] = len(variable)
        report['keys'] = list(variable.keys())
    elif callable(variable):
        report['category'] = 'callable'
        report['is_function'] = callable(variable)
        import inspect
        report['takes_arguments'] = len(inspect.signature(variable).parameters) > 0 if inspect.isfunction(variable) else 'unknown'
    
    return report

# Test with different types
test_cases = [
    42,
    "Hello, World!",
    [1, 2, 3, "four"],
    {"a": 1, "b": 2},
    None,
    lambda x: x*2
]

for case in test_cases:
    report = explore_variable(case)
    print(f"Report for {case}:")
    for key, value in report.items():
        print(f"  {key}: {value}")
    print()

Activity 2: The Shape Shifter Challenge

Create a class that demonstrates Python's variable flexibility by shape-shifting between different types based on operations performed on it.

class ShapeShifter:
    """
    A class that changes its behavior and type based on operations.
    Demonstrates Python's dynamic typing.
    """
    def __init__(self, value=None):
        self.value = value
    
    def __repr__(self):
        return f"ShapeShifter({self.value})"
    
    def __str__(self):
        return str(self.value) if self.value is not None else "None"
    
    # Numeric operations
    def __add__(self, other):
        if isinstance(other, ShapeShifter):
            other = other.value
            
        if self.value is None:
            self.value = other
        elif isinstance(self.value, (int, float)) and isinstance(other, (int, float)):
            self.value += other
        elif isinstance(self.value, str) and isinstance(other, str):
            self.value += other
        elif isinstance(self.value, list):
            if isinstance(other, list):
                self.value.extend(other)
            else:
                self.value.append(other)
        else:
            # Convert to string and concatenate
            self.value = str(self.value) + str(other)
            
        return self
    
    # Multiplication
    def __mul__(self, other):
        if isinstance(other, ShapeShifter):
            other = other.value
            
        if isinstance(self.value, (int, float)) and isinstance(other, (int, float)):
            self.value *= other
        elif isinstance(self.value, str) and isinstance(other, int):
            self.value *= other
        elif isinstance(self.value, list) and isinstance(other, int):
            self.value = self.value * other
        else:
            # Default behavior: convert to string
            self.value = str(self.value) * (other if isinstance(other, int) else 1)
            
        return self
    
    # Boolean behavior
    def __bool__(self):
        return bool(self.value)
    
    # List-like behavior
    def __getitem__(self, index):
        if self.value is None:
            raise IndexError("Cannot index None value")
            
        if not hasattr(self.value, '__getitem__'):
            # Convert to string if not indexable
            self.value = str(self.value)
            
        return self.value[index]
    
    # Dictionary-like behavior
    def keys(self):
        if not isinstance(self.value, dict):
            # Convert to dict if not already
            if isinstance(self.value, list):
                self.value = {i: item for i, item in enumerate(self.value)}
            else:
                self.value = {"value": self.value}
                
        return self.value.keys()
    
    def clear(self):
        """Reset to None"""
        self.value = None
        return self

# Test the ShapeShifter
shifter = ShapeShifter(5)
print(f"Initial: {shifter}")  # 5

# Number operations
shifter = shifter + 10
print(f"After addition: {shifter}")  # 15

shifter = shifter * 2
print(f"After multiplication: {shifter}")  # 30

# Convert to string via concatenation with string
shifter = shifter + " points"
print(f"After string concat: {shifter}")  # "30 points"

# String operations
shifter = shifter * 2
print(f"After string replication: {shifter}")  # "30 points30 points"

# Access like a string
print(f"First character: {shifter[0]}")  # "3"

# Convert to list
shifter.clear()
shifter = shifter + [1, 2, 3]
print(f"As list: {shifter}")  # [1, 2, 3]

# List operations
shifter = shifter + [4, 5]
print(f"After list extend: {shifter}")  # [1, 2, 3, 4, 5]

shifter = shifter * 2
print(f"After list replication: {shifter}")  # [1, 2, 3, 4, 5, 1, 2, 3, 4, 5]

# Convert to dictionary
print(f"Keys: {list(shifter.keys())}")  # [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

Activity 3: Profile a Complex Application

In this activity, you'll create a function that analyzes a Python script to identify variable usage patterns, potential errors, and optimization opportunities.

def profile_script(script_text):
    """
    Analyze a Python script and profile its variable usage.
    
    Returns a report with variable analysis.
    """
    import ast
    import re
    
    report = {
        'variables': {},
        'potential_issues': [],
        'statistics': {
            'total_variables': 0,
            'reassigned_variables': 0,
            'type_changed_variables': 0,
            'unused_variables': 0,
            'none_assignments': 0
        }
    }
    
    # Simple pattern matching for variable assignments
    assignment_pattern = re.compile(r'(\w+)\s*=\s*(.+)')
    assignments = [match for match in assignment_pattern.findall(script_text)]
    
    # Track all variable assignments
    for var_name, value in assignments:
        if var_name not in report['variables']:
            report['variables'][var_name] = {
                'assignments': [],
                'potential_type_changes': False,
                'assigned_none': False
            }
        
        report['variables'][var_name]['assignments'].append(value.strip())
        
        # Check for None assignments
        if 'None' in value:
            report['variables'][var_name]['assigned_none'] = True
            report['statistics']['none_assignments'] += 1
            
    # Analyze for type changes
    for var_name, details in report['variables'].items():
        assignments = details['assignments']
        if len(assignments) > 1:
            report['statistics']['reassigned_variables'] += 1
            
            # Simple heuristic for type changes
            has_string = any('"' in a or "'" in a for a in assignments)
            has_number = any(a.isdigit() or a.replace('.', '', 1).isdigit() for a in assignments if a.strip() not in ['True', 'False', 'None'])
            has_list = any('[' in a and ']' in a for a in assignments)
            has_dict = any('{' in a and '}' in a for a in assignments)
            has_bool = any(a.strip() in ['True', 'False'] for a in assignments)
            has_none = any(a.strip() == 'None' for a in assignments)
            
            # Count different types
            type_count = sum([has_string, has_number, has_list, has_dict, has_bool, has_none])
            
            if type_count > 1:
                report['variables'][var_name]['potential_type_changes'] = True
                report['statistics']['type_changed_variables'] += 1
                report['potential_issues'].append(
                    f"Variable '{var_name}' appears to change types"
                )
    
    # Check for variables that might be unused
    var_usage_pattern = re.compile(r'(?= 4.0:
    performance_bonus = base_salary * 0.1
elif performance_rating >= 3.0:
    performance_bonus = base_salary * 0.05
else:
    performance_bonus = 0

# Loyalty bonus based on years of service
loyalty_factor = years_service // 5
loyalty_bonus = loyalty_factor * 1000

# Total bonus
total_bonus = performance_bonus + loyalty_bonus

# Convert to string for reporting
performance_bonus = f"${performance_bonus:.2f}"
loyalty_bonus = f"${loyalty_bonus:.2f}"
total_bonus = f"${total_bonus}"  # Oops, this will cause an error!

# Unused variable
report_date = "2025-05-13"

print(f"Employee {employee_id} receives a total bonus of {total_bonus}")
"""

profile_result = profile_script(sample_script)

print("Script Profile:")
print(f"Total variables: {profile_result['statistics']['total_variables']}")
print(f"Reassigned variables: {profile_result['statistics']['reassigned_variables']}")
print(f"Variables changing type: {profile_result['statistics']['type_changed_variables']}")
print(f"Unused variables: {profile_result['statistics']['unused_variables']}")
print(f"None assignments: {profile_result['statistics']['none_assignments']}")

print("\nPotential issues:")
for issue in profile_result['potential_issues']:
    print(f"- {issue}")

print("\nVariable assignment details:")
for var_name, details in profile_result['variables'].items():
    print(f"- {var_name}: {len(details['assignments'])} assignments")
    if details.get('potential_type_changes', False):
        print(f"  Warning: Type appears to change")
        print(f"  Assignments: {details['assignments']}")

Common Pitfalls and How to Avoid Them

Even experienced Python developers can stumble over these common variable-related issues:

Mutable Default Arguments

One of Python's most notorious gotchas involves mutable default arguments:

# Problematic function with mutable default argument
def add_to_list(item, my_list=[]):
    my_list.append(item)
    return my_list

print(add_to_list("a"))  # ['a']
print(add_to_list("b"))  # ['a', 'b'] - Surprise! The list persisted

# Correct pattern
def add_to_list_correctly(item, my_list=None):
    if my_list is None:
        my_list = []
    my_list.append(item)
    return my_list

print(add_to_list_correctly("a"))  # ['a']
print(add_to_list_correctly("b"))  # ['b'] - Each call gets a fresh list
graph TD A[Function Definition] -->|Default arg created ONCE| B[my_list] C[First Call] -->|Uses and modifies| B D[Second Call] -->|Uses same modified| B E[Correct Version] -->|New list each time| F[New List]

Variable Scope Issues

Python's scoping rules can sometimes cause confusion:

# Global vs. local scope
counter = 0

def increment():
    counter = counter + 1  # This will fail
    return counter

try:
    print(increment())
except UnboundLocalError as e:
    print(f"Error: {e}")  # Error: local variable 'counter' referenced before assignment

# Correct version using 'global'
counter = 0

def increment_correctly():
    global counter
    counter = counter + 1
    return counter

print(increment_correctly())  # 1
print(counter)  # 1 (global variable was modified)

Shallow vs. Deep Copies

When working with mutable objects, understanding the difference between shallow and deep copies is crucial:

import copy

# Original list with nested structure
original = [1, [2, 3], 4]

# Simple assignment (reference)
reference = original
reference[0] = 99
print(original)  # [99, [2, 3], 4] - Original also changed

# Reset
original = [1, [2, 3], 4]

# Shallow copy
shallow = original.copy()  # or list(original) or original[:]
shallow[0] = 99            # Modifying a top-level element
print(original)            # [1, [2, 3], 4] - Original unchanged
shallow[1][0] = 99         # Modifying a nested element
print(original)            # [1, [99, 3], 4] - Original IS changed

# Reset
original = [1, [2, 3], 4]

# Deep copy
deep = copy.deepcopy(original)
deep[1][0] = 99
print(original)  # [1, [2, 3], 4] - Original unchanged
graph TD subgraph "Original" O1[1] --- O2["[2, 3]"] --- O3[4] end subgraph "Reference (Assignment)" R1[99] --- R2["[2, 3]"] --- R3[4] end subgraph "Shallow Copy" S1[99] --- S2["[99, 3]"] --- S3[4] end subgraph "Deep Copy" D1[1] --- D2["[99, 3]"] --- D3[4] end O2 --- S2 O2 -.-> D2

Advanced Topics

For those ready to dive deeper, here are some advanced concepts related to Python variables:

Python's Memory Management

Understanding how Python manages memory can help you write more efficient code:

# Check the memory address of a variable
a = 42
print(f"Memory address of a: {id(a)}")

# Small integers share the same object (implementation dependent)
b = 42
print(f"Memory address of b: {id(b)}")
print(f"Are a and b the same object? {a is b}")  # Often True for small integers

# But larger or calculated numbers might not
large_a = 1000000
large_b = 1000000
print(f"Are large_a and large_b the same object? {large_a is large_b}")  # Often False

# String interning is also implementation-dependent
str_a = "hello"
str_b = "hello"
print(f"Are str_a and str_b the same object? {str_a is str_b}")  # Often True for simple strings

# But larger or dynamic strings might not be interned
str_c = "hello" * 1000
str_d = "hello" * 1000
print(f"Are str_c and str_d the same object? {str_c is str_d}")  # Often False

Variable Annotations and Type Hints

Python 3.5+ introduced optional type hints, which can improve code clarity and enable static type checking:

from typing import List, Dict, Optional, Union, Callable

# Function with type hints
def process_user_data(
    user_id: int,
    name: str,
    age: Optional[int] = None,
    roles: List[str] = None,
    metadata: Dict[str, Union[str, int, bool]] = None,
    callback: Callable[[bool], None] = None
) -> Dict[str, Union[int, str, list]]:
    """Process user data with type-annotated parameters."""
    result = {
        'id': user_id,
        'name': name,
        'processed': True
    }
    
    if age is not None:
        result['age'] = age
    
    if roles:
        result['roles'] = roles
    
    if metadata:
        result['metadata'] = {k: v for k, v in metadata.items() if v is not None}
    
    if callback:
        callback(True)
    
    return result

# Usage
result = process_user_data(
    user_id=1001,
    name="Alex Johnson",
    age=34,
    roles=["admin", "editor"],
    metadata={"last_login": "2025-05-10", "posts_count": 27, "verified": True}
)

print(result)

Type hints don't change how Python runs your code—they're just annotations. Tools like mypy can perform static type checking to catch potential errors before runtime.

Context Variables (Python 3.7+)

For advanced use cases, Python 3.7 introduced context variables for managing state within a context:

import contextvars
import asyncio

# Define a context variable
request_id = contextvars.ContextVar('request_id', default=None)

async def process_request(req_id):
    # Set the request ID for this context
    token = request_id.set(req_id)
    
    try:
        # The request ID is now available to any function in this context
        await handle_request()
    finally:
        # Reset the request ID
        request_id.reset(token)

async def handle_request():
    # Get the current request ID
    current_id = request_id.get()
    print(f"Handling request: {current_id}")
    
    # Simulate some work
    await asyncio.sleep(0.1)
    
    # Log with the request ID
    await log_action(f"Processed request {current_id}")

async def log_action(message):
    # Access the current request ID even though it wasn't passed as an argument
    current_id = request_id.get()
    print(f"[Request {current_id}] {message}")

async def main():
    # Process multiple requests concurrently
    await asyncio.gather(
        process_request('REQ-001'),
        process_request('REQ-002'),
        process_request('REQ-003')
    )

# Run the example
asyncio.run(main())

Context variables are particularly useful in asynchronous code where thread-local storage won't work correctly.

What You've Learned

Congratulations! You've covered a comprehensive exploration of Python variables and their capabilities:

These foundational skills provide you with the tools to write more elegant, flexible, and robust Python code. As you continue your Python journey, you'll find that mastering variables and expressions is key to unlocking the full power of the language.

Further Exploration

Want to deepen your understanding even further? Consider exploring these related topics:

Happy coding, and remember: in Python, your variables are only as flexible as your imagination!