Why I Picked Starlette Over FastAPI for This Site

Why I Picked Starlette Over FastAPI for This Site

I use FastAPI every day at work. It's great. I've built production APIs with it, I know its patterns well, and I recommend it to people all the time. But when I started building this portfolio, I went with Starlette instead. Here's why.

What is Starlette anyway?

If you've never heard of Starlette, here's the deal: it's a lightweight ASGI framework. ASGI is the async successor to WSGI - it's what lets Python web apps handle async/await properly.

And here's the thing most people don't know: FastAPI is literally built on top of Starlette. When you use FastAPI, you're using Starlette under the hood. FastAPI adds Pydantic validation, automatic OpenAPI docs, and dependency injection on top of Starlette's routing and request handling.

So when you learn Starlette, you're learning the foundation that FastAPI uses. It's not a competing framework - it's the layer beneath.

Think of it like this: Starlette is a chassis and engine. FastAPI is a fully-loaded car with power steering, cruise control, backup cameras, and a fancy dashboard. If you're driving cross-country with passengers, you want all those features. But if you're just cruising around town solo? Sometimes the simpler vehicle is easier to handle and repair.

I actually went and looked at the FastAPI source code when I was making this decision. In fastapi/routing.py, you'll find lines like from starlette.routing import Route and from starlette.responses import Response. The whole thing is Starlette components wrapped in additional functionality. Understanding this relationship completely changed how I think about both frameworks.

FastAPI is overkill for a portfolio

Think about what FastAPI gives you:

  • Automatic OpenAPI documentation: Generates Swagger UI and ReDoc automatically. Super useful for APIs that other developers will consume.

  • Request/response validation with Pydantic: Validates incoming JSON, coerces types, returns nice error messages.

  • Dependency injection system: Lets you share database connections, auth logic, etc. across endpoints.

Cool features! I use all of them at work. But for a site that's mostly just serving HTML pages? I don't need any of that.

I'm not building an API that other people will consume. There's no Swagger UI to show anyone. My 'validation' is 'does the template render?' I don't have complex dependencies to inject.

All I really need is: receive request, get some data, render a template, return HTML. That's exactly what Starlette does.

What Starlette looks like

Here's my entire blog page route:

async def blog_page(request: Request) -> Response:
    posts = await get_posts()
    return templates.TemplateResponse(
        request, 'blog.html', {'posts': posts}
    )

No decorators. No magic. Just a function that takes a request and returns a response. I can look at this and immediately understand what it does.

Routing is done separately:

routes = [
    Route('/', homepage),
    Route('/blog', blog_page),
    Route('/blog/{slug}', blog_post_page),
    Mount('/static', StaticFiles(directory='static')),
]

app = Starlette(routes=routes)

It's explicit. All my routes are in one place. I can see the entire URL structure of my site at a glance.

Compare this to the equivalent FastAPI version:

@app.get("/")
async def homepage(request: Request):
    # ...

@app.get("/blog")
async def blog_page(request: Request):
    # ...

@app.get("/blog/{slug}")
async def blog_post_page(request: Request, slug: str):
    # ...

This FastAPI code is fine, but the routes are scattered across your file. Want to see all your URLs? You have to read through the entire file looking for decorators. In Starlette, it's just one list.

This matters more than you'd think. When I'm debugging a 404 or trying to remember the exact URL pattern for something, I open my routes file and see everything instantly. No hunting, no searching, just a clear map of my entire site structure.

Middleware is straightforward

Need to add middleware? It's just a class:

class TimingMiddleware:
    def __init__(self, app):
        self.app = app

    async def __call__(self, scope, receive, send):
        start = time.time()
        await self.app(scope, receive, send)
        duration = time.time() - start
        print(f'Request took {duration:.2f}s')

app = Starlette(routes=routes)
app = TimingMiddleware(app)

You can see exactly what's happening. The middleware wraps the app, does something before the request, calls the app, does something after. No magic.

Here's a more practical example - I wrote middleware to add security headers to all responses:

class SecurityHeadersMiddleware:
    def __init__(self, app):
        self.app = app

    async def __call__(self, scope, receive, send):
        if scope['type'] != 'http':
            await self.app(scope, receive, send)
            return

        async def send_with_headers(message):
            if message['type'] == 'http.response.start':
                headers = message.get('headers', [])
                headers.append((b'X-Content-Type-Options', b'nosniff'))
                headers.append((b'X-Frame-Options', b'DENY'))
                headers.append((b'X-XSS-Protection', b'1; mode=block'))
                message['headers'] = headers
            await send(message)

        await self.app(scope, receive, send_with_headers)

When I first wrote this, I had to understand what scope, receive, and send actually are. That's ASGI's interface - the raw protocol that all these frameworks are built on. scope contains request metadata, receive is how you get the request body, and send is how you send the response.

