Why too much Pydantic can be a bad thing
Recently, there has been a fashion among open source packages (such as Langchain and CrewAI) to have all their objects, such as agents and tools, inherit from Pydantic's BaseModel
. They call it "strongly typed code" or something like that, and talk about it like it's a good thing.
We think it's a terrible idea; this post will explain why.
First of all, we're not opposed to Pydantic per se. Our favorite use for it is specifying the data models for getting structured outputs from an LLM. Take this example from Langchain's docs:
class Joke(BaseModel):
"""Joke to tell user."""
setup: str = Field(description="The setup of the joke")
punchline: str = Field(description="The punchline to the joke")
rating: Optional[int] = Field(
default=None, description="How funny the joke is, from 1 to 10"
)
structured_llm = llm.with_structured_output(Joke)
structured_llm.invoke("Tell me a joke about cats")
This is perfect: type declarations, descriptions, defaults - everything is tidy, everything in its right place. For such pure data objects, Pydantic is even better than Python's @dataclass
.
Another brilliant Pydantic use case is declaring input models when building an API, like in this FastAPI tutorial, and the validation of input data couldn't be more important in such cases - who knows what stuff the users might try to shove into the request?
The trouble starts when people try to inherit every important class in their library from BaseModel
. We are against it for a variety of reasons:
Firstly, the foundational or if you wish a philosophical reason: whenever you try to make a thing into something which is contrary to its inner nature, the result is guaranteed to be painful and crippled. If you really want to do strongly typed coding, pick a strongly typed language and use it - for example, I hear very good things about Rust and its compatibility with Python.
You don't have to convert your whole library into Rust either, just the bits where you care greatly about enforcing the typing. And these days, with AI-assisted editors such as Cursor, learning a new language is not nearly as much of a hurdle as it used to be.
But trying to turn Python into a statically typed language, breaking many of Python's conventions in the process, won't give you the full advantages of a proper strongly typed language, while taking away many of Python's.
Let's take just one example:
from pydantic import BaseModel
class A(BaseModel):
pass
class Picky(BaseModel):
thingy: A
Picky(A())
Do you think this'll work? No! Pydantic breaks Python's convention for resolving arguments and requires you to pass the instance of A as keyword argument.
At least it now seems to work reasonably well with inheritance - that wasn't the case when I tried it a year ago, and that was truly a crippling flaw.
The main beef we have with Pydantic however are its exceptions. They seem to be designed to be as cryptic and useless as possible (I haven't seen something quite as painful since debugging templates in C++) - typically, when anything goes wrong, all the exception tells you is that some function deep inside Pydantic's code base didn't see what it expected to see (for example, three arguments instead of two, etc), without any hints as to what the cause of this might be.
The latest example was yesterday, when Egor was writing some RAG code, and some function inside LllamaIndex was calling .dict()
on a TextNode
class. That class is pretty chunky, with dozens of properties, some of which are themselves dicts, other classes, etc. That .dict()
call was failing, and all the information Pydantic was giving was this:
File "C:\Users\EgorKraev\...\site-packages\pydantic\main.py", line 347, in model_dump
return self.__pydantic_serializer__.to_python(
pydantic_core._pydantic_core.PydanticSerializationError: Error calling function `<lambda>`: AttributeError: 'str' object has no attribute 'value'
Which attribute of the class caused the error? No idea, and no way to tell - and since the Pydantic code underneath seems to be compiled (no source code visible, just headers), no way to step into it and see for myself either. Egor tried writing a for-loop that added the attributes one by one, calling dict()
and seeing when it started failing - but no, the class inherits from BaseModel
, some of those attributes are mandatory, and the class can't be created without them; sure it's possible to introspect the data model to figure out which ones, but just how much time should the user waste on this?
In the end Egor solved it by monkey-patching the method on his objects, node.__class__.dict = lambda x: x.__dict__
(which by the way would be impossible in the strongly typed languages that indiscriminate Pydantic usage tries to imitate) and the code ran fine, which illustrates our basic point: often, the best way to interact with Pydantic is just to avoid using it.
Like all great tools, Pydantic was designed to serve one particular purpose: defining and validating data models, and it does this very well. Problems arise, however, when folks try to use it to turn Python into something it is not, such as a strongly typed language. When used thoughtfully and within its intended scope, Pydantic can indeed greatly simplify data validation in your apps.