Python - vars() and dir() Functions

Python's introspection capabilities are among its most powerful features for debugging, metaprogramming, and building dynamic systems. Two functions sit at the heart of object inspection: `vars()`...

Key Insights

  • vars() returns an object’s __dict__ attribute (instance variables only), while dir() returns a comprehensive list of all accessible names including inherited methods and attributes
  • Use vars() when you need to read or modify instance attributes dynamically; use dir() when exploring an unfamiliar object’s capabilities
  • Both functions fail gracefully in different ways: vars() raises TypeError on objects without __dict__, while dir() always returns something useful

Introduction

Python’s introspection capabilities are among its most powerful features for debugging, metaprogramming, and building dynamic systems. Two functions sit at the heart of object inspection: vars() and dir(). Despite their similar-sounding purposes, they serve fundamentally different roles.

Understanding when to reach for each function separates developers who fumble through debugging sessions from those who surgically identify issues. These functions aren’t just debugging tools—they’re essential for building serializers, ORMs, testing frameworks, and any code that needs to work with objects generically.

Let’s cut through the confusion and establish exactly what each function does, when to use it, and the gotchas that trip up even experienced Python developers.

Understanding vars()

The vars() function returns the __dict__ attribute of an object. This dictionary contains the object’s writable instance attributes—the data that makes each instance unique.

When called without arguments, vars() returns the local symbol table, equivalent to locals().

class User:
    def __init__(self, name, email, age):
        self.name = name
        self.email = email
        self.age = age
    
    def greet(self):
        return f"Hello, {self.name}"

user = User("Alice", "alice@example.com", 30)

# Using vars() on an instance
print(vars(user))
# Output: {'name': 'Alice', 'email': 'alice@example.com', 'age': 30}

# This is identical to accessing __dict__ directly
print(user.__dict__)
# Output: {'name': 'Alice', 'email': 'alice@example.com', 'age': 30}

# Verify they're the same object
print(vars(user) is user.__dict__)
# Output: True

The critical insight here: vars() returns the actual dictionary, not a copy. Modifications to this dictionary directly affect the object:

# Dynamically add an attribute
vars(user)['role'] = 'admin'
print(user.role)
# Output: admin

# Modify existing attribute
vars(user)['age'] = 31
print(user.age)
# Output: 31

When called without arguments inside a function, vars() returns the local namespace:

def calculate_total(price, quantity, tax_rate=0.08):
    subtotal = price * quantity
    tax = subtotal * tax_rate
    total = subtotal + tax
    
    print(vars())
    return total

calculate_total(29.99, 3)
# Output: {'price': 29.99, 'quantity': 3, 'tax_rate': 0.08, 
#          'subtotal': 89.97, 'tax': 7.1976, 'total': 97.1676}

Understanding dir()

The dir() function takes a broader approach. It returns a sorted list of names—strings representing attributes, methods, and other accessible names. Unlike vars(), it includes inherited attributes and special methods.

class Animal:
    species = "Unknown"
    
    def breathe(self):
        return "Breathing..."

class Dog(Animal):
    def __init__(self, name):
        self.name = name
    
    def bark(self):
        return "Woof!"

dog = Dog("Rex")

print(dir(dog))
# Output includes: [..., 'bark', 'breathe', 'name', 'species', ...]
# Plus all inherited object methods like __class__, __init__, etc.

When called without arguments, dir() returns names in the current local scope:

import json

def example_function():
    x = 10
    y = "hello"
    print(dir())

example_function()
# Output: ['x', 'y']

dir() is particularly useful for exploring modules and built-in types:

# Exploring a module
import datetime
print([name for name in dir(datetime) if not name.startswith('_')])
# Output: ['MAXYEAR', 'MINYEAR', 'date', 'datetime', 'time', 'timedelta', 'timezone', 'tzinfo']

# Exploring string methods
print([m for m in dir(str) if not m.startswith('_')])
# Output: ['capitalize', 'casefold', 'center', 'count', 'encode', ...]

Key Differences Between vars() and dir()

The differences become stark when you examine the same object with both functions:

class Vehicle:
    wheels = 4  # Class attribute
    
    def __init__(self, brand, model):
        self.brand = brand  # Instance attribute
        self.model = model  # Instance attribute
    
    def start(self):
        return f"{self.brand} {self.model} starting..."

car = Vehicle("Toyota", "Camry")

print("vars() output:")
print(vars(car))
# Output: {'brand': 'Toyota', 'model': 'Camry'}

print("\ndir() output (filtered):")
print([attr for attr in dir(car) if not attr.startswith('_')])
# Output: ['brand', 'model', 'start', 'wheels']

