I recently discovered something that blew my mind: Python lets you examine itself while it's running. You can ask a function "what are your parameters?" or look at the call stack to see who called you. The inspect module is like giving your code a mirror.

What Is Introspection?

Introspection means a program examining its own structure at runtime. Python is particularly good at this because everything is an object—functions, classes, modules, even code blocks. The inspect module gives you tools to examine all of them.

I started exploring this after needing to build a decorator that logged function arguments. The standard approach felt clunky until I discovered inspect.signature().

Getting Function Signatures

This is probably what I use most often. You can extract the complete signature of any function:

import inspect
 
def process_order(
    order_id: int,
    items: list[str],
    discount: float = 0.0,
    *,
    expedited: bool = False,
    notes: str | None = None
) -> dict:
    """Process an order and return confirmation."""
    return {"order_id": order_id, "status": "processed"}
 
# Get the signature object
sig = inspect.signature(process_order)
print(sig)
# (order_id: int, items: list[str], discount: float = 0.0, *, expedited: bool = False, notes: str | None = None) -> dict
 
# Examine each parameter
for name, param in sig.parameters.items():
    print(f"{name}:")
    print(f"  kind: {param.kind.name}")
    print(f"  default: {param.default}")
    print(f"  annotation: {param.annotation}")

The kind attribute tells you what type of parameter it is:

import inspect
 
def example(pos_only, /, pos_or_kw, *args, kw_only, **kwargs):
    pass
 
sig = inspect.signature(example)
for name, param in sig.parameters.items():
    print(f"{name}: {param.kind.name}")
# pos_only: POSITIONAL_ONLY
# pos_or_kw: POSITIONAL_OR_KEYWORD
# args: VAR_POSITIONAL
# kw_only: KEYWORD_ONLY
# kwargs: VAR_KEYWORD

Python 3.8 introduced positional-only parameters (the / separator), and inspect understands them perfectly.

Binding Arguments

What really impressed me was discovering sig.bind(). It lets you see exactly how arguments would map to parameters:

import inspect
 
def send_email(to: str, subject: str, body: str, cc: str | None = None, bcc: str | None = None):
    pass
 
sig = inspect.signature(send_email)
 
# Simulate calling with specific arguments
bound = sig.bind("alice@example.com", "Hello", body="Test message")
print(bound.arguments)
# {'to': 'alice@example.com', 'subject': 'Hello', 'body': 'Test message'}
 
# Fill in defaults
bound.apply_defaults()
print(bound.arguments)
# {'to': 'alice@example.com', 'subject': 'Hello', 'body': 'Test message', 'cc': None, 'bcc': None}

This is incredibly useful for decorators and validation. You can check if arguments are valid before the function even runs.

Examining Stack Frames

This is where things get really powerful. You can inspect the call stack—who called the current function, and who called them:

import inspect
 
def innermost():
    """Examine the call stack from here."""
    print("Current stack:")
    for frame_info in inspect.stack():
        print(f"  {frame_info.function} at {frame_info.filename}:{frame_info.lineno}")
 
def middle():
    innermost()
 
def outer():
    middle()
 
outer()
# Current stack:
#   innermost at example.py:4
#   middle at example.py:10
#   outer at example.py:13
#   <module> at example.py:15

Getting Caller Information

I use this pattern a lot for logging and debugging:

import inspect
 
def get_caller_info():
    """Get information about who called this function."""
    # Go back two frames: one for this function, one for our caller
    caller_frame = inspect.currentframe().f_back.f_back
    info = inspect.getframeinfo(caller_frame)
    
    return {
        'function': info.function,
        'filename': info.filename,
        'lineno': info.lineno,
        'code_context': info.code_context[0].strip() if info.code_context else None
    }
 
def log_with_caller(message):
    """Log a message with caller information."""
    caller = get_caller_info()
    print(f"[{caller['function']}:{caller['lineno']}] {message}")
 
def process_data():
    log_with_caller("Starting data processing")
    # ... do work ...
    log_with_caller("Finished processing")
 
process_data()
# [process_data:21] Starting data processing
# [process_data:23] Finished processing

Accessing Local Variables

Frame objects give you access to local variables at each level of the stack:

import inspect
 
def debug_locals():
    """Show local variables of the caller."""
    caller_frame = inspect.currentframe().f_back
    return caller_frame.f_locals.copy()
 
def example_function():
    name = "Alice"
    age = 30
    items = ["apple", "banana"]
    
    # See what variables exist here
    print(debug_locals())
    # {'name': 'Alice', 'age': 30, 'items': ['apple', 'banana']}
 
example_function()

Warning: Be careful with frame references. They can create reference cycles that prevent garbage collection. Always clean up or use inspect.stack() which handles this for you.

Source Code Inspection

You can actually retrieve the source code of functions and classes at runtime:

import inspect
 
