DEV Community

Cover image for FastAPI vs. Flask: Comparing the Pros and Cons of Top Microframeworks for Building a REST API in Python
Adam Przewoźny for STX Next

Posted on • Originally published at stxnext.com

FastAPI vs. Flask: Comparing the Pros and Cons of Top Microframeworks for Building a REST API in Python

Originally written by Adam Stempniak and Daniel Różycki

Creating web applications such as REST APIs is the bread and butter of backend developers. Therefore, working with a web framework should be quick and easy.

Microframeworks are a great start for small projects, MVPs, or even large systems that need a REST API—including Flask and FastAPI.

I wrote an application to create, update, download, and delete news in these two frameworks. As a result, here’s my comparison of FastAPI and Flask.

What is Flask? Why use it?

Flask is one of the most popular libraries for building web applications in Python. People who start their adventure with programming will easily find a lot of Flask tutorials and solutions to common problems.

It is lightweight (a “microframework”) and very well documented, with many extensions and a large community.

What is FastAPI? Why use it?

FastAPI ranks among the highest-performing Python web frameworks for building APIs out there and it’s being used more and more day by day.

Its emphasis on speed, not only in terms of the number of queries handled per second, but also the speed of development and its built-in data validation, makes it an ideal candidate for the backend side of our web application.

Data validation

Here’s where we can find the first significant difference between the two libraries.

By installing Flask, we don’t get any data validation tool. However, we can work around that by using extensions offered by the community, such as Flask-Marshmallow or Flask-Inputs.

The downside of this solution is that we have to rely on libraries that are developed separately from our main framework, meaning we can’t be 100% sure they will be compatible.

FastAPI, on the other hand, gives us the Pydantic library to use, which makes data validation much simpler and faster than typing it by hand. It’s closely related to FastAPI itself, so we can be sure that Pydantic will be compatible with our framework at all times.

So, what are the validations in the individual libraries based on our simple API?

We create classes named NewsSchema / CreatorSchema that will be the base classes for validating our news and authors.

# Flask
@dataclass()
class NewsSchema(BaseSchema):
   title: str = ""
   content: str = ""
   creator: CreatorSchema = CreatorSchema()

@dataclass
class CreatorSchema(BaseSchema):
   first_name: str = ""
   last_name: str = ""
Enter fullscreen mode Exit fullscreen mode
  # FastAPI
   class NewsSchema(BaseModel):
      title: str = ""
      content: str = ""
      creator: CreatorSchema

   class CreatorSchema(BaseModel):
      first_name: str = ""
      last_name: str = ""
Enter fullscreen mode Exit fullscreen mode

We can notice that FastAPI’s NewsSchema / CreatorSchema use BaseModel as a parent class. This is required because BaseModel comes from the Pydantic library and has the functions necessary for data validation.

In Flask, however, we inherit from the BaseSchema class, which is a regular data class and contains several methods the inheriting classes will use or override.

In our case, we will only check whether the text we enter is within the character limit.

The validation itself will take place in the NewsSchemaInput / CreatorSchemaInput classes:

 # Flask
   @dataclass()
   class NewsSchemaInput(NewsSchema):
      _errors: dict = field(init=False, default_factory=dict)

      def _validate_title(self) -> None:
         if MIN_TITLE_LEN > len(self.title) < MAX_TITLE_LEN:
            self._errors[
               "title"
            ] = f"Title should be {MIN_TITLE_LEN}-{MAX_TITLE_LEN} characters long"

      def _validate_content(self) -> None:
         if len(self.content) < MIN_CONTENT_LEN:
            self._errors[
               "content"
            ] = f"Content should be minimum {MIN_CONTENT_LEN} characters long"

      def __post_init__(self) -> None:
         self._validate_content()
         self._validate_title()
         try:
            if not isinstance(self.creator, CreatorSchemaInput):
               self.creator = CreatorSchemaInput(**self.creator)
         except ValidationError as err:
            self._errors["creator"] = err.errors
         if self._errors:
            raise ValidationError(
               f"Validation failed on {type(self).__name__}", self._errors
            )