Notice the key differences:

Aspect vars() dir()
Return type dict list of strings
Class attributes Excluded Included
Methods Excluded Included
Inherited members Excluded Included
Modifiable Yes (returns actual __dict__) No (returns new list)

The TypeError behavior also differs significantly:

# vars() fails on objects without __dict__
try:
    print(vars(42))
except TypeError as e:
    print(f"vars() error: {e}")
# Output: vars() error: vars() argument must have __dict__ attribute

# dir() always works
print(dir(42)[:5])
# Output: ['__abs__', '__add__', '__and__', '__bool__', '__ceil__']

Practical Use Cases

Building a Simple Object Serializer

vars() shines when converting objects to dictionaries for JSON serialization:

import json
from datetime import datetime

class Order:
    def __init__(self, order_id, items, total, created_at=None):
        self.order_id = order_id
        self.items = items
        self.total = total
        self.created_at = created_at or datetime.now()

def serialize_object(obj):
    """Convert an object to a JSON-serializable dictionary."""
    data = {}
    for key, value in vars(obj).items():
        if isinstance(value, datetime):
            data[key] = value.isoformat()
        elif hasattr(value, '__dict__'):
            data[key] = serialize_object(value)
        else:
            data[key] = value
    return data

order = Order("ORD-001", ["Widget", "Gadget"], 59.99)
print(json.dumps(serialize_object(order), indent=2))

Attribute Discovery and Documentation

dir() excels at exploring unfamiliar objects:

def discover_object(obj, show_private=False):
    """Discover and categorize an object's attributes."""
    attributes = []
    methods = []
    
    for name in dir(obj):
        if not show_private and name.startswith('_'):
            continue
        
        attr = getattr(obj, name)
        if callable(attr):
            methods.append(name)
        else:
            attributes.append(name)
    
    return {
        'type': type(obj).__name__,
        'attributes': attributes,
        'methods': methods
    }

import collections
result = discover_object(collections.Counter([1, 2, 2, 3, 3, 3]))
print(f"Type: {result['type']}")
print(f"Attributes: {result['attributes']}")
print(f"Methods: {result['methods'][:10]}...")  # First 10 methods

Dynamic Configuration Loading

class Config:
    def __init__(self, **kwargs):
        for key, value in kwargs.items():
            setattr(self, key, value)
    
    def to_dict(self):
        return vars(self).copy()
    
    def update_from_dict(self, data):
        vars(self).update(data)

config = Config(debug=True, database_url="postgres://localhost/app")
config.update_from_dict({'cache_ttl': 3600, 'debug': False})
print(config.to_dict())
# Output: {'debug': False, 'database_url': 'postgres://localhost/app', 'cache_ttl': 3600}

Common Pitfalls and Edge Cases

Objects Using slots

Classes that define __slots__ don’t have a __dict__ attribute, causing vars() to fail:

class Point:
    __slots__ = ['x', 'y']
    
    def __init__(self, x, y):
        self.x = x
        self.y = y

point = Point(3, 4)

# dir() works fine
print([attr for attr in dir(point) if not attr.startswith('_')])
# Output: ['x', 'y']

# vars() raises TypeError
try:
    print(vars(point))
except TypeError as e:
    print(f"Error: {e}")
# Output: Error: vars() argument must have __dict__ attribute

# Workaround: manually build dict from slots
def slots_to_dict(obj):
    slots = getattr(type(obj), '__slots__', [])
    return {slot: getattr(obj, slot) for slot in slots}

print(slots_to_dict(point))
# Output: {'x': 3, 'y': 4}

Read-Only Mappings

Some objects return read-only mappings from vars():

import types

# Module __dict__ is a read-only mappingproxy
module_vars = vars(types)
print(type(module_vars))
# Output: <class 'mappingproxy'>

try:
    module_vars['custom'] = 'value'
except TypeError as e:
    print(f"Cannot modify: {e}")
# Output: Cannot modify: 'mappingproxy' object does not support item assignment

Conclusion

Use Case Function Reason
Serialize object to dict vars() Direct access to instance data
Explore unknown object dir() Shows all accessible names
Modify attributes dynamically vars() Returns mutable __dict__
Check if method exists dir() Includes methods and inherited members
Debug local variables vars() Returns local namespace
Build documentation tools dir() Comprehensive attribute listing

Use vars() when you need the raw instance data for serialization, cloning, or dynamic modification. Use dir() when you’re exploring, debugging, or need to know everything an object can do. Both functions are indispensable—knowing which to reach for makes you a more effective Python developer.

Liked this? There's more.

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