Understanding this made me way better at debugging production issues. When something weird happens in FastAPI, I can drop down to the ASGI level and figure out what's going on. Before learning Starlette, this layer was just mysterious magic.

Testing is refreshingly simple

One thing I really appreciate about Starlette is how straightforward testing is. No special test client, no weird magic - just plain async Python tests.

Starlette provides TestClient from starlette.testclient, which is actually just a wrapper around the excellent httpx library. Your tests look like this:

from starlette.testclient import TestClient

def test_homepage():
    client = TestClient(app)
    response = client.get('/')
    assert response.status_code == 200
    assert 'Ben Purdy' in response.text

That's it. No special decorators, no pytest plugins to install, no test fixtures to understand. Just make a client, make requests, check responses.

What I really like is that because Starlette doesn't do magic, your tests don't have to either. When I test a route, I'm testing exactly what I wrote - a function that takes a request and returns a response. There's no hidden validation layer or dependency injection system that might behave differently in tests.

And if you want to test your handlers directly without HTTP? You can just call them:

async def test_blog_page_directly():
    request = Request({'type': 'http', 'path': '/blog'})
    response = await blog_page(request)
    assert response.status_code == 200

It's Python. It's async. It's testable. No framework magic required.

Real-world patterns I've developed

After building this site with Starlette, I've developed some patterns that I use everywhere. Here are a few that might help if you're considering Starlette.

Error handling

One thing you'll need to handle yourself is error pages. In FastAPI, you can use exception handlers with decorators. In Starlette, you write middleware or use the built-in exception handlers. Here's what I do:

from starlette.exceptions import HTTPException
from starlette.middleware.errors import ServerErrorMiddleware

async def not_found(request: Request, exc: HTTPException) -> Response:
    return templates.TemplateResponse(
        request,
        'errors/404.html',
        {'url': request.url.path},
        status_code=404
    )

async def server_error(request: Request, exc: Exception) -> Response:
    # Log the error for debugging
    print(f"Server error: {exc}")
    return templates.TemplateResponse(
        request,
        'errors/500.html',
        status_code=500
    )

app = Starlette(
    routes=routes,
    exception_handlers={
        404: not_found,
        500: server_error,
    }
)

Clean, explicit, and you have complete control over what the error pages look like.

Template context processors

Need to add something to every template context? I created a simple decorator:

def with_base_context(func):
    async def wrapper(request: Request) -> Response:
        # Get the response from the handler
        context = await func(request)

        # Add common context
        context['current_year'] = datetime.now().year
        context['site_name'] = 'Ben Purdy'

        return context
    return wrapper

@with_base_context
async def blog_page(request: Request) -> dict:
    posts = await get_posts()
    return {'posts': posts}

This is simpler than context processors in Django or Flask - it's just a decorator that modifies the context dict.

Background tasks

FastAPI has BackgroundTasks built in. Starlette doesn't, but you don't need it. Just use Python's asyncio:

import asyncio

async def send_email(to: str, subject: str, body: str):
    # Simulate sending email
    await asyncio.sleep(1)
    print(f"Sent email to {to}")

async def contact_form(request: Request) -> Response:
    form = await request.form()

    # Fire and forget the email task
    asyncio.create_task(send_email(
        to=form['email'],
        subject='Thanks for contacting me',
        body='I\'ll get back to you soon!'
    ))

    return RedirectResponse('/thanks', status_code=303)

No special framework feature needed. It's just async Python doing what async Python does.

Request lifecycle hooks

Sometimes you want to do something before or after every request. Here's middleware that adds request timing to all responses:

class RequestTimingMiddleware:
    def __init__(self, app):
        self.app = app

    async def __call__(self, scope, receive, send):
        if scope['type'] != 'http':
            await self.app(scope, receive, send)
            return

        start_time = time.time()

        async def send_with_timing(message):
            if message['type'] == 'http.response.start':
                duration = time.time() - start_time
                headers = list(message.get('headers', []))
                headers.append((
                    b'X-Process-Time',
                    f'{duration:.4f}'.encode()
                ))
                message['headers'] = headers
            await send(message)

        await self.app(scope, receive, send_with_timing)

Now every response has an X-Process-Time header showing how long it took to generate. Super useful for debugging slow pages.

The real reason: learning

Honestly? The biggest reason I chose Starlette was educational.

At work I use FastAPI and I just... use it. I know the patterns, I know the decorator syntax, I know how to inject dependencies. But I didn't really understand what was happening underneath.

What is an ASGI app, really? How does middleware work? What's in a Request object? How do templates actually get rendered?

Building something with Starlette forced me to understand these things. There's no magic to hide behind. When something didn't work, I had to actually understand the HTTP request/response cycle to debug it.

Now when I use FastAPI, I understand what it's doing for me. I know what's happening when I add a decorator or inject a dependency. That understanding makes me better at using FastAPI too.

Common gotchas and how to handle them

