In the latest release of FastAPI (v0.95 at the time of writing), there is a cool new feature for adding type annotations. It is Annotated
that allows you to add some metadata to your query/path parameters, headers, and even dependencies. There are many benefits of using Annotated
for type annotations, including better editor support, easier-to-share annotated functions, and reduction of code duplication in your codebase. However, it also has some pitfalls in its current state that may cost you hours of debugging time.
Let’s see how it works in practice because it’s much easier to learn it by coding than by talking.
Preparation
If you want to follow along, you need to install the dependencies for FastAPI and uvicorn. The versions are important here because I’m sure the bugs shown in this post will be solved in a new release. Nevertheless, knowing the bugs can save you hours of painful debugging time if you happen to use this version of FastAPI (0.95.0) as me. Or if this bug takes a long time to solve.
It’s recommended to create a virtual environment for our project as we can install different versions of Python and libraries in it.
conda create -n fastapi-v95 python=3.11
conda activate fastapi-v95
pip install fastapi==0.95.0 uvicorn==0.21.1
Create a simple API
We will create a very simple FastAPI endpoint that has a query parameter for demonstration:
from fastapi import FastAPI, Query
app = FastAPI()
@app.get("/")
async def hello(name: str | None = Query(default="World", alias="n")):
return {"Hello": name}
Query
is used for the demonstration of Annotated
later.
We can spin up a server locally with uvicorn
:
uvicorn main:app --port 8000 --reload
Now when you call the API, you can see the default query parameter returned if none is passed:
curl http://127.0.0.1:8000
# {"Hello":"World"}
curl http://127.0.0.1:8000?n=Lynn
# {"Hello":"Lynn"}
Up to now, everything is classical, and nothing new or surprising.
Use Annotated
Now let’s use the new Annotated
feature introduced in FastAPI 0.95.
from typing import Annotated
from fastapi import FastAPI, Query
app = FastAPI()
@app.get("/")
async def hello(name: Annotated[str | None, Query(alias="n")] = "World"):
return {"Hello": name}
With Annotated, the first type parameter (here str | None
) passed to Annotated
is the actual type and the rest is just metadata for other tools (here FastAPI). You can have anything as the metadata, and it’s up to the other tools how to use it.
Importantly, using Annotated
, the default value is specified after the equal sign (=
), and should not be specified in Query
anymore.
Since we use --reload
with uvicorn, the API code should be reloaded automatically. And if you call the endpoint again, everything should work properly as demonstrated above.
Use Annotated with APIRouter
Now it’s the fun part. Let’s try to break the code with APIRouter
:
from typing import Annotated
from fastapi import APIRouter, FastAPI, Query
app = FastAPI()
router = APIRouter()
@router.get("/")
async def hello(name: Annotated[str | None, Query(alias="n")] = "World"):
return {"Hello": name}
app.include_router(router)
This time the server crashed with a weird error saying that Query
default value cannot be set in Annotated
and needs to be set by the equal sign “=” instead:
assert field_info.default is Undefined or field_info.default is Required, (
AssertionError: `Query` default value cannot be set in `Annotated` for 'name'.
Set the default value with `=` instead.
This error is not helpful at all because we are using Annotated
as instructed in the official document, and we didn’t set a default value for Query
inside Annotated
. It’s specified with the equal sign. So why does it crash?
I tried to Google this problem with all kinds of keywords but was unable to find an answer (at the time of writing). And I tried to debug all the API code as well as the source code but still could not find a clue.
I even tried to rule out problems in library dependencies in my project’s requirements.txt
, but couldn’t find anything suspicious there either.
Finally, when I was almost ready to give up, I said to myself, there must be something different in my code that breaks the Annotated
feature.
So I started to compare the official code with my project code line by line. And after hours of debugging (which has the side effect of letting me get more familiar with my project code by the way 😏), I finally found that the APIRouter
is something not covered in the official documentation for Annotated
.
Then I tried to remove the APIRouter
and it worked, as shown in the second example above.
However, removing the APIRouter
instances will mean a major refactoring of the project code and it’s a very bad idea.
Instead, we can use the old style without Annotated
when default values are required and thus end in a hybrid state. Yes, this is not perfect and may look ugly to many perfectionists, including me. However, it’s a workaround if you want to use the latest Annotated
feature in your API code together with APIRouter
, before the FastAPI developers fix this bug.
This is the “hybrid” code:
from typing import Annotated
from fastapi import APIRouter, FastAPI, Path, Query
app = FastAPI()
router = APIRouter()
@router.get("/{greet}")
async def hello(
greet: Annotated[str, Path()],
name: str | None = Query(default="World", alias="n"),
):
return {greet: name}
app.include_router(router)
In order to create a “hybrid” state, a new Path
parameter is introduced above. And now the server can be started properly and everything is working as expected:
curl http://127.0.0.1:8000/hello
# {"hello":"World"}
curl http://127.0.0.1:8000/hey
# {"hey":"World"}
curl http://127.0.0.1:8000/hi?n=Lynn
# {"hi":"Lynn"}
Cheers! Problem “solved”!
In this post, we have introduced the new Annotated
typing feature of FastAPI. Annotated
is a very cool feature and has many advantages. However, it also has some bugs in its current state. Using some simple-to-follow examples, we have covered how to use it properly and how to avoid the pitfalls with APIRouter
. These small tips can potentially save you hours of debugging time.
Leave a comment