Technology Python

Python's Runtime Identity Crisis: A Guide to Knowing What Type You're Holding

Ever felt betrayed when your Python type hints vanish at runtime? This is the story of how to uncover the real types inside your Pydantic generics and typing constructs, with a battle-tested strategy that actually works.

The Lie We Tell Ourselves

I used to think my code was safe. mypy was happy. My IDE flashed a reassuring green checkmark. My Pydantic models were beautifully annotated. “Of course, this Message[int] knows it’s holding an integer,” I’d tell myself. “The types are right there.”

Then I tried to ask my model, at runtime, what type it was holding. The response was a confused shrug.

It turns out there are two worlds: the pristine, orderly world of static type checking, and the chaotic, messy reality of runtime. The map your type checker uses is often thrown away before your code actually runs. This is the story of how to draw a new map—a reliable way to ask your code, “What type are you, really?”

The Two Worlds: Static Blueprints vs. Runtime Reality

Think of type hints like an architect’s blueprint for a house. The blueprint for list[int] clearly says, “This is a list, and it must contain integers.” mypy and your IDE are like building inspectors who check the blueprint for errors. They’ll tell you if you try to put a str where an int should go.

But when Python runs the code, it’s not looking at the blueprint anymore. It’s standing inside the finished house. All it sees is a list. The [int] part? That’s just a faint memory, a notation on a blueprint that got filed away.

This is the heart of the problem. We, as developers, need to be able to look at the finished house and figure out what was on the blueprint. We need to do some runtime detective work.

Level 1: The Simplest Clue - Just Ask the Value

Our first tool is the most direct. If you want to know what something is, just ask it. In Python, that’s the type() function.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# Trivial Example: What is 5?
print(type(5))
# Output: <class 'int'>

# Realistic Example: Inside a Pydantic model
from pydantic import BaseModel

class User(BaseModel):
    name: str
    age: int

user = User(name="Alice", age=30)

# What's the type of the 'age' we received?
print(type(user.age))
# Output: <class 'int'>

This is our bedrock, our source of ground truth. When you have a value, type(value) will never lie to you. It tells you what you have right now.

But what if you don’t have a value yet? What if you’re writing a function that needs to know what type of list it’s supposed to receive, even if the list is empty? For that, we need to dig deeper.

Level 2: Reading the Blueprint’s Margins with typing

Sometimes, Python doesn’t throw the whole blueprint away. For special objects from the typing module, it keeps some notes in the margins. We can read these notes with two helper functions: get_origin and get_args.

  • get_origin(some_type) is like asking, “What’s the main container type?” (e.g., list, dict).
  • get_args(some_type) is like asking, “What are the specific types inside?” (e.g., int, str, float).

Let’s see them in action:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
from typing import get_origin, get_args, Optional, Dict

# The blueprint for a list of integers
int_list_type = list[int]

print(f"Origin: {get_origin(int_list_type)}")   # --> <class 'list'>
print(f"Args: {get_args(int_list_type)}")     # --> (<class 'int'>,)

# Works for more complex types, too
user_data_type = Dict[str, Optional[int]]

print(f"Origin: {get_origin(user_data_type)}")   # --> <class 'dict'>
# The args are the key and value types
print(f"Args: {get_args(user_data_type)}")     # --> (<class 'str'>, typing.Optional[int])

This feels powerful! It seems like we’ve solved it. But a new villain enters the scene, and this is where most developers get stuck.

The Trap: What happens if you use these on a normal class?

1
2
3
4
5
class Message:
    ...

print(get_origin(Message)) # --> None
print(get_args(Message))   # --> ()

Nothing. These tools only work on the special constructs from the typing module, not on regular classes. And as it turns out, a specialized Pydantic generic like Message[int] behaves a lot more like a regular class than a typing construct.

The Boss Level: Pydantic v2’s Clever Disguise

When you write MyGenericModel[int], Pydantic doesn’t just store int somewhere. It dynamically creates a brand new class on the fly. This new class is a subclass of MyGenericModel, and it’s been specifically tailored to handle integers.

This is incredibly powerful, but it means our get_origin/get_args trick won’t work. We’re dealing with a real class, not a typing annotation. I remember spending hours on this, thinking I was going crazy. “Why can’t I get the int out of Message[int]?!”

The secret is that Pydantic leaves clues for us inside this new class. We just have to know where to look. There are two reliable spots:

  1. The Secret Metadata Pouch: A hidden attribute called __pydantic_generic_metadata__. This is the most direct and precise clue, telling us exactly what T was specialized with.
  2. The Public Field Annotation: Pydantic updates the annotation on the model’s fields. So, on a Message[int] class, the content field’s annotation is no longer T, but int.

The Grand Unifying Strategy: A Three-Layer Forensic Kit

