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

Time to read
13 min
Category
FastAPI vs. Flask: Comparing the Pros and Cons of Top Microframeworks for Building a REST API in Python
Table of Contents
  • What is Flask? Why use it?
  • What is FastAPI? Why use it?
  • Data validation
  • Outbound data serialization
  • Creating views and defining data
  • Fetching messages, variables in the address, and GET parameters
  • Filtering messages with GET parameters
  • Dependency injection
  • Asynchronicity
  • Automatic documentation
  • Final thoughts on Flask and FastAPI

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 = ""
 
   # FastAPI
class NewsSchema(BaseModel):
title: str = ""
content: str = ""
creator: CreatorSchema

class CreatorSchema(BaseModel):
first_name: str = ""
last_name: str = ""

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
)
 
   # 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
)

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
 
   # 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",
)

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
 
   # 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())}

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
 
   # 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()

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()
 
   news_schema = NewsSchemaInput(**request.get_json())

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
 
   # 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()},
)

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()

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

 
   return output_schema, HTTPStatus.CREATED

Now, let’s take a look at FastAPI. This time, we get two parameters in the view: `news_input` and` db_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 the` NewsSchemaInput` 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),
):

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

 
   db_news = await db_repo.save_news(news_dto=news_dto)

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 the` status_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
 
   # 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()

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]
)
 
   # 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]

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)

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]

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]
)

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]

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)

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()

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

Thanks to dependency injection in FastAPI. we can use…

 
   app.dependency_overrides[db_repo] = OUR OWN CALLABLE IMPLEMENTATION

…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.

Asynchronicity - 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.

Asynchronicity - Locust - FastAPI

FastAPI

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

SwaggerUI

Automatic documentation - ReDoc

ReDoc

In SwaggerUI, we also have access to all the schemas that we have defined in our application:

Automatic documentation - 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()

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"}}
},
}
}

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

Automatic documentation - docstring 1

Automatic documentation - docstring 2

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.

At STX Next, we specialize in Python and offer plenty of useful resources on the subject—for instance, how it compares to other languages and what it’s used for the most. Lastly, head over here to check out the application I made!

 


Daniel Różycki has been working in the IT business for four years. He specializes in Python and has been working as a Python Developer at STX Next for a year. He started his career working with blockchain technologies and currently deals with various projects as part of his software house work. Passionate about clean architecture and clean code, he loves programming in his spare time, as well.

Read insights from 250 CTOs

Read the report
Read the report

Read insights from 250 CTOs

Read the report
Read the report
Content Specialist
Python Developer
Share this post