Enter fullscreen mode Exit fullscreen mode
# Flask
   @dataclass
   class CreatorSchemaInput(CreatorSchema):
      _errors: dict = field(init=False, default_factory=dict)

      def _validate_first_name(self) -> None:
         if FIRST_NAME_MIN_LEN > len(self.first_name) < FIRST_NAME_MAX_LEN:
            self._errors[
               "first_name"
            ] = f"First name should be {FIRST_NAME_MIN_LEN}-{FIRST_NAME_MAX_LEN} characters long"

      def _validate_last_name(self) -> None:
         if LAST_NAME_MIN_LEN > len(self.last_name) < LAST_NAME_MAX_LEN:
            self._errors[
               "last_name"
            ] = f"Last name should be {LAST_NAME_MIN_LEN}-{LAST_NAME_MAX_LEN} characters long"

      def __post_init__(self) -> None:
         self._validate_first_name()
         self._validate_last_name()
         if self._errors:
            raise ValidationError(
               f"Validation failed on {type(self).__name__}", self._errors
            )
Enter fullscreen mode Exit fullscreen mode

When we create our object NewsSchemaInput / CreatorSchemaInput, the __post_init__ method will be run, where we execute data validation (checking the text length). If it’s incorrect, we add errors to the _errors variable, and finally raise a Validation Error exception.

In the case of structures that are nested (CreatorSchemaInput), we have to create these objects manually. We do it after the NewsSchemaInput validation is done in the __post_init__ method.

The data checking itself is not a big problem—only adding new fields will be cumbersome, because we have to add a separate _validate method each time. In the case of a nested structure, we have to create an instance of this object and catch an exception.

We can see that the classes that validate the incoming data become quite extensive—and that’s just for a few keys. We also need to add our own implementation of error handling, so that we can add nested error information in the API responses.

In FastAPI, it is much simpler and more enjoyable:

 # FastAPI
   class NewsSchemaInput(NewsSchema):
      title: str = Field(
         title="Title of the News",
         max_length=MAX_TITLE_LEN,
         min_length=MIN_TITLE_LEN,
         example="Clickbait title",
      )
      content: str = Field(
         title="Content of the News", min_length=50, example="Lorem ipsum..."
      )
      creator: CreatorSchemaInput
Enter fullscreen mode Exit fullscreen mode
   # FastAPI
   class CreatorSchemaInput(CreatorSchema):
      first_name: str = Field(
         title="First name of the creator",
         min_length=FIRST_NAME_MIN_LEN,
         max_length=FIRST_NAME_MAX_LEN,
         example="John",
      )
      last_name: str = Field(
         title="Last name of the creator",
         min_length=LAST_NAME_MIN_LEN,
         max_length=LAST_NAME_MAX_LEN,
         example="Doe",
      )
Enter fullscreen mode Exit fullscreen mode

By importing Field from Pydantic, we have access to simple rules that must be followed for user input to be valid. Data types are also validated on the basis of variable types, so if our first_name variable has the str type, we must pass text in the input (and act similarly for all built-in data types).

Without any extra code, Pydantic does a great job checking nested structures (CreatorSchemaInput in this case).

We can find all of this in no more than a few lines of code!

In addition to max_length and min_length, we can also see two additional parameters: title and example. They are optional, but will be visible in the automatic documentation generated by FastAPI for us.

Outbound data serialization

Now that we know how to validate the data, we should think about how we want to return it.

The message will have not only the content, title, and author, but also its unique number (id) and the date it was created and updated. We need to create a new class that will serialize the News domain model and it will be NewsSchemaOutput.

# Flask
   @dataclass
   class NewsSchemaOutput(NewsSchema):
      id: int = 0
      created_at: datetime = datetime.now()
      updated_at: datetime = datetime.now()

      def as_dict(self) -> dict:
         schema_as_dict = super().as_dict()
         schema_as_dict["created_at"] = int(self.created_at.timestamp())
         schema_as_dict["updated_at"] = int(self.updated_at.timestamp())
         return schema_as_dict
Enter fullscreen mode Exit fullscreen mode
 # FastAPI
   class NewsSchemaOutput(NewsSchema):
      id: int = Field(example="26")
      created_at: datetime = Field(example="1614198897")
      updated_at: datetime = Field(example="1614198897")

      class Config:
         json_encoders = {datetime: lambda dt: int(dt.timestamp())}
