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: DRF API

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 FastAPI

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.

Tags