fastapi-best-practices
fastapi-best-practices copied to clipboard
Pydantic2 & double conversions (#22)
Hello,
Love this repo!
I was trying to modify your example from #22
to check if nested models are round-tripped unnecessarily if contained within the dict I return in my endpoint function. Before I could do that, I had to update the example code in #22
to work with pydantic 2.0 (fastapi==0.100.1
, pydantic==2.4.2
). I first changed the root_validator
to model_validator
:
@model_validator(mode="before")
@classmethod
def debug_usage(cls, data: dict):
print("created pydantic model")
return data
and when I run the app and hit that endpoint, I see "created pydantic model" once, and do not get "called dict" logged at all.
The dict
method is deprecated in favor of model_dump
, but if I also override model_dump
and model_dump_json
:
def model_dump(self, *args, **kwargs):
print("called model_dump")
return super().model_dump(*args, **kwargs)
def model_dump_json(self, *args, **kwargs):
print("called model_dump_json")
return super().model_dump_json(*args, **kwargs)
I don't get any of those "called ..." messages printed. If I use jsonable_encoder
on a model in a terminal, I can see it uses model_dump_json
, but FastAPI doesn't seem to use any of these!
So my questions are:
- in recent versions of
pydantic
andfastapi
, what happens if I return an object whose type matchesresponse_model
? - is this double-encoding problem still a problem? That I only see the object created once makes me think it isn't, but since I can't replicate the full example I'm a but unsure what's going on.
That is the nice catch! Probably, I messed up with migration from Pydantic v1 to v2 since the code of converting the BaseModel to dict is still there and there somewhere must be a validation with response_model
That is the important issue, so I will look at it properly later!
Thanks for the prompt response, and the fastapi link! I used that to dig a bit deeper. I see there it does call model_dump
underneath.
I'll spare you the code, but I can modify you original code to override model_dump
instead of dict
, and call _prepare_response_content
, I get the expected behavior of each message printing once. But in a FastAPI endpoint, I still only get the creation message, and only once.
Looks like _prepare_response_content
is only called 3 times: twice within itself (list/dict, just below where you linked me), and then here, inside serialize_response
. But the comment above that line suggests that's only done for Pydantic v1! So I think _prepare_response_content
is a red herring?
Yet when I call serialize_response(response_content=profile_response)
, I do get the model_dump
message!
It's unclear to me which path the endpoint is taking through serialize_response
that avoids calling model_dump
(or dict
or model_dump_json
), but it seems clear the ProfileResponse
is only created once, which seems like a good sign that this double-serializing is no longer an issue?
Well, I have some understanding right now with those updates.
1, If you change from mode=before
to mode=after
in the model_validator
, you'll see the print is called twice.
2. It seems like re-validation of the model with ModelField happens in create_response_field function. Not sure though.
Probably, the usage of those modes in ModelField
(validation vs serialization) influences the way Pydantic validates models with modes like before
and after
. Also, "field" part of the "ModeField" shouldn't confuse you to think that it's only the fields inside since it seems like it's not. Maybe I'm wrong though :)
If you want, you can make a PR with fixing the mode so that you would be added to the contributors list.
Used logging
to print a stack trace and learned some things!
First, my code:
code
import logging
from fastapi import FastAPI
from pydantic import BaseModel, model_validator
STACK_INFO = False
app = FastAPI()
class ProfileResponse(BaseModel):
def __init__(self, *args, **kwargs):
logging.error("init'd", stack_info=STACK_INFO)
super().__init__(*args, **kwargs)
@model_validator(mode="before")
@classmethod
def validate_before(cls, data: dict):
logging.error("validated before", stack_info=STACK_INFO)
return data
@model_validator(mode="after")
def validate_after(self):
logging.error("validated after", stack_info=STACK_INFO)
return self
def model_dump(self, **kwargs) -> dict:
logging.error("called model_dump", stack_info=STACK_INFO)
return super().model_dump(**kwargs)
def model_dump_json(self, *args, **kwargs) -> str:
logging.error("called model_dump_json", stack_info=STACK_INFO)
return super().model_dump_json(*args, **kwargs)
def dict(self, *args, **kwargs) -> dict:
logging.error("called dict", stack_info=STACK_INFO)
return super().dict(*args, **kwargs)
@app.get("/", response_model=ProfileResponse)
async def root():
return ProfileResponse()
Calling that endpoint shows we only create the object and call the before-validator once, but we call the after-validator twice:
ERROR:root:init'd
ERROR:root:validated before
ERROR:root:validated after
ERROR:root:validated after
If we modify the endpoint to return {}
instead of ProfileResponse
, we get exactly the same logs! So it appears that FastAPI is making an additional call to the after-validator, without recreating the object, in either case.
If we then change STACK_INFO = True
at the top, we can see where this is coming from.
The first set of logs, up through the first call to the after-validator, come from either run_endpoint_function
(return ProfileResponse
) or serialize_response
(return {}
)
stack traces
<truncated>
File "/home/claytonjy/src/ears-asr/.venv/lib/python3.10/site-packages/starlette/routing.py", line 66, in app
response = await func(request)
File "/home/claytonjy/src/ears-asr/.venv/lib/python3.10/site-packages/fastapi/routing.py", line 273, in app
raw_response = await run_endpoint_function(
File "/home/claytonjy/src/ears-asr/.venv/lib/python3.10/site-packages/fastapi/routing.py", line 190, in run_endpoint_function
return await dependant.call(**values)
File "/home/claytonjy/src/ears-asr/temp.py", line 45, in root
return ProfileResponse()
File "/home/claytonjy/src/ears-asr/temp.py", line 15, in __init__
super().__init__(*args, **kwargs)
File "/home/claytonjy/src/ears-asr/.venv/lib/python3.10/site-packages/pydantic/main.py", line 164, in __init__
__pydantic_self__.__pydantic_validator__.validate_python(data, self_instance=__pydantic_self__)
File "/home/claytonjy/src/ears-asr/temp.py", line 26, in validate_after
logging.error("validated after", stack_info=STACK_INFO)
<truncated>
File "/home/claytonjy/src/ears-asr/.venv/lib/python3.10/site-packages/starlette/routing.py", line 66, in app
response = await func(request)
File "/home/claytonjy/src/ears-asr/.venv/lib/python3.10/site-packages/fastapi/routing.py", line 291, in app
content = await serialize_response(
File "/home/claytonjy/src/ears-asr/.venv/lib/python3.10/site-packages/fastapi/routing.py", line 144, in serialize_response
value, errors_ = field.validate(response_content, {}, loc=("response",))
File "/home/claytonjy/src/ears-asr/.venv/lib/python3.10/site-packages/fastapi/_compat.py", line 119, in validate
self._type_adapter.validate_python(value, from_attributes=True),
File "/home/claytonjy/src/ears-asr/.venv/lib/python3.10/site-packages/pydantic/type_adapter.py", line 283, in validate_python
return self.validator.validate_python(__object, strict=strict, from_attributes=from_attributes, context=context)
File "/home/claytonjy/src/ears-asr/temp.py", line 15, in __init__
super().__init__(*args, **kwargs)
File "/home/claytonjy/src/ears-asr/.venv/lib/python3.10/site-packages/pydantic/main.py", line 164, in __init__
__pydantic_self__.__pydantic_validator__.validate_python(data, self_instance=__pydantic_self__)
File "/home/claytonjy/src/ears-asr/temp.py", line 26, in validate_after
logging.error("validated after", stack_info=STACK_INFO)
but the additional after-validation comes from serialize_response
in both cases
stack trace
<truncated>
File "/home/claytonjy/src/ears-asr/.venv/lib/python3.10/site-packages/starlette/routing.py", line 66, in app
response = await func(request)
File "/home/claytonjy/src/ears-asr/.venv/lib/python3.10/site-packages/fastapi/routing.py", line 291, in app
content = await serialize_response(
File "/home/claytonjy/src/ears-asr/.venv/lib/python3.10/site-packages/fastapi/routing.py", line 144, in serialize_response
value, errors_ = field.validate(response_content, {}, loc=("response",))
File "/home/claytonjy/src/ears-asr/.venv/lib/python3.10/site-packages/fastapi/_compat.py", line 119, in validate
self._type_adapter.validate_python(value, from_attributes=True),
File "/home/claytonjy/src/ears-asr/.venv/lib/python3.10/site-packages/pydantic/type_adapter.py", line 283, in validate_python
return self.validator.validate_python(__object, strict=strict, from_attributes=from_attributes, context=context)
File "/home/claytonjy/src/ears-asr/temp.py", line 26, in validate_after
logging.error("validated after", stack_info=STACK_INFO)
I think this shows that the double-creation problem has been fixed in the latest versions, but that a new, possibly-undesirable extra call to the after-validator is now present? Perhaps there's some other way to avoid that?
Happy to file a PR; should I remove the section, or add some kind of note about it no longer applying?