Featured image

Table of Contents Link to heading

Variable Scope in Python Classes Link to heading

Python classes have two variable scopes that are frequently confused because both can be accessed using the same dot notation on an instance:

  • Class variables — defined at class level; one copy shared by the class and all its instances
  • Instance variables — defined in methods (typically __init__) using self; each instance has its own independent copy

Understanding which type a variable is determines whether changing it on one instance affects other instances.

Class Variables Link to heading

Class variables are defined inside the class body but outside any method. They are associated with the class object itself, not with any instance.

class NetworkDevice:
    vendor = "Cisco"           # class variable
    total_devices = 0          # class variable used as a counter

    def __init__(self, hostname):
        self.hostname = hostname   # instance variable
        NetworkDevice.total_devices += 1

Shared State Across All Instances Link to heading

All instances access the same class variable — changes through the class object propagate to all instances (unless an instance has shadowed the variable):

d1 = NetworkDevice("router-01")
d2 = NetworkDevice("router-02")

print(NetworkDevice.vendor)    # Cisco
print(d1.vendor)               # Cisco (found on class, no instance shadow)
print(d2.vendor)               # Cisco

NetworkDevice.vendor = "Juniper"

print(d1.vendor)               # Juniper (all instances see the change)
print(d2.vendor)               # Juniper
print(NetworkDevice.total_devices)  # 2 (counter incremented by both __init__ calls)

Assigning to an instance variable with the same name shadows the class variable for that instance only:

d1.vendor = "Arista"           # creates an instance variable, shadows class variable

print(d1.vendor)               # Arista (instance variable takes precedence)
print(d2.vendor)               # Juniper (still sees class variable)
print(NetworkDevice.vendor)    # Juniper (class variable unchanged)

del d1.vendor                  # remove the instance variable
print(d1.vendor)               # Juniper (class variable visible again)

The Mutable Class Variable Pitfall Link to heading

The most common OOP mistake in Python: using a mutable object (list, dict) as a class variable when per-instance behaviour was intended.

class Router:
    interfaces = []    # WRONG: shared list — all instances share the same list

r1 = Router()
r2 = Router()

r1.interfaces.append("Gi0/0")
print(r2.interfaces)    # ['Gi0/0'] — r2 is affected!

The fix: initialise mutable attributes in __init__:

class Router:
    def __init__(self):
        self.interfaces = []    # CORRECT: each instance gets its own list

r1 = Router()
r2 = Router()

r1.interfaces.append("Gi0/0")
print(r2.interfaces)    # [] — r2 is unaffected
Warning
Use class variables only for data that should genuinely be shared across all instances — counters, constants, configuration defaults. Never use class variables for mutable state that should vary per instance (lists, dicts, sets). When in doubt, define it in __init__.

Instance Variables Link to heading

Instance variables are defined by assigning to self.name inside a method. Each instance maintains its own independent namespace for these variables.

class Switch:
    model = "Catalyst 9300"    # class variable

    def __init__(self, hostname, ip_address):
        self.hostname = hostname       # instance variable
        self.ip_address = ip_address   # instance variable
        self.vlans = []                # instance variable — mutable, per instance
        self.uptime_seconds = 0        # instance variable

    def add_vlan(self, vlan_id, name):
        self.vlans.append({"id": vlan_id, "name": name})
        return self

s1 = Switch("access-01", "10.0.1.1")
s2 = Switch("access-02", "10.0.1.2")

s1.add_vlan(10, "DATA").add_vlan(20, "VOICE")
s1.uptime_seconds = 3600

print(s1.vlans)         # [{'id': 10, 'name': 'DATA'}, {'id': 20, 'name': 'VOICE'}]
print(s2.vlans)         # [] — independent
print(s1.uptime_seconds)  # 3600
print(s2.uptime_seconds)  # 0 — independent

Attribute Lookup Order Link to heading

When you access instance.attribute, Python follows this lookup chain:

  1. The instance’s own __dict__ (instance variables)
  2. The class’s __dict__ (class variables)
  3. Base classes in Method Resolution Order (MRO) — relevant for inheritance
print(d1.__dict__)          # instance's own attributes
print(NetworkDevice.__dict__)    # class attributes

This lookup order is why an instance can shadow a class variable: when found in step 1, the search stops before reaching step 2.

Class Methods: @classmethod Link to heading

A class method receives the class as its first argument (cls) rather than an instance. It can access and modify class-level state.

Common use: alternative constructors that create instances from different input formats.

class NetworkDevice:
    _registry = {}     # class-level device registry

    def __init__(self, hostname, ip_address, device_type):
        self.hostname = hostname
        self.ip_address = ip_address
        self.device_type = device_type
        NetworkDevice._registry[hostname] = self

    @classmethod
    def from_dict(cls, config_dict):
        """Alternative constructor: create from a config dictionary."""
        return cls(
            hostname=config_dict["hostname"],
            ip_address=config_dict["ip"],
            device_type=config_dict["type"]
        )

    @classmethod
    def get_device(cls, hostname):
        """Look up a device from the class registry."""
        return cls._registry.get(hostname)

    @classmethod
    def device_count(cls):
        return len(cls._registry)


# Standard constructor
d1 = NetworkDevice("router-01", "10.0.0.1", "router")

# Alternative constructor via classmethod
config = {"hostname": "switch-01", "ip": "10.0.0.2", "type": "switch"}
d2 = NetworkDevice.from_dict(config)

print(NetworkDevice.device_count())         # 2
print(NetworkDevice.get_device("router-01").ip_address)  # 10.0.0.1

Static Methods: @staticmethod Link to heading

A static method receives neither self nor cls. It behaves like a regular function that happens to live inside a class namespace — used when a method is logically related to the class but does not need to access instance or class state.

class IPUtils:
    @staticmethod
    def validate_ipv4(address):
        """Return True if address is a valid IPv4 address."""
        parts = address.split(".")
        if len(parts) != 4:
            return False
        return all(p.isdigit() and 0 <= int(p) <= 255 for p in parts)

    @staticmethod
    def to_binary(octet):
        """Convert a decimal octet to 8-bit binary string."""
        return format(int(octet), "08b")


# Called on the class (no instance needed)
print(IPUtils.validate_ipv4("192.168.1.1"))   # True
print(IPUtils.validate_ipv4("300.0.0.1"))     # False
print(IPUtils.to_binary("192"))               # 11000000

# Can also be called on an instance
utils = IPUtils()
print(utils.validate_ipv4("10.0.0.1"))        # True

When to Use Each Link to heading

Variable / Method Type Use When
Class variable Data shared across all instances — counters, constants, registries, default configuration
Instance variable Data unique to each instance — hostname, IP, state, mutable collections
Instance method (self) Logic that reads or modifies instance state
Class method (@classmethod, cls) Alternative constructors; factory methods; class-level operations
Static method (@staticmethod) Utility functions logically related to the class but requiring no instance or class state
Tip
A practical heuristic: if a method body never references self or cls, it can be a @staticmethod. If it references the class but no specific instance, it can be a @classmethod. If it references instance state, it must be an instance method.