Enter fullscreen mode Exit fullscreen mode

The NewsSchemaOutput class is practically the same in both cases, the only difference being the parent class and the method of serialization to the dictionary (together with changing the datetime object into timestamp).

In FastAPI, while using Pydantic, we have the option of adding a Config class, in which we have placed the json_encoders variable. It helps to serialize the data in the way that we require. In this case, we want to pass the date object as a timestamp. In Flask, however, we had to change the data in the already created dictionary into those that we want to return.

Creating views and defining data

Setting up messages in both libraries is very similar and uses a simple decorator on the function we want to use. However, the ways of defining data validation and serialization differ.

 # Flask
   @news_router.route("/news", methods=["POST"])
   def add_news():
      db_repo = get_database_repo()
      news_schema = NewsSchemaInput(**request.get_json())
      news_dto = NewsDTO.from_news_schema(news_schema=news_schema)
      saved_news = db_repo.save_news(news_dto=news_dto)
      output_schema = NewsSchemaOutput.from_entity(news=saved_news).as_dict()
      return output_schema, HTTPStatus.CREATED
Enter fullscreen mode Exit fullscreen mode
 # FastAPI
   @news_router.post(
      "/news",
      response_model=NewsSchemaOutput,
      summary="Create the news",
      status_code=status.HTTP_201_CREATED,
   )
   async def add_news(
      news_input: NewsSchemaInput,
      db_repo: DatabaseRepository = Depends(get_database_repo),
   ):
      """
      Create the news with following information:

      - **title**: Title of news
      - **content**: News content
      - **creator**: Creator of content
      """
      news_dto = NewsDTO.from_news_schema(news_schema=news_input)
      db_news = await db_repo.save_news(news_dto=news_dto)
      return db_news.as_dict()
Enter fullscreen mode Exit fullscreen mode

At the very beginning, we have a decorator that specifies the path and the HTTP method that will be handled. Flask sets it using the methods parameter, where we need to pass the list of supported methods, while FastAPI uses the post attribute on news_router.

The decorator FastAPI uses is not only used to determine the HTTP path and methods, but also to serialize the data (response_model), describe the view in automatic documentation (summary), define the response status (status_code), and much more—not all of its functions have been included in this example.

It can be said that FastAPI not only defines the access path and method, but also describes the whole view in depth. But what’s really going on in this view? Let’s start with Flask!

The first thing we do is get the database repository for our function with: db_repo = get_database_repo ()

In the next step, we validate the data submitted by the user, which are in the request object:

   db_repo = get_database_repo()
Enter fullscreen mode Exit fullscreen mode
   news_schema = NewsSchemaInput(**request.get_json())
Enter fullscreen mode Exit fullscreen mode

This line will raise a ValidationError exception if the input is invalid.

The exception will be caught in the errorhandler we created and Flask will return a reply with all errors that are in the _errors variable on NewsSchemaInput.

But hold on just a second! We haven’t yet discussed the errorhandler we supposedly created.

In Flask and FastAPI, we can add our own exception handling, which will be thrown in the views implementation. They look like this:

 # Flask
   @app.errorhandler(ValidationError)
   def handle_validation_error(exc: ValidationError) -> Tuple[dict, int]:
      status_code = HTTPStatus.UNPROCESSABLE_ENTITY
      return {"detail": exc.errors}, status_code
Enter fullscreen mode Exit fullscreen mode
# FastAPI
   @app.exception_handler(ValidationError)
   async def handle_validation_error(request: Request, exc: ValidationError):
      return JSONResponse(
         status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
         content={"detail": exc.errors()},
      )
Enter fullscreen mode Exit fullscreen mode

If the validation was successful, create a NewsDTO object that will pass the necessary information to the database repository. The repository will do its magic (save a message in the database) and return the News domain object to us, which we then serialize with the NewsSchemaOutput class:

 news_dto = NewsDTO.from_news_schema(news_schema=news_schema)
   saved_news = db_repo.save_news(news_dto=news_dto)
   output_schema = NewsSchemaOutput.from_entity(news=saved_news).as_dict()
Enter fullscreen mode Exit fullscreen mode

At the very end, we return NewsSchemaOutput as the dictionary and the response status:

   return output_schema, HTTPStatus.CREATED
Enter fullscreen mode Exit fullscreen mode

Now, let’s take a look at FastAPI. This time, we get two parameters in the view: news_input anddb_repo.

In the first one, the input data validation happens before the execution of our view method, thanks to the news_input parameter.

You might be asking yourself: how does FastAPI know which class to use? It’s thanks to typing. The news_input parameter has theNewsSchemaInput type, so what FastAPI does is pass all the data to this class that we sent using the POST method. We don’t need to create an instance of the NewsSchemaInput object because we will get validated data in the news_input parameter.

Regarding db_repo, it works similar to Flask, except that here we’re using dependency injection. The Depends keyword allows you to substitute classes or functions while our application is running. We’ll talk about dependency injection a bit later.

   async def add_news(
      news_input: NewsSchemaInput,
      db_repo: DatabaseRepository = Depends(get_database_repo),
   ):
Enter fullscreen mode Exit fullscreen mode

When our method is called, we save the message in the database.

   db_news = await db_repo.save_news(news_dto=news_dto)
Enter fullscreen mode Exit fullscreen mode

In Flask, we had to create an instance of the NewsSchemaOutput class to return the correct data. Same with the response status: it’s also returned using the return keyword.

FastAPI allows you to specify a class to serialize data using the response_model parameter in the decorator. All we need to do is to provide the correct structure that Pydatnic will understand. The response status can also be set in the same place as response_model, but using thestatus_code parameter.

Fetching messages, variables in the address, and GET parameters

Just as when we create a post, we define the view with a simple decorator. This time, however, we use the GET method.

   # Flask
   @news_router.route("/news/<int:news_id>", methods=["GET"])
   def get_news(news_id: int):
      db_repo = get_database_repo()
      news_from_db = db_repo.get_news(news_id=news_id)
      output_schema = NewsSchemaOutput.from_entity(news=news_from_db).as_dict()
      return output_schema
Enter fullscreen mode Exit fullscreen mode
   # FastAPI
   @router.get(
      "/news/{news_id}",
      response_model=NewsSchemaOutput,
      summary="Get the news by ID",
      responses=NOT_FOUND_FOR_ID,
   )
   async def get_news(
      news_id: int, db_repo: DatabaseRepository = Depends(get_database_repo)
   ):
      """
      Get the news with passed ID
      """
      db_news = await db_repo.get_news(news_id=news_id)
      return db_news.as_dict()
Enter fullscreen mode Exit fullscreen mode

To download the message we’re interested in, we need to pass its id to our view. We do this with an address to which we add the news_id parameter. In Flask, we have to specify its type in detail using angle brackets and the name, i.e. <int: news_id>. We’re forced to use only basic types that Flask understands, such as int, uuid, str or float, and so on.

FastAPI uses a convention that is similar to that used by f-string, where the name of our variable is defined by curly brackets and its type is set in the parameters of the view function.

This is a more flexible solution, as we can try to pass complicated structures in the address. You may also have noticed a new parameter that has appeared in the view decorator. This parameter is called responses—we’ll come back to it when we discuss automatic documentation.

Filtering messages with GET parameters

When we want a flexible solution, instead of creating a view that needs defined variables in the address, we use GET parameters. In this case, we need to return messages that meet the criteria passed to us by the so-called query parameters. We have two parameters: id and created_at.

   # Flask
   @news_router.route("/news", methods=["GET"])
   def get_news_by_filter():
      db_repo = get_database_repo()
      ids = request.args.getlist("id", type=int)
      created_at = request.args.getlist("created_at", type=int)
      news_from_db = db_repo.get_news_by_filter(id=ids, created_at=created_at)
      return jsonify(
         [NewsSchemaOutput.from_entity(news=news).as_dict() for news in news_from_db]
      )
Enter fullscreen mode Exit fullscreen mode
   # FastAPI
   @router.get(
      "/news",
      response_model=List[NewsSchemaOutput],
      summary="Get the news by filter",
      responses=NOT_FOUND_FOR_ID,
   )
   async def get_news_by_filter(
      id: Set[int] = Query(set()),
      created_at: Set[datetime] = Query(set()),
      db_repo: DatabaseRepository = Depends(get_database_repo),
   ):
      """
      Get the news with passed filters.

      - **id**: List of id to search for
      - **created_at**: List of date of creation timestamps
      """
      db_news = await db_repo.get_news_by_filter(id=id, created_at=created_at)
      return [news.as_dict() for news in db_news]
Enter fullscreen mode Exit fullscreen mode

Flask provides the request object from which we can extract data about the request to our view method. Flask offers a request object from which we can retrieve all query data to our view.

This time, we’re interested in the id and created_at parameters. We also know that we can expect a list of these parameters—for this, we use the getlist method from the special args dictionary.

   ids = request.args.getlist("id", type=int)
   created_at = request.args.getlist("created_at", type=int)
Enter fullscreen mode Exit fullscreen mode

Then we send the extracted data to the database repository to get a list of News domain models, which we turn into a list of dictionaries from the NewsSchemaOutput class.

   news_from_db = db_repo.get_news_by_filter(id=ids, created_at=created_at)
   [NewsSchemaOutput.from_entity(news=news).as_dict() for news in news_from_db]
Enter fullscreen mode Exit fullscreen mode

We must also remember that we can’t return the list from the view—it’s necessary to execute the jsonify function for our endpoint to return the Response object with the correct serialization of the list.

   return jsonify(
         [NewsSchemaOutput.from_entity(news=news).as_dict() for news in news_from_db]
      )
Enter fullscreen mode Exit fullscreen mode

With FastAPI, the whole process looks quite similar to Flask—the difference is that we get the address variables in the function parameters, which is much more readable than executing request.args.getlist with each variable we need. In order for FastAPI to know that the function parameters are address variables, we need to add the default Query value to them, which is predefined.

How does FastAPI know that we want a specific data type if we haven’t specified it in curly brackets? Typing shows it.

All we need to do is to add a type to our parameters, e.g. set [int], and we will be sure that the variable will contain a set with integers only.

After the address variables are validated, we extract the News domain models from the database repository using the sent criteria. Then we return the list of message model dictionaries and the response_model in the decorator will deal with correct serialization of the data.

   db_news = await db_repo.get_news_by_filter(id=id, created_at=created_at)
      return [news.as_dict() for news in db_news]
Enter fullscreen mode Exit fullscreen mode

Dependency injection

Dependency injection is a pattern in design and software architecture based on removing direct dependencies between components.

Sounds pretty complicated, right? Well, FastAPI was able to implement this pattern in a very simple way.

We may have noticed that in each view, there is something like this in the function parameters:

   db_repo: DatabaseRepository = Depends(get_database_repo)
Enter fullscreen mode Exit fullscreen mode

This is what we call a dependency injection—in this case, we’re injecting the database repository. The Depends keyword is able to inject anything that can be named (e.g. classes or functions). This is a good method, as it allows you to stick to the DRY (Don’t Repeat Yourself) rule, because you don’t have to create a new variable for the database repository each time, as it is done in Flask:

   db_repo = get_database_repo()
Enter fullscreen mode Exit fullscreen mode

Another advantage of Depends is that it can easily substitute implementations in tests.

In Flask, to replace the return value from get_database_repo, we would have to mock this function every time we run tests.

   @mock.patch("path.to.dependency.get_database_repo)
   def test_some_view(db_repo_inject_mock):
      db_repo_inject_mock.return_value = OUR OWN DB REPO IMPLEMENTATION
Enter fullscreen mode Exit fullscreen mode

Thanks to dependency injection in FastAPI. we can use…

   app.dependency_overrides[db_repo] = OUR OWN CALLABLE IMPLEMENTATION
Enter fullscreen mode Exit fullscreen mode

…to replace the implementation when running the tests.

Depends can also be used to not repeat the same function parameters n times. For more, take a look at the documentation.

Asynchronicity

Unfortunately, Flask doesn’t support asynchronicity and ASGI interface, which means that some long-running queries may block our application. This is related to a smaller number of users we can handle with our REST API.

As you may have noticed, the view functions in FastAPI start with async and each method calling on the database repository is preceded by the word await.

FastAPI is fully asynchronous—which doesn’t mean it’s required, since we can also implement ordinary synchronous functions—and uses the ASGI interface. Thanks to that, we can use non-blocking queries to databases or external services, which means the number of simultaneous users using our application will be much larger than in the case of Flask.

In its documentation, FastAPI has a very well written example of using async and await. I highly recommend reading it!

And how about running a benchmark?

For this task, we will use Locust. It’s a free, open-source Python load testing tool. Our test will be based on adding 100 users to the pool of active connections every second, until we reach 2,000 users at the same time.
Locust - Flask Flask
As we can see, the number of queries per second we can handle is around 633. That’s not bad, right? It could be better, though. The average waiting time for a response is about 1,642 ms—practically one and a half seconds to receive any data from the API is definitely too much. To this, we can add 7% of unsuccessful queries.
Locus - FastAPIFastAPI
FastAPI did much better in this task. The number of queries that we can handle is about 1,150 per second (almost twice as much as in Flask), and the average waiting time for a response is only… 14 ms. All queries were correct and we didn’t spot any errors.

Automatic documentation

When creating a REST API, documentation is essential for a team of developers or users who want to use this interface to communicate with our application.

You can do it manually, e.g. in the Jira Confluence / Github wiki or any other design data collection tool. However, there is a risk of human error, e.g. when someone forgets to update the addresses to views or makes a typo.

The most common standard for creating such documentation is OpenAPI and JSONSchema.

Flask offers extensions, such as Flask-Swagger or Flasgger, which operate using the specification mentioned above. They require additional installation and knowledge of the format used by these standards.

Also, the specifications of the transferred data must be saved manually—they will not be taken from the classes that validate or the parameters that we download.

FastAPI has documentation that is fully compatible with OpenAPI and JSONSchema, which is created automatically from Pydantic schemas and function parameters or GET variables. The user interface is provided by SwaggerUI and Redoc.

This is a very interesting feature, as it doesn’t require any work from us (unless we want to embellish our documentation with details). All the rules for the required data can be found in the Pydatnic schemas.

Documentation is available at host / doc (SwaggerUI) and host / redoc (ReDoc) and looks like this:
Automatic documentation - SwaggerUI - FastAPISwagger UI
Automatic documentation - SwaggerUI - ReDoc
In SwaggerUI, we also have access to all the schemas that we have defined in our application:
Schemas
We can notice that the information from the summary and title parameters from CreatorSchemaInput appeared.

How does FastAPI know what information to pass to the documentation? Let’s look at an example of downloading messages:

   # FastAPI
   @router.get(
      "/news/{news_id}",
      response_model=NewsSchemaOutput,
      summary="Get the news by ID",
      responses=NOT_FOUND_FOR_ID,
   )
   async def get_news(
      news_id: int, db_repo: DatabaseRepository = Depends(get_database_repo)
   ):
      """
      Get the news with passed ID
      """
      db_news = await db_repo.get_news(news_id=news_id)
      return db_news.as_dict()
Enter fullscreen mode Exit fullscreen mode

There are parameters in the decorator that will be taken into account when creating documentation:

  • / news / {news_id}—in the documentation, we will see that the news_id parameter is required and must be an integer
  • response_model—this response scheme will be automatically displayed in the documentation
  • responses—if our view returns response codes other than 200/400/422 or 500, we can add a special dictionary with the statuses and the returned data schema, like here:
   NOT_FOUND_FOR_ID: Response_Type = {
      404: {
         "description": "News with given ID wasn't found",
         "content": {
            "application/json": {"example": {"detail": "News with id {id} don't exist"}}
         },
      }
   }
Enter fullscreen mode Exit fullscreen mode

Also, the docstring is taken into account and will be shown as additional information for the specific view.
Automatic documentation - docstring

Final thoughts on Flask and FastAPI

Thanks for reading my comparison of these two great libraries using a very simple CRUD application from a REST API as an example.

On the one hand, we have the very popular Flask, which can’t be ignored; on the other, there is FastAPI, which wins the hearts of users with the number of built-in functionalities and asynchronicity.

So, which one is better? Personally, if I were to pick the framework for my next REST project, I’d certainly lean toward FastAPI.

Of course, you’re free to draw your own conclusions and choose differently. However, I hope you’ll at least try to give FastAPI a chance.

Top comments (0)