def calculate_tax(amount: float, rate: float = 0.08) -> float:
    """Calculate tax on an amount.
    
    Args:
        amount: The base amount
        rate: Tax rate (default 8%)
    
    Returns:
        The tax amount
    """
    return amount * rate
 
# Get the source code
source = inspect.getsource(calculate_tax)
print(source)
# def calculate_tax(amount: float, rate: float = 0.08) -> float:
#     """Calculate tax on an amount.
#     ...
 
# Get source lines with starting line number
lines, start_line = inspect.getsourcelines(calculate_tax)
print(f"Function starts at line {start_line}")
for i, line in enumerate(lines):
    print(f"{start_line + i}: {line}", end="")
 
# Get the file where it's defined
print(inspect.getfile(calculate_tax))
 
# Get clean docstring
print(inspect.getdoc(calculate_tax))

This is how documentation generators and IDEs get their information. When you hover over a function in VSCode, it's using introspection behind the scenes.

The Limitation

Source inspection only works for functions defined in actual .py files. Built-in functions and C extensions don't have accessible source:

import inspect
 
try:
    print(inspect.getsource(len))
except OSError as e:
    print(f"Can't get source: {e}")
# Can't get source: could not find class definition

Getting Class Members

You can inspect classes to see all their attributes, methods, and properties:

import inspect
 
class DataProcessor:
    """Process data with various transformations."""
    
    version = "1.0.0"
    
    def __init__(self, config: dict):
        self.config = config
        self._cache = {}
    
    def process(self, data: list) -> list:
        """Process the data."""
        return [self._transform(item) for item in data]
    
    def _transform(self, item):
        """Internal transformation."""
        return item
    
    @classmethod
    def from_file(cls, path: str) -> "DataProcessor":
        """Create processor from config file."""
        return cls({})
    
    @staticmethod
    def validate(data: list) -> bool:
        """Validate data format."""
        return isinstance(data, list)
    
    @property
    def cache_size(self) -> int:
        """Return current cache size."""
        return len(self._cache)
 
# Get all members
print("All members:")
for name, value in inspect.getmembers(DataProcessor):
    if not name.startswith('_'):
        print(f"  {name}: {type(value).__name__}")
 
# Get only methods (functions defined in the class)
print("\nMethods:")
for name, method in inspect.getmembers(DataProcessor, inspect.isfunction):
    if not name.startswith('_'):
        print(f"  {name}")
 
# Get class methods
print("\nClass methods:")
for name, method in inspect.getmembers(DataProcessor, inspect.ismethod):
    print(f"  {name}")

Type Checking Predicates

inspect provides predicates to check what kind of object something is:

import inspect
import asyncio
 
def sync_func():
    pass
 
async def async_func():
    pass
 
class MyClass:
    def method(self):
        pass
 
obj = MyClass()
 
print(f"sync_func is function: {inspect.isfunction(sync_func)}")          # True
print(f"async_func is coroutine function: {inspect.iscoroutinefunction(async_func)}")  # True
print(f"MyClass is class: {inspect.isclass(MyClass)}")                    # True
print(f"obj.method is method: {inspect.ismethod(obj.method)}")            # True
print(f"inspect is module: {inspect.ismodule(inspect)}")                  # True
 
# Generator detection
gen = (x for x in range(10))
print(f"gen is generator: {inspect.isgenerator(gen)}")                    # True

Practical Uses

Here's where all of this comes together. These are real patterns I use in my projects.

Automatic Logging Decorator

import inspect
from functools import wraps
 
def log_calls(func):
    """Decorator that logs function calls with arguments."""
    sig = inspect.signature(func)
    
    @wraps(func)
    def wrapper(*args, **kwargs):
        # Bind arguments to parameters
        bound = sig.bind(*args, **kwargs)
        bound.apply_defaults()
        
        # Format arguments nicely
        arg_strs = [f"{k}={v!r}" for k, v in bound.arguments.items()]
        print(f"→ {func.__name__}({', '.join(arg_strs)})")
        
        result = func(*args, **kwargs)
        
        print(f"← {func.__name__} returned {result!r}")
        return result
    
    return wrapper
 
@log_calls
def calculate_total(items: list[float], tax_rate: float = 0.08) -> float:
    subtotal = sum(items)
    return subtotal * (1 + tax_rate)
 
calculate_total([10.0, 20.0, 30.0])
# → calculate_total(items=[10.0, 20.0, 30.0], tax_rate=0.08)
# ← calculate_total returned 64.8

Argument Validation

import inspect
from typing import get_type_hints
 
def validate_types(func):
    """Decorator that validates argument types at runtime."""
    sig = inspect.signature(func)
    hints = get_type_hints(func)
    
    @wraps(func)
    def wrapper(*args, **kwargs):
        bound = sig.bind(*args, **kwargs)
        
        for param_name, value in bound.arguments.items():
            if param_name in hints:
                expected_type = hints[param_name]
                # Simple type check (doesn't handle generics)
                if hasattr(expected_type, '__origin__'):
                    continue  # Skip complex types like list[str]
                if not isinstance(value, expected_type):
                    raise TypeError(
                        f"Parameter '{param_name}' expected {expected_type.__name__}, "
                        f"got {type(value).__name__}"
                    )
        
        return func(*args, **kwargs)
    
    return wrapper
 