So, how do we combine all this knowledge into a single, reliable strategy? We build a function that checks for clues in the right order, from most specific to most general.

First, we need a little helper to make our type names readable. Think of it as a magnifying glass that works on any kind of clue.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
from typing import Any, get_origin, get_args

def pretty_type_name(tp: Any) -> str:
    """A helper to get readable names for any type."""
    # Is it a plain class like `int` or `User`?
    if hasattr(tp, "__name__"):
        return tp.__name__
    
    # Is it a typing construct like `list[int]`?
    origin = get_origin(tp)
    if origin:
        # Recursively pretty-print the inner types
        inner = ", ".join(pretty_type_name(a) for a in get_args(tp))
        base = getattr(origin, "__name__", str(origin))
        return f"{base}[{inner}]"

    # If all else fails, just convert it to a string
    return str(tp)

Now, we can build our master detective method inside our generic Pydantic model. We’ll use a @computed_field to make this information easily accessible.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
from typing import Generic, TypeVar
from pydantic import BaseModel, computed_field

T = TypeVar("T")

class Message(BaseModel, Generic[T]):
    content: T

    @computed_field
    @property
    def param_type(self) -> str:
        """
        The design-time type. What was this generic parameterized with?
        """
        # 1. Check Pydantic's secret metadata pouch first. It's the most precise clue.
        meta = getattr(self.__class__, "__pydantic_generic_metadata__", None)
        if meta and meta.get("args"):
            # We found it! Let's make it readable.
            return pretty_type_name(meta["args"][0])

        # 2. No metadata? Let's check the field's public annotation.
        #    Pydantic often updates this for us on the specialized class.
        field_annotation = self.__class__.model_fields["content"].annotation
        if field_annotation is not T: # Make sure it's not just the unspecialized TypeVar
            return pretty_type_name(field_annotation)

        # 3. If we're still here, it means the model was likely not parameterized
        #    (e.g., Message(content=123)). Our only source of truth is the
        #    actual value.
        return self.runtime_type

    @computed_field
    @property
    def runtime_type(self) -> str:
        """The value-time type. What is the type of the content right now?"""
        return pretty_type_name(type(self.content))

Let’s test our detective kit:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# Create a specialized class
IntMessage = Message[int]
msg1 = IntMessage(content=123)
print(f"Param Type: {msg1.param_type}")     # -> "int"
print(f"Runtime Type: {msg1.runtime_type}")   # -> "int"

# Create a generic message where the value is the only truth
msg2 = Message(content="hello")
print(f"Param Type: {msg2.param_type}")     # -> "str" (falls back to runtime_type)
print(f"Runtime Type: {msg2.runtime_type}")   # -> "str"

It works! This three-layer strategy is robust. It prefers the precise design-time information when available, but gracefully falls back to the undeniable truth of the runtime value.

Side Quest: Taming Forward Refs and Circular Nightmares

Sometimes, you have to define models that refer to each other before they’re fully defined. This is common in things like ORMs or complex API schemas.

1
2
3
4
5
class A(BaseModel):
    b: 'B'  # 'B' isn't defined yet! This is a "forward reference".

class B(BaseModel):
    a: 'A'

This creates a paradox. How can Python understand A without knowing B, and vice-versa? The string 'B' is like an IOU for a type. The problem is that when it’s time to cash in that IOU, Python needs to know where to look.

If your models are defined inside a function, the names A and B might only exist in that function’s local scope. When you try to resolve the types later from a different scope, Python can’t find them.

The solution is to give Python a map. You capture the namespace (the dictionary of local and global names) where the models were defined and provide it when you ask for the type hints.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
from typing import get_type_hints

def create_circular_models():
    class A(BaseModel):
        b: 'B'
    
    class B(BaseModel):
        a: A

    # Capture the "map" of names available right here, right now.
    local_namespace = locals()

    # Later, from anywhere, you can resolve the types using the map.
    hints_A = get_type_hints(A, localns=local_namespace)
    print(hints_A['b']) # --> <class '__main__.create_circular_models.<locals>.B'>
    # It worked!

create_circular_models()

If you keep your models at the top level of a module, you often don’t need to worry about this, as Python’s default global scope is usually enough. But the moment you start defining models inside functions, this localns trick is a lifesaver.

Your New Mental Model

Stop asking, “Why won’t Python give me the type?” Start asking:

What am I inspecting (a blueprint, a class, or a value), and do I have the right map (the scope) to find what I’m looking for?

With this mental model, runtime type introspection stops being a frustrating mystery and becomes a straightforward process of investigation. Your Pydantic generics will no longer feel like a black box, but a powerful tool you can confidently inspect and understand.

(Written by Human, improved using AI where applicable.)