Django user trying out FastAPI
Django was my first choice for many years. But building new API-driven products using Django and Django REST Framework never brought that pleasing feeling I would like to feel.
The Good, the Bad and the Ugly
It’s been around 2007 I stumbled upon Django framework. Needed to build a very simple image gallery, nothing serious. And since then, every time I had to do something to be displayed on the web, Django was my first choice. What was a big deal for me was it’s ORM, contrib.auth
and contrib.admin
. Going up with and running in minutes, having some database models done really fast and being able to fill the tables with some test data right away was always a pleasing experience.
As time fled, things started to change. AJAX, Unobtrusive JavaScript, first Single-page applications been gaining popularity, until we arrived at the destination node.js as a daily bread. What caused APIs to be the more and more popular choice not only for consumer apps but for almost everything. For many products, going SPA is a good choice, no matter it still runs on the same JavaScript and all its ugliness is just abstracted with multiple levels on top of it.
So, where it made sense, we were doing simple JSON responses instead of rich Django views, and then this Django REST framework (DRF) happened. It suddenly brought a “djangish” solution to complex API apps and we could start migrating existing HTML apps to API-driven experiences.
But, does it make sense to build a new, greenfield product using Django and DRF? Suddenly, what we do use more or less, is it’s ORM, contrib.auth and contrib.admin. And for the rest, we use DRF. And we hold a huge framework doing mostly nothing, having now two frameworks. Such an approach does feel a bit odd.
Exploring alternatives
I defined some rough expectations. In my case it was: being small/minimal (right, the opposite of Django), still having support for middleware if needed, good to have some schematic approach (OpenAPI), having something like CRUD tooling and/or content management, serving fast simple responses, having some authentication/authorization around. What made me google a lot. TL; DR: I didn’t find a 1:1 replacement, but I did find a solution which made me happy.
Obvious one
There are these obvious choices, like Flask with SQLAlchemy and/or Flask-Admin and/or Flask-RESTful. I’ve been listening about Flask for years. People around me were always telling me this is the best Django replacement if you want to work with something smaller without all that swiss-knife equipment. I believe it. So I decided to not try it out, because I already considered it a good choice.
Less obvious one
There are less obvious choices, like the Falcon Web Framework. I would use it any time while building something quite low-level. It looks like there is a nice falcon-autocrud app which works with SQLAlchemy. What I didn’t like was the release attitude having plenty release candidates. There is this hug built on top of Falcon. What could be a good path to explore. However, I skipped it at this point.
New kids on the block
And then, there are these new kids on the block. Frameworks running with asyncio. Didn’t take too much time to consider Sanic and FastAPI. However, I decided to try out only one. It was FastAPI. And that’s the one, which made me write this article.
FastAPI
Coming from Django, it was a bit weird for me at first. I didn’t expect small frameworks having all stuff loaded. So was interested in combining different libs, or either using some extensions. FastAPI even suggests many approaches and tools in their rich documentation. They don’t try to attach/inherit everything, they just give suggestions to “goes well along” libraries. Like the uvicorn to run the app, Pydantic for managing validation and config. SQLAlchemy if people need ORM, databases to do low-level SQL and such. And yep, did I mention rich documentation?
It doesn’t exactly meet my requirements of having crud/content management tool/library available. But still, it is my winner.
Side by side example in DRF vs FastAPI
To understand the differences, one of the ways how to migrate, it is best to just try something real.
For this purpose, I will create over-simplistic library app containing Author
and Book
models. I won’t go into Django details, just expecting Django experience by readers.
Starting a new project
Django
mkdir drf_bookapp
cd drf_bookapp
python3.8 -m venv env
source env/bin/activate
pip install djangorestframework psycopg2
django-admin startproject drf_bookapp .
./manage.py startapp library
library/models.py
:
from django.db import models
class Author(models.Model):
first_name = models.CharField(max_length=32)
last_name = models.CharField(max_length=32)
bio = models.TextField()
def __str__(self):
return f'{self.first_name} {self.last_name}'
class Book(models.Model):
title = models.CharField(max_length=128)
description = models.TextField()
authors = models.ManyToManyField(Author)
published = models.DateField()
def __str__(self):
return self.title
Editing drf_bookapp/settings.py
and adding two lines into INSTALLED_APPS
, make it look like:
INSTALLED_APPS = [
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.staticfiles',
'rest_framework',
'library',
]
And also editing DATABASES
settings:
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'HOST': 'localhost',
'NAME': 'library_drf',
'USER': 'postgres',
'PASSWORD': 'password'
}
}
Making migration:
./manage.py makemigrations
Migrations for 'library':
library/migrations/0001_initial.py
- Create model Author
- Create model Book
And migrating:
Operations to perform:
Apply all migrations: auth, contenttypes, library, sessions
Running migrations:
Applying contenttypes.0001_initial... OK
Applying contenttypes.0002_remove_content_type_name... OK
Applying auth.0001_initial... OK
Applying auth.0002_alter_permission_name_max_length... OK
Applying auth.0003_alter_user_email_max_length... OK
Applying auth.0004_alter_user_username_opts... OK
Applying auth.0005_alter_user_last_login_null... OK
Applying auth.0006_require_contenttypes_0002... OK
Applying auth.0007_alter_validators_add_error_messages... OK
Applying auth.0008_alter_user_username_max_length... OK
Applying auth.0009_alter_user_last_name_max_length... OK
Applying auth.0010_alter_group_name_max_length... OK
Applying auth.0011_update_proxy_permissions... OK
Applying auth.0012_alter_user_first_name_max_length... OK
Applying library.0001_initial... OK
Applying sessions.0001_initial... OK
Now, we will do just a simple serializer, creating library/serializers.py
:
from rest_framework import serializers
from library.models import Author, Book
class AuthorSerializer(serializers.ModelSerializer):
class Meta:
model = Author
fields = '__all__'
class BookSerializer(serializers.ModelSerializer):
class Meta:
model = Book
fields = '__all__'
and library/views.py
:
from rest_framework import viewsets
from library.models import Author, Book
from library.serializers import AuthorSerializer, BookSerializer
class AuthorViewSet(viewsets.ModelViewSet):
queryset = Author.objects.all()
serializer_class = AuthorSerializer
class BookViewSet(viewsets.ModelViewSet):
queryset = Book.objects.all()
serializer_class = BookSerializer
Finally, updating drf_bookapp/urls.py
to look like this:
from django.contrib import admin
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from library.views import AuthorViewSet, BookViewSet
router = DefaultRouter()
router.register('authors', AuthorViewSet)
router.register('books', BookViewSet)
urlpatterns = [
path('', include(router.urls)),
path('api-auth/', include('rest_framework.urls')),
]
After starting the server with
./manage.py runserver
We should see something like:
FastAPI
Now, achieving similar in FastAPI
mkdir fastapi_bookapp
cd fastapi_bookapp
python3.8 -m venv env
source env/bin/activate
pip install asyncpg fastapi uvicorn sqlalchemy==1.4.15 alembic==1.5.8 python-dotenv
Note: asyncio support in SQLAlchemy is quite fresh, starting from 1.4. Authors consider it “alpha”. It may be me, or there is still some issue here and there, especially working with relationships caused by lazy loading. It may also lack asyncio support for some other database engines. Some people may still prefer synchronous approach even in FastAPI.
Assuming we have a postgres database named library_fastapi
, we create .env
:
DB_URL = 'postgresql+asyncpg://postgres:password@localhost:5432/library_fastapi'
then, database.py
:
import os
from dotenv import load_dotenv
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
load_dotenv('.env')
Base = declarative_base()
engine = create_async_engine(os.environ['DB_URL'], echo=True)
SessionLocal = sessionmaker(
autocommit=False, autoflush=False, bind=engine, class_=AsyncSession)
async def get_db():
db = SessionLocal()
try:
yield db
finally:
await db.close()
Next are models, library/models.py
:
from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, Table
from sqlalchemy.orm import relationship
from database import Base
book_authors = Table('library_book_authors', Base.metadata,
Column('author_id', Integer, ForeignKey('library_author.id')),
Column('book_id', Integer, ForeignKey('library_book.id'))
)
class Author(Base):
__tablename__ = 'library_author'
id = Column(Integer, primary_key=True, index=True)
first_name = Column(String(32))
last_name = Column(String(32))
bio = Column(String)
books = relationship('Book', secondary=book_authors, back_populates='authors')
class Book(Base):
__tablename__ = 'library_book'
id = Column(Integer, primary_key=True, index=True)
title = Column(String(128))
description = Column(String)
published = Column(DateTime(timezone=True))
authors = relationship('Author', secondary=book_authors, back_populates='books')
And, we set up migrations:
alembic init -t async alembic
Now, we need to edit alembic.ini
where we edit the following line: sqlalchemy.url =
- like this, to be empty.
Instead, we modify the beginning of the file alembic/env.py
:
import asyncio
from logging.config import fileConfig
import os
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from sqlalchemy.ext.asyncio import AsyncEngine
from alembic import context
from dotenv import load_dotenv
from database import Base
from library import models
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
load_dotenv('.env')
config.set_main_option('sqlalchemy.url', os.environ['DB_URL'])
# Interpret the config file for Python logging.
# This line sets up loggers basically.
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
target_metadata = Base.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline():
### =============== JUST CONTINUE REST OF THE FAIL AS IS =============== ###
Finally:
PYTHONPATH="." alembic revision --autogenerate -m "initial"
PYTHONPATH="." alembic upgrade head
Remaining is schema , database operations and views. For the sake of this example, we will continue organizing code in a way similar to Django apps. We will need library/views.py
and library/schema.py
and we will also add library/crud.py
.
the library/schema.py
:
from datetime import datetime
from typing import List, Optional
from pydantic import BaseModel, Field
class BaseOrmModel(BaseModel):
class Config:
orm_mode = True
class CreateAuthor(BaseOrmModel):
first_name: str = Field(max_length=32)
last_name: str = Field(max_length=32)
bio: str
class ReadAuthor(CreateAuthor):
id: int
class AuthorsList(BaseOrmModel):
results: List[ReadAuthor]
class CreateBook(BaseOrmModel):
title: str = Field(max_length=128)
description: str
published: datetime
authors: List[int]
class ReadBook(CreateBook):
id: int
authors: Optional[List[ReadAuthor]]
class BooksList(BaseOrmModel):
results: List[ReadBook]
The library/crud.py
:
from typing import Union
from sqlalchemy import select
from sqlalchemy.orm import Session, selectinload
from library import schema
from library.models import Author, Book
async def create_author(db: Session, author: schema.CreateAuthor):
db_author = Author(**author.dict())
db.add(db_author)
await db.commit()
await db.refresh(db_author)
return db_author
async def find_author(db: Session, author_id: int) -> Union[Author, None]:
result = await db.execute(select(Author).where(Author.id==author_id))
db_author = result.one_or_none()
if db_author:
return db_author[0]
return None
async def list_authors(db: Session):
result = await db.execute(select(Author))
return [x[0] for x in result.all()]
async def find_book(db: Session, book_id: int) -> Union[Book, None]:
result = await db.execute(
select(Book).options(selectinload(
Book.authors)).where(Book.id == book_id))
db_book = result.one_or_none()
if db_book:
return db_book[0]
return None
async def list_books(db: Session):
result = await db.execute(select(Book).options(selectinload(Book.authors)))
return [x[0] for x in result.all()]
async def create_book(db: Session, book: schema.CreateBook):
book_dict = book.dict()
author_ids = book_dict['authors']
result = await db.execute(select(Author).where(Author.id.in_(author_ids)))
authors = [x[0] for x in result.all()]
found_ids = [x.id for x in authors]
wrong_ids = list(set(author_ids) - set(found_ids))
if wrong_ids:
raise ValueError(f'The following author IDs are invalid: {wrong_ids}')
book_dict['authors'] = authors
db_book = Book(**book_dict)
db.add(db_book)
await db.commit()
await db.refresh(db_book)
# TODO: seems returning the refreshed object causes some troubles in lazy loading of authors
# thus replacing for now with query
result = await db.execute(
select(Book).options(selectinload(
Book.authors)).filter_by(id=db_book.id))
db_book = result.one_or_none()
if db_book:
return db_book[0]
return None
And the library/views.py
:
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.encoders import jsonable_encoder
from fastapi.responses import JSONResponse
from sqlalchemy.orm import Session
from database import get_db
from library import crud
from library.schema import AuthorsList, CreateAuthor, ReadAuthor, BooksList, CreateBook, ReadBook
class ItemNotFoundException(HTTPException):
def __init__(self) -> None:
super().__init__(status_code=status.HTTP_404_NOT_FOUND, detail='Item not found')
authors_router = APIRouter(
prefix='/authors',
tags=['authors']
)
books_router = APIRouter(
prefix='/books',
tags=['books']
)
@authors_router.get('', response_model=AuthorsList)
async def get_authors(db: Session = Depends(get_db)) -> AuthorsList:
authors = await crud.list_authors(db)
results = [ReadAuthor.from_orm(x) for x in authors]
return AuthorsList(results=results)
@authors_router.post('', response_model=ReadAuthor)
async def post_author(author: CreateAuthor, db: Session = Depends(get_db)) -> ReadAuthor:
db_author = await crud.create_author(db=db, author=author)
return ReadAuthor.from_orm(db_author)
@authors_router.get('{author_id}', response_model=ReadAuthor)
async def get_author(author_id: int, db: Session = Depends(get_db)) -> ReadAuthor:
db_author = await crud.find_author(db, author_id)
if not db_author:
raise ItemNotFoundException
return ReadAuthor.from_orm(db_author)
@books_router.get('', response_model=BooksList)
async def get_books(db: Session = Depends(get_db)) -> BooksList:
books = await crud.list_books(db)
results = [ReadBook.from_orm(x) for x in books]
return BooksList(results=results)
@books_router.post('', response_model=ReadBook)
async def post_book(book: CreateBook, db: Session = Depends(get_db)) -> ReadBook:
try:
db_book = await crud.create_book(db=db, book=book)
except ValueError as exc:
return JSONResponse(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
content=jsonable_encoder({'detail': exc.__str__()}),)
return ReadBook.from_orm(db_book)
@books_router.get('{book_id}', response_model=ReadBook)
async def get_book(book_id: int, db: Session = Depends(get_db)) -> ReadBook:
db_book = await crud.find_book(db, book_id)
if not db_book:
raise ItemNotFoundException
return ReadBook.from_orm(db_book)
Finally, to make everything roll, we will need main.py
:
from fastapi import FastAPI
from library.views import authors_router, books_router
app = FastAPI(
title='SimpleLibraryApp',
description='Example FastAPI Library App',
openapi_tags=[
{
'name': 'authors',
'description': 'Authors'
},
{
'name': 'books',
'description': 'Books'
}
]
)
app.include_router(authors_router)
app.include_router(books_router)
And we will just start it up:
uvicorn --port 8282 --reload main:app
INFO: Uvicorn running on http://127.0.0.1:8282 (Press CTRL+C to quit)
INFO: Started reloader process [26324] using statreload
INFO: Started server process [26326]
INFO: Waiting for application startup.
INFO: Application startup complete.
Finally, opening http://localhost:8282/docs in browser will bring us something like
Conclusion
I did not find a 1:1 Django replacement. The most painful part is something similar to Django admin, if such feature is a product requirement. Choosing different approaches, there is a nice looking app called FastAPI Admin. Which uses TortoiseORM and is directly inspired by Django admin. Haven’t tried yet.