@validate_types
def greet(name: str, times: int = 1) -> str:
    return (f"Hello, {name}! " * times).strip()
 
print(greet("Alice", 2))  # Works fine
print(greet("Bob", "three"))  # Raises TypeError

Documentation Generator

import inspect
 
def generate_docs(cls) -> str:
    """Generate markdown documentation for a class."""
    lines = [f"# {cls.__name__}"]
    
    if cls.__doc__:
        lines.append(f"\n{inspect.cleandoc(cls.__doc__)}\n")
    
    # Document public methods
    lines.append("\n## Methods\n")
    
    for name, method in inspect.getmembers(cls, inspect.isfunction):
        if name.startswith('_'):
            continue
        
        sig = inspect.signature(method)
        lines.append(f"### `{name}{sig}`\n")
        
        if method.__doc__:
            lines.append(f"{inspect.cleandoc(method.__doc__)}\n")
    
    return '\n'.join(lines)
 
class Calculator:
    """A simple calculator class."""
    
    def add(self, a: float, b: float) -> float:
        """Add two numbers."""
        return a + b
    
    def multiply(self, a: float, b: float) -> float:
        """Multiply two numbers."""
        return a * b
 
print(generate_docs(Calculator))

Dependency Injection

This pattern is common in web frameworks:

import inspect
from typing import Type, TypeVar, get_type_hints
 
T = TypeVar('T')
 
class Container:
    """Simple dependency injection container."""
    
    def __init__(self):
        self._services: dict[type, type] = {}
        self._instances: dict[type, object] = {}
    
    def register(self, cls: type) -> None:
        """Register a class as a service."""
        self._services[cls] = cls
    
    def resolve(self, cls: Type[T]) -> T:
        """Resolve a service, creating it if needed."""
        if cls in self._instances:
            return self._instances[cls]
        
        if cls not in self._services:
            raise KeyError(f"Service {cls.__name__} not registered")
        
        # Get constructor signature and type hints
        sig = inspect.signature(cls.__init__)
        hints = get_type_hints(cls.__init__)
        
        # Resolve dependencies
        kwargs = {}
        for name, param in sig.parameters.items():
            if name == 'self':
                continue
            if name in hints and hints[name] in self._services:
                kwargs[name] = self.resolve(hints[name])
        
        instance = cls(**kwargs)
        self._instances[cls] = instance
        return instance
 
# Example usage
class Database:
    def query(self, sql: str) -> list:
        return []
 
class UserRepository:
    def __init__(self, db: Database):
        self.db = db
    
    def find_user(self, user_id: int):
        return self.db.query(f"SELECT * FROM users WHERE id = {user_id}")
 
class UserService:
    def __init__(self, repo: UserRepository):
        self.repo = repo
    
    def get_user(self, user_id: int):
        return self.repo.find_user(user_id)
 
container = Container()
container.register(Database)
container.register(UserRepository)
container.register(UserService)
 
# Automatically resolves the dependency chain
service = container.resolve(UserService)
print(f"Service has repo: {type(service.repo).__name__}")
print(f"Repo has db: {type(service.repo.db).__name__}")

Debug Context Manager

import inspect
from contextlib import contextmanager
 
@contextmanager
def debug_context(label: str = ""):
    """Context manager that shows entry/exit points."""
    caller_frame = inspect.currentframe().f_back.f_back
    info = inspect.getframeinfo(caller_frame)
    location = f"{info.function}:{info.lineno}"
    
    print(f"→ Entering {label or 'block'} at {location}")
    try:
        yield
    finally:
        print(f"← Exiting {label or 'block'}")
 
def process_data():
    with debug_context("data loading"):
        data = [1, 2, 3]
    
    with debug_context("transformation"):
        data = [x * 2 for x in data]
    
    return data
 
process_data()
# → Entering data loading at process_data:3
# ← Exiting data loading
# → Entering transformation at process_data:6
# ← Exiting transformation

Key Takeaways

After spending time with inspect, here's what I've learned:

  1. Signatures are powerfulinspect.signature() is the foundation for argument validation, documentation, and smart decorators.

  2. Stack frames reveal context — You can see exactly where code is executing and access local variables at any level.

  3. Source inspection enables tooling — Documentation generators, linters, and IDEs all rely on this.

  4. Use predicates for type checkingisfunction, isclass, ismethod are cleaner than isinstance for callable types.

  5. Be careful with frames — Frame objects can create reference cycles. Use them carefully or rely on inspect.stack().

The inspect module turned me from someone who wrote code to someone who could write code that understands code. It's essential knowledge for building decorators, frameworks, and debugging tools.

React to this post: