Build a python app in 2026

I started to develop a web app to manage the household maintenance about two weeks ago. For prototyping I always chose python, but what was the landscape of the python web framework in 2026?

Clearly, there are Django, FastAPI, Flask, also new comers such as Litestar, and Sanic. I considered the performance was quite essential even for prototyping as it extended the runway before we had to refactor the architecture, thus my short list will be FastAPI, Litestar and Sanic.

Exploration

I tried FastAPI a while ago, it leveraged the Pydantic for type-safety, and Starlette for ASGI capabilities. It was quite performant, with massive community support. I was a little annoyed by the push of SQLModel, due to that benefit of welding the models from pydantic and SQLAlchemy could not justify the complexity.

I played with the Litestar about a month ago. It was more community driven as FastAPI was mostly a monologure. It was also more opinionated about the best practice: using the repository to encapsulate the data access, and DTO for type-safety. I think they’re both neat ideas, which I might end up implementning poorly; but these patterns were a little bit overwhelming in the prototyping phase with excessive cognition overhead.

I then fell in love with Sanic, a micro framework inspired by Flask: the similar syntax for routing, middleware, blueprint, and signals. The sanic_ext provides the validate decorator for type check on the payloads, and the SQLAlchemy integration is dead simple. One more thing, the async is first-class citizen. Let’s go asynchronous all the way!

Lessons learned

Switching to async is not simply adding async, await keyword to the control flows. Here are some lesson I learned.

Async testing is complicated

Why we need the async testing? Because I use the SQLAlchemy’s AsyncSession and async database driver in the production code, and use the same session to scaffold the test fixtures.

The pytest-asyncio supports async test if decorated.

@pytest.mark.asyncio
async def test_health_check(test_app):
    pass

Though we need to pay attention to the scope of event loop under the hood: pytest-asyncio provides one asyncio event loop for each test function by default. If the AsyncSession’s underlying connection pool is reused across the test function, it will be detached and attached to a new even loop, and results in RuntimeError: Event loop is closed. The proper way to instantiate the engine is to use NullPool as:

engine = create_async_engine(
    os.environ["DATABASE_URL"], 
    poolclass=NullPool)

Use create_app factory method

Sanic supports running Sanic app instance as sanic webapp:app or invoking the factory method as sanic webapp:create_app. Use the latter please. The factory method allows us to dynamically create a Sanic app with dependency injection:

@pytest_asyncio.fixture(scope="function")
async def test_app(session):
    """Overrides the Sanic application dependency structure for tests."""
    app = create_app()

    @app.on_request
    async def inject_test_session(request):
        request.ctx.session = session

    yield app

The session binds the engine created with NullPool to use the correct event loop scope.

Use PostgresSQL

I used to prefer SQLite for prototyping as the in memory database was quite handy for unit testing. I revisited this and found we could, and should use PostgresSQL for development. It supports SAVEPOINT to rollback the change in the nested transactions, so we can commit the change with database constraint integrity check in the test, but leave no side effects in the database to interfere other test functions. 1

@pytest_asyncio.fixture
async def session():
    async with engine.connect() as connection:
        async with connection.begin() as transaction:
            session = async_session_factory(
                bind=connection, join_transaction_mode="create_savepoint"
            )
            yield session

            await session.close()
            await transaction.rollback()

Automate database migration

I use Alembic to manage the database schema migration. The async driver requires async template as alembic init -t async migration. We can add the database schema migration as pytest fixture:

@pytest.fixture(scope="session", autouse=True)
def migrate_test_database():
    ini_path = os.path.join(os.path.dirname(__file__), "../alembic.ini")
    config = Config(ini_path)
    command.upgrade(config, "head")

    yield

With pytest-env, we can setup the DATABASE_URL for the test environment in pyproject.toml:

[tool.pytest_env]
DATABASE_URL = "postgresql+asyncpg://postgres:postgres@localhost:5432/pecan_ut"

Session management

It is tempting to manage the session with session.begin() context manager, but it hardly works as SQLAlchemy opens a transaction implicitly for query, the succeeding session.begin() would fail for the active transaction.

Footnotes

  1. The SAVEPOINT will consume the primary key though.