Let me save you some time by sharing the things that tripped me up when I first started with Starlette.

Path parameters aren't automatically converted

In FastAPI, if you write async def handler(item_id: int), FastAPI validates and converts the path parameter to an integer. Starlette doesn't do this:

# This gives you a string, not an int
async def project_detail(request: Request) -> Response:
    project_id = request.path_params['id']  # This is a string!
    # You need to convert it yourself
    try:
        project_id = int(project_id)
    except ValueError:
        raise HTTPException(404)

At first this annoyed me. Then I realized: it's one less thing to learn. I know how to convert strings to integers. I don't need to learn a framework's type coercion rules.

You handle your own validation

FastAPI's Pydantic integration is magic. Post a JSON body and it's automatically validated and parsed. In Starlette, you do it yourself:

async def create_post(request: Request) -> Response:
    data = await request.json()

    # Validate manually
    if 'title' not in data:
        return JSONResponse(
            {'error': 'title is required'},
            status_code=400
        )

    if not isinstance(data['title'], str) or len(data['title']) < 1:
        return JSONResponse(
            {'error': 'title must be a non-empty string'},
            status_code=400
        )

    # Now use the data
    post = await create_blog_post(data['title'], data.get('content'))
    return JSONResponse({'id': post.id})

You can still use Pydantic if you want - just import it and use it manually:

from pydantic import BaseModel, ValidationError

class CreatePostRequest(BaseModel):
    title: str
    content: str

async def create_post(request: Request) -> Response:
    try:
        data = CreatePostRequest(**await request.json())
    except ValidationError as e:
        return JSONResponse({'errors': e.errors()}, status_code=400)

    post = await create_blog_post(data.title, data.content)
    return JSONResponse({'id': post.id})

Same validation, you just wire it up yourself.

Static files need explicit mounting

FastAPI serves your static files automatically if you set it up. Starlette requires you to explicitly mount them:

from starlette.staticfiles import StaticFiles

routes = [
    # Your regular routes
    Route('/', homepage),
    # Mount static files - order matters!
    Mount('/static', StaticFiles(directory='static'), name='static'),
]

Put the Mount at the end of your routes list. If you put it first, it might catch routes you meant for your handlers.

Request body consumption

This one is subtle. The request body can only be read once. If you try to read it twice, the second time gets nothing:

async def handler(request: Request) -> Response:
    body1 = await request.body()  # This works
    body2 = await request.body()  # This is empty!

If you need the body multiple times, save it to a variable. This matters for middleware that needs to inspect the request body.

Performance considerations

One question I get asked: "Is Starlette slower than FastAPI?"

No. Remember, FastAPI is built on Starlette. The core request/response handling is identical. FastAPI adds validation and serialization overhead, but it's minimal and only happens if you're using those features.

For HTML-rendered sites like mine, there's basically no performance difference. The bottleneck is template rendering and database queries, not the framework.

I ran some informal benchmarks. With a simple "hello world" endpoint:

  • Starlette: ~20,000 requests/second
  • FastAPI: ~19,000 requests/second

The difference is noise. And in production with real work being done? You won't notice it.

What I do notice is that my app feels snappier during development. Starlette has less code to import and initialize, so the dev server starts faster. Not a huge deal, but it's nice.

Migration path

Here's something I don't see talked about enough: you can migrate between Starlette and FastAPI pretty easily because they share the same foundation.

Say I built this site with Starlette, and later I want to add a JSON API for a mobile app. I could add FastAPI for just those routes:

from starlette.applications import Starlette
from starlette.routing import Route, Mount
from fastapi import FastAPI

# My existing Starlette app
starlette_routes = [
    Route('/', homepage),
    Route('/blog', blog_page),
    # ... other HTML routes
]

# New FastAPI app for the API
api = FastAPI()

@api.get('/api/posts')
async def get_posts_api():
    posts = await get_posts()
    return [{'title': p.title, 'slug': p.slug} for p in posts]

# Combine them
app = Starlette(routes=[
    *starlette_routes,
    Mount('/api', api),  # Mount FastAPI under /api
])

Now I get Starlette for my HTML pages and FastAPI for my API endpoints. Best of both worlds.

Or I could go the other way - start with FastAPI and drop down to Starlette for specific routes that need more control.

When to use which

Use FastAPI when:
- You're building an API others will consume
- You want automatic documentation
- You need request/response validation
- You're working on a team and want conventions
- You're in production and want the ecosystem (Depends, BackgroundTasks, etc.)

Use Starlette when:
- You're building a simple website or HTML app
- You want to understand how ASGI frameworks work
- You don't need the extra features FastAPI provides
- You want maximum control with minimal abstraction

For most projects, especially team projects, I'd recommend FastAPI. Its conventions and documentation features are too useful to give up.

But for a solo portfolio site where I control everything? Starlette is perfect. It's exactly as much framework as I need, and no more.

Send a Message