All The Ways To Introspect Python Objects at Runtime

Python provides a lot of ways to ask questions about your code. Whether it's basic things like help() function, builtin functions like dir() or more advanced methods in inspect module - the tools are there to help you find the answers to your questions.

Let's find out what kinds of questions about our own code can Python answer for us and how it can help us during debugging sessions, dealing with type annotations, validating inputs and much more.

The Builtins

As already mentioned - there are a couple categories of introspection tools/functions in Python - let's start with the most basic one, which is the builtin functions.

Python includes a basic set of builtin function, most of which we already know - such as len(), range() or print() - there are however a couple obscure ones that can help us answer some questions about our code:

if "some_var" in locals():
    ...  # some_var exists

if "some_var" in globals():
    ...  # some_var exists

if hasattr(instance, "some_class_attr"):
    ...  # instance.some_class_attr exists

The locals(), globals() and hasattr(), can help us find out whether local/global variable or class instance attribute exists.

Furthermore, we can use builtin functions to also check whether a variable is a function:

# True

# Prior to Python 3.2
print(hasattr(some_func, "__call__"))
# True

# Careful!
# False

There are a couple ways to do that - the best option is to use callable(), if you're however running Python 3.1, then you'd have to check for presence of __call__ attribute using hasattr function instead. Last option would be to use isfunction() from inspect module, be careful with that one though, as it will return False for builtin functions such as sum, len or range because these are implemented in C, so they're not Python functions.

Another thing you might want to check is whether a variable is list (sequence) or a scalar:


# Works with builtin types, but includes strings. Also doesn't work with NumPy arrays:
print(isinstance([1, 2, 3],  # True
print(isinstance(np.array([1, 2, 3]),  # False - wrong
print(isinstance("1234",  # True - wrong

# Works also for NumPy arrays, but will return True for dictionaries
print(hasattr([1, 2, 3], "__len__"))  # True
print(hasattr(np.array([1, 2, 3]), "__len__"))  # True
print(hasattr({"a": 1}, "__len__"))  # True - wrong (?)

print(isinstance([1, 2, 3], (, np.ndarray)))  # True
print(isinstance(np.array([1, 2, 3]), (, np.ndarray)))  # True
print(isinstance("1234", (, np.ndarray)))  # True - wrong

The "obvious" solution is to use isinstance to check whether variable is an instance of abstract class Sequence. That however won't work with non-builtin types such as NumPy arrays. It also considers strings a sequence which is correct, but might not be desirable. Alternative is to use hasattr to check whether the variable has __len__ attribute, this will work for NumPy arrays, but will also return True for dictionaries. Last option is to pass a tuple of types to isinstance to customize the behavior to your needs.

We've already seen how we can use globals() to check if variable exists, we can however use it also to call function by a string:

def some_func():
    print("Call me!")

func_name = "some_func"

# Call me!

Similar can be used for object (class) attributes with getattr(instance, "func_name")().

Last example with builtin functions uses locals() - let's say you have a function that takes a lot of arguments, all of which need to be passed to another function, and you don't want to write them all out. Well you can simply use locals() with the ** (destructuring) operator, like so:

def func(a, b, c, d, e):

def other_func(a, b, c, d, e):
    print(a, b, c, d, e)

func(1, 2, 3, 4, 5)
# Prints: 1 2 3 4 5

The above should give you a decent idea of what you can do with the builtin functions, it's however not an exhaustive list. There are couple more functions, such as dir() or vars(), so be sure to check out the docs to get a full picture.

Object Attributes

If the above builtins aren't giving you all the answers, then we can dig a little deeper. Every object in Python has a quite extensive set of attributes that can tell us more about a particular object.

These are essentially the values used to construct the output of help(object), so anything you find in output of help(object) can be pulled from object attribute. For example, you can find there object's doc string (.__doc__), function's source code (.__code__), or traceback/stack information (e.g. .tb_frame).

This can be useful - for example - if you need to know number of function argument (func.__code__.co_argcount), their names (func.__code__.co_varnames) or their default values (.__defaults__).

Messing with attributes is not ideal though, but there's a better way. Let's take a look at inspect module...

inspect Module

The inspect module leverages all the above attributes (and some more) to allow us to introspect our code more efficiently.

You might have already used the builtin dir() method to get all attributes of an object. inspect module has a similar one called getmembers():

import inspect


# [('__class__', <class 'type'>),
#  ('__delattr__', <slot wrapper '__delattr__' of 'object' objects>),
#  ('__dict__', mappingproxy({...})),
#  ...,
#  ('__init__', <slot wrapper '__init__' of 'object' objects>), ('__repr__', <slot wrapper '__repr__' of 'object' objects>),
#  ('__weakref__', ..., ('some_func', <function SomeClass.some_func at 0x7f3fdf62e5e0>)]

methods = inspect.getmembers(SomeClass, lambda attr: not(inspect.ismethod(attr)))  # No attributes
methods_filtered = [m for m in methods if not(m[0].startswith("__") and m[0].endswith("__"))]
# [('some_func', <function SomeClass.some_func at 0x7fc823f77430>), ...]

attributes = inspect.getmembers(SomeClass, lambda attr: not(inspect.isroutine(attr)))  # No functions
attrs_filtered = [a for a in attributes if not(a[0].startswith("__") and a[0].endswith("__"))]
# [('some_var', 'value'), ...]

inspect.getmembers() has the advantage of providing second argument which can be used for filtering the attributes. Here we use it to filter out the variable attributes and functions respectively.

Another great use-case for inspect module is debugging. You can - for example - use it to debug state of a generator:

import inspect

def some_gen():
    yield 1

# True

gen = some_gen()


except StopIteration:

Here we define dummy generator by putting yield in a body of a function. We can then test whether it's really a generator using isgeneratorfunction(). We can also check its state using getgeneratorstate() which will return one of GEN_CREATED (not yet executed), GEN_RUNNING, GEN_SUSPENDED (waiting at yield) or GEN_CLOSED (consumed).

With help of inspect.signature you can also debug things related to function signature, such as mutable default arguments:

def some_func(var_with_default=[]):

# (var_with_default=[])


# (var_with_default=['value', 'value', 'value'])

It's common knowledge that you should not use mutable types for argument defaults, such as list, because it will get modified (mutated) during each function execution. Inspecting the function signature with inspect.signature() makes it very clear here.

While on the topic of argument defaults, we can also use the signature() function to read them:

def some_func(some_arg=42):

signature = inspect.signature(some_func)
    k: v.default
    for k, v in signature.parameters.items()
    if v.default is not inspect.Parameter.empty
# {'some_arg': 42}

# If you know the argument name:
# 42

This can be helpful if you want to pass the defaults of one function to another one. The above example shows that you can either iterate over all the arguments and pick out the ones that have non-empty default value, or if you know the argument name then you can query it directly.

Some more advanced uses of signature() function include injecting extra arguments to function using a decorator:

from functools import wraps
import inspect

def optional_debug(fn):
    if "debug" in inspect.signature(fn).parameters:
        raise TypeError('"debug" argument already defined')

    def wrapper(*args, debug=False, **kwargs):
        if debug:
            print("Calling", fn.__name__)
        return fn(*args, **kwargs)

    sig = inspect.signature(fn)
    params = list(sig.parameters.values())
    wrapper.__signature__ = sig.replace(parameters=params)
    return wrapper

def func(x):
    return x

# (x, *, debug=False)

func(42, debug=True)
# Prints: Calling func

The above snippet defines a decorator called optional_debug which injects debug argument when applied to a function. It does this by first inspecting function signature and checking for presence of debug argument. If not present, it then replaces the original function signature with a new one with keyword-only argument appended.

Now, let's say you have a function that only declares *args and **kwargs for arguments. This makes the function very "general-purpose", but makes parameter checking quite messy. We can solve this with Signature and Parameter classes from inspect module:

from inspect import Signature, Parameter

params = [Parameter("first", Parameter.POSITIONAL_ONLY),
          Parameter("second", Parameter.POSITIONAL_OR_KEYWORD),
          Parameter("third", Parameter.KEYWORD_ONLY, default="default_value")]

# Options:
# POSITIONAL_ONLY (ones before "/")
# VAR_POSITIONAL ("*args")
# KEYWORD_ONLY (ones after a "*" or "*args")
# VAR_KEYWORD ("**kwargs")

sig = Signature(params)
# (first, /, second, *, third=None)

def func(*args, **kwargs):
    bound_values = sig.bind(*args, **kwargs)
    for name, value in bound_values.arguments.items():
        print(name, value)

func(10, "value")
# first 10
# second value

# func(second="value", third="another")
# TypeError: missing a required argument: 'first'

First, we define the expected parameters (params) using the Parameter class, specifying their types and defaults, after which we create signature from them. Inside the "general-purpose" function, we use bind method of signature to bind the provided *args and **kwargs to the prepared signature. If the parameters don't satisfy the signature, we receive an exception. If all is good, we can proceed to access the bound parameter using the return value of bind method.

The above worked for validating parameters inside a basic function, but what if we wanted to validate that a generator gets all the necessary parameters from a decorated function?

import functools
import inspect

def login_required(fn):

    def wrapper(*args, **kwargs):
        func_args = inspect.Signature.from_callable(fn).parameters
        if "username" not in func_args:
            raise Exception("Missing username argument")

        # ... Perform authentication logic

        return fn(*args, **kwargs)
    return wrapper

def handler(username):

handler()  # Argument not provided...
# TypeError: handler() missing 1 required positional argument: 'username'

The above snippet shows an implementation of an authentication decorator that expects username parameter to be passed to the decorated function. To validate that the parameter is present, we use from_callable() method of Signature class which pulls the supplied parameters from the function. We then check whether username is in the returned tuple before performing any actual logic.

Moving on from the inspect.signature, you can also use the inspect module to introspect your source code files:

from pathlib import Path

import datetime

# /home/martin/.../

# /usr/lib/python3.8/

Here we simply look up location (file) of some_func function using getfile. Same also works for builtin functions/objects, demonstrated above with

The inspect module also includes helpers for working with tracebacks. You can use it - for example - to find source file and line that's being currently executed:

from inspect import currentframe, getframeinfo, stack

frame_info = getframeinfo(currentframe())
frame_info = getframeinfo(stack()[0][0]) # Same as above

print(f"{frame_info.filename}: {frame_info.lineno}")
# 235

def some_func():
    # Equivalent to "getframeinfo(stack()[1][0]).lineno"
    frame = currentframe()
    print(f"Line: {frame.f_back.f_lineno}") # <- Line 243

some_func()  # <- Line 245
# Prints: "Line: 245"

There are couple ways of doing that, the most straightforward is using getframeinfo(currentframe()), you can however also access the stack frames through stack() function - in that case you will have to index into the stack to find the correct frame.

Another option is to use currentframe() directly, in that case you will have to access the f_back and f_lineno to find the correct frame and line respectively.

Next thing you can do with traceback helpers, is to access the caller object:

class SomeClass:

    def some_func(self):
        some_other_class = SomeOtherClass()

class SomeOtherClass:

    def some_other_func(self):
        import inspect

some_class = SomeClass()
# <__main__.SomeClass object at 0x7f341fe173d0>

To do so, we use inspect.currentframe().f_back.f_locals['self'] which gives us access to the "parent" object. This should only ever be used for debugging - in case you need to access the caller object, you should pass it into the function as an argument, rather than crawling through the stack.

Last but not least, if you're using type hints in your code, then you might be familiar with typing.get_type_hints which helps you inspect type hints. This function however will often throw NameError (especially when called on a class), so if you're on Python 3.10 you should probably switch to using inspect.get_annotations which handles many edge cases for you:

import pytest

# Help on function exit in module _pytest.outcomes:
# exit(reason: str = '', returncode: Optional[int] = None, *, msg: Optional[str] = None) -> 'NoReturn'
#     Exit testing process.
#     ...

# {'reason': <class 'str'>, 'returncode': typing.Optional[int], 'msg': typing.Optional[str], 'return': 'NoReturn'}

For more details about accessing annotations in Python 3.10 and beyond, see docs.

Closing Thoughts

As we've seen, there are more than enough tools to introspect and interrogate your Python code. Some of these are quite obscure, and you might never need to use them, but it's good to be at aware that they exist, especially the ones that can be used for debugging.

If however the above isn't enough, or you just want to dive a bit deeper, then you might want to take a look at ast, which can be used to traverse and inspect the syntax tree or even the dis module used for disassembling Python bytecode.