Python - isinstance() and issubclass()

Python's dynamic typing gives you flexibility, but that flexibility comes with responsibility. When you need to verify types at runtime—whether for input validation, polymorphic dispatch, or...

Key Insights

  • isinstance() checks if an object is an instance of a class (or classes), while issubclass() checks if a class inherits from another class—confusing these leads to TypeError exceptions
  • Both functions accept tuples for checking against multiple types, making them more flexible than manual type comparisons with type()
  • These functions integrate seamlessly with Abstract Base Classes, enabling duck typing checks that respect Python’s protocol-based design philosophy

Introduction

Python’s dynamic typing gives you flexibility, but that flexibility comes with responsibility. When you need to verify types at runtime—whether for input validation, polymorphic dispatch, or defensive programming—isinstance() and issubclass() are your primary tools.

These built-in functions do more than simple type comparisons. They understand inheritance hierarchies, work with abstract base classes, and integrate with Python’s duck typing philosophy. Yet I regularly see developers misuse them, confuse their purposes, or avoid them entirely in favor of brittle type() comparisons.

This article covers both functions thoroughly, shows you when each is appropriate, and demonstrates practical patterns you’ll use in production code.

Understanding isinstance()

The isinstance() function checks whether an object is an instance of a specified class or any class in a tuple of classes. The syntax is straightforward:

isinstance(object, classinfo)

Here’s basic usage with built-in types:

# Checking against single types
x = 42
print(isinstance(x, int))      # True
print(isinstance(x, str))      # False

name = "Alice"
print(isinstance(name, str))   # True

items = [1, 2, 3]
print(isinstance(items, list)) # True
print(isinstance(items, dict)) # False

The real power comes when checking against multiple types. Pass a tuple of classes as the second argument:

def process_numeric(value):
    # Accept int, float, or complex numbers
    if not isinstance(value, (int, float, complex)):
        raise TypeError(f"Expected numeric type, got {type(value).__name__}")
    return value * 2

print(process_numeric(10))      # 20
print(process_numeric(3.14))    # 6.28
print(process_numeric(2 + 3j))  # (4+6j)
# process_numeric("10")         # Raises TypeError

Crucially, isinstance() respects inheritance. A boolean is also an integer in Python:

flag = True
print(isinstance(flag, bool))  # True
print(isinstance(flag, int))   # True (bool inherits from int)
print(type(flag) == int)       # False (exact type comparison)

This inheritance awareness is why isinstance() is preferred over type() comparisons in most cases.

Understanding issubclass()

While isinstance() works with objects, issubclass() works with classes themselves. It checks whether a class is a subclass of another class or classes:

issubclass(class, classinfo)

Here’s a simple inheritance chain:

class Animal:
    pass

class Mammal(Animal):
    pass

class Dog(Mammal):
    pass

# Direct and indirect inheritance
print(issubclass(Dog, Mammal))    # True
print(issubclass(Dog, Animal))    # True
print(issubclass(Mammal, Animal)) # True

# A class is a subclass of itself
print(issubclass(Dog, Dog))       # True

# No inheritance relationship
print(issubclass(Animal, Dog))    # False

Like isinstance(), you can check against multiple parent classes:

class Serializable:
    pass

class Cacheable:
    pass

class UserModel(Serializable, Cacheable):
    pass

class ProductModel(Serializable):
    pass

# Check if classes support certain capabilities
print(issubclass(UserModel, (Serializable, Cacheable)))    # True
print(issubclass(ProductModel, (Serializable, Cacheable))) # True (only needs one)
print(issubclass(ProductModel, Cacheable))                 # False

Key Differences Between the Two

The distinction is simple but critical: isinstance() takes an object as its first argument, issubclass() takes a class. Mixing them up causes TypeError:

class Vehicle:
    pass

class Car(Vehicle):
    pass

my_car = Car()

# isinstance() works with OBJECTS
print(isinstance(my_car, Vehicle))  # True
print(isinstance(my_car, Car))      # True

# issubclass() works with CLASSES
print(issubclass(Car, Vehicle))     # True
print(issubclass(type(my_car), Vehicle))  # True

# Common mistakes
try:
    issubclass(my_car, Vehicle)  # TypeError: arg 1 must be a class
except TypeError as e:
    print(f"Error: {e}")

try:
    isinstance(Car, Vehicle)  # Returns False, but probably not what you meant
except TypeError:
    pass
print(isinstance(Car, Vehicle))  # False - Car is a class, not a Vehicle instance

The last example is subtle. isinstance(Car, Vehicle) doesn’t raise an error—it returns False because Car is a class object, and class objects are instances of type, not Vehicle.

Practical Use Cases

Input Validation and Polymorphic Handling

Here’s a function that gracefully handles different input types:

from typing import Union, List

def normalize_ids(ids: Union[int, str, List[int], List[str]]) -> List[int]:
    """Convert various ID formats to a list of integers."""
    
    if isinstance(ids, int):
        return [ids]
    
    if isinstance(ids, str):
        # Handle comma-separated strings like "1,2,3"
        return [int(x.strip()) for x in ids.split(',')]
    
    if isinstance(ids, list):
        if not ids:
            return []
        if isinstance(ids[0], str):
            return [int(x) for x in ids]
        if isinstance(ids[0], int):
            return list(ids)
    
    raise TypeError(f"Cannot normalize IDs from {type(ids).__name__}")

# All of these work
print(normalize_ids(42))           # [42]
print(normalize_ids("1, 2, 3"))    # [1, 2, 3]
print(normalize_ids([1, 2, 3]))    # [1, 2, 3]
print(normalize_ids(["4", "5"]))   # [4, 5]

Plugin System with issubclass()

issubclass() shines when building extensible systems:

from abc import ABC, abstractmethod

class BasePlugin(ABC):
    @abstractmethod
    def execute(self, data: dict) -> dict:
        pass
    
    @property
    @abstractmethod
    def name(self) -> str:
        pass

class ValidationPlugin(BasePlugin):
    name = "validator"
    
    def execute(self, data: dict) -> dict:
        # Validation logic
        return {"valid": True, **data}

class TransformPlugin(BasePlugin):
    name = "transformer"
    
    def execute(self, data: dict) -> dict:
        # Transform logic
        return {k.upper(): v for k, v in data.items()}

class PluginRegistry:
    def __init__(self):
        self._plugins = {}
    
    def register(self, plugin_class):
        """Register a plugin class after validating it."""
        if not issubclass(plugin_class, BasePlugin):
            raise TypeError(
                f"{plugin_class.__name__} must inherit from BasePlugin"
            )
        
        # Ensure it's not the abstract base itself
        if plugin_class is BasePlugin:
            raise ValueError("Cannot register abstract BasePlugin")
        
        instance = plugin_class()
        self._plugins[instance.name] = instance
        return plugin_class  # Allow use as decorator
    
    def get(self, name: str) -> BasePlugin:
        return self._plugins.get(name)

# Usage
registry = PluginRegistry()
registry.register(ValidationPlugin)
registry.register(TransformPlugin)

Abstract Base Classes Integration

Python’s collections.abc module provides abstract base classes that define interfaces. isinstance() checks against these ABCs enable proper duck typing:

from collections.abc import Iterable, Mapping, Callable, Sequence

def process_items(items):
    """Process any iterable, but handle mappings specially."""
    
    if isinstance(items, Mapping):
        # Dictionaries and dict-like objects
        return {k: v * 2 for k, v in items.items()}
    
    if isinstance(items, Sequence) and not isinstance(items, str):
        # Lists, tuples, but not strings
        return [x * 2 for x in items]
    
    if isinstance(items, Iterable):
        # Generators, sets, other iterables
        return list(x * 2 for x in items)
    
    raise TypeError("Expected an iterable")

# Works with various types
print(process_items({"a": 1, "b": 2}))  # {'a': 2, 'b': 4}
print(process_items([1, 2, 3]))          # [2, 4, 6]
print(process_items((1, 2, 3)))          # [2, 4, 6]
print(process_items({1, 2, 3}))          # [2, 4, 6] (from generator)

def validate_callback(func):
    """Ensure a callback is actually callable."""
    if not isinstance(func, Callable):
        raise TypeError(f"Expected callable, got {type(func).__name__}")
    return func

This approach respects duck typing—any object that registers with the ABC or implements the required methods will pass the check.

Best Practices and Pitfalls

When Type Checking Is Appropriate

Type checking makes sense for:

  • Public API boundaries where you need clear error messages
  • Polymorphic dispatch based on input types
  • Plugin systems and framework extension points
  • Serialization/deserialization logic

When to Avoid It

Python’s EAFP (Easier to Ask Forgiveness than Permission) philosophy often produces cleaner code:

# Overly rigid type checking
def bad_get_length(obj):
    if isinstance(obj, (str, list, tuple, dict, set, frozenset)):
        return len(obj)
    raise TypeError("Object has no length")

# Better: just try it
def good_get_length(obj):
    try:
        return len(obj)
    except TypeError:
        raise TypeError(f"{type(obj).__name__} has no length")

# Best: let the error propagate naturally if that's acceptable
def best_get_length(obj):
    return len(obj)  # Raises TypeError with clear message if no __len__

Common Mistakes to Avoid

# Mistake 1: Using type() for comparison
if type(x) == list:  # Breaks for subclasses
    pass
if isinstance(x, list):  # Correct
    pass

# Mistake 2: Checking for None with isinstance
if isinstance(x, type(None)):  # Works but weird
    pass
if x is None:  # Correct
    pass

# Mistake 3: Overly specific checks
def process(data):
    if isinstance(data, list):  # Too specific
        for item in data:
            yield item
            
def process_better(data):
    if isinstance(data, Iterable) and not isinstance(data, (str, bytes)):
        yield from data  # Works with any iterable

Refactoring Rigid Type Checks

Transform verbose type checking into cleaner patterns:

# Before: rigid and repetitive
def serialize_value(value):
    if isinstance(value, str):
        return f'"{value}"'
    elif isinstance(value, (int, float)):
        return str(value)
    elif isinstance(value, bool):
        return "true" if value else "false"
    elif isinstance(value, list):
        return "[" + ", ".join(serialize_value(v) for v in value) + "]"
    elif isinstance(value, dict):
        pairs = [f'"{k}": {serialize_value(v)}' for k, v in value.items()]
        return "{" + ", ".join(pairs) + "}"
    else:
        raise TypeError(f"Cannot serialize {type(value)}")

# After: dispatch table pattern
SERIALIZERS = {
    str: lambda v: f'"{v}"',
    int: str,
    float: str,
    bool: lambda v: "true" if v else "false",
}

def serialize_value_clean(value):
    for type_class, serializer in SERIALIZERS.items():
        if isinstance(value, type_class):
            return serializer(value)
    
    if isinstance(value, Mapping):
        pairs = [f'"{k}": {serialize_value_clean(v)}' for k, v in value.items()]
        return "{" + ", ".join(pairs) + "}"
    
    if isinstance(value, Iterable) and not isinstance(value, str):
        return "[" + ", ".join(serialize_value_clean(v) for v in value) + "]"
    
    raise TypeError(f"Cannot serialize {type(value)}")

isinstance() and issubclass() are fundamental tools for runtime type checking in Python. Use them at API boundaries, for polymorphic dispatch, and when building extensible systems. But remember that Python’s dynamic nature is a feature—don’t fight it with excessive type checking when duck typing would serve you better.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.