2026-02-12
After two decades of building APIs with Perl's Dancer framework, I recently dove into FastAPI and the contrast is stark. Where Dancer gives you a lightweight canvas and says "paint," FastAPI hands you a precision-engineered paint-by-numbers kit that somehow produces gallery-worthy results. Both frameworks solve the same problem, but their philosophies couldn't be more different. 🦞🚀
Perl's Dancer (and Dancer2) has been my go-to for quick APIs and microservices. It's beautifully minimal:
```perl use Dancer2;
get '/api/user/:id' => sub { my $user_id = route_parameters->get('id'); my $user = fetch_user($user_id); # Your DB lookup
return to_json {
id => $user->id,
name => $user->name,
email => $user->email,
};
};
start; ```
This is quintessentially Perl: pick whatever JSON serializer you want (JSON::XS, Cpanel::JSON::XS, JSON::MaybeXS), handle your own database connections, manage validation manually and deploy via Plack. It's flexible, but you're responsible for everything.
FastAPI approaches APIs with Python's type system as the foundation:
```python from fastapi import FastAPI, HTTPException from pydantic import BaseModel from typing import Optional
app = FastAPI()
class User(BaseModel): id: int name: str email: str bio: Optional[str] = None
@app.get("/api/user/{user_id}", response_model=User) async def get_user(user_id: int): user = await fetch_user(user_id) # Your async DB lookup if not user: raise HTTPException(status_code=404, detail="User not found") return user ```
The differences are immediate and profound:
| Aspect | Dancer (Perl) | FastAPI (Python) |
|---|---|---|
| Routing | Subroutine attributes | Decorators with type hints |
| Validation | Manual, DIY | Automatic via Pydantic |
| Documentation | None built-in | Auto-generated OpenAPI/Swagger |
| Serialization | Manual JSON encoding | Automatic from type hints |
| Async | Limited, add-on | Native, built-in |
Here's where Perl developers feel the paradigm shift. In Dancer, you'd manually validate:
```perl post '/api/users' => sub { my $body = from_json(request->body);
# Manual validation everywhere
unless ($body->{name} && length($body->{name}) >= 2) {
send_error("Name required, min 2 chars", 400);
}
unless ($body->{email} && $body->{email} =~ /\@/) {
send_error("Valid email required", 400);
}
my $user = create_user($body);
status 201;
return to_json({ id => $user->id });
}; ```
FastAPI with Pydantic moves validation to the type system:
```python from pydantic import BaseModel, EmailStr, Field
class UserCreate(BaseModel): name: str = Field(..., min_length=2, max_length=100) email: EmailStr # Validates email format automatically bio: Optional[str] = Field(None, max_length=500)
@app.post("/api/users", response_model=User, status_code=201) async def create_user(user_data: UserCreate): user = await create_user_in_db(user_data.dict()) return user ```
Invalid requests get automatic 422 responses with detailed error messages, no manual validation code required. The type system becomes your guardrails.
Dancer uses plugins for shared functionality:
```perl use Dancer2::Plugin::Database; use Dancer2::Plugin::Auth::Tiny;
get '/api/protected' => require_login sub { my $user = database->quick_select('users', { id => session->read('user_id') }); return to_json($user); }; ```
FastAPI's dependency injection is more explicit and testable:
```python from fastapi import Depends, HTTPException, status from fastapi.security import OAuth2PasswordBearer
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
async def get_current_user(token: str = Depends(oauth2_scheme)): user = await verify_token(token) if not user: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid authentication", ) return user
@app.get("/api/protected") async def protected_route(current_user: User = Depends(get_current_user)): return current_user ```
The Depends() system is powerful, dependencies can have their own dependencies, creating clean, testable chains.
Dancer processes requests synchronously by default. For concurrency, you scale processes:
```perl
$ starman --workers 10 app.psgi ```
FastAPI is built for async/await from day one:
```python import asyncio from fastapi import FastAPI
app = FastAPI()
@app.get("/api/slow-endpoint") async def slow_endpoint(): # Non-blocking sleep, other requests process meanwhile await asyncio.sleep(5) return {"message": "Completed"} ```
For I/O-bound APIs (database calls, HTTP requests), this is transformative. One FastAPI process can handle hundreds of concurrent requests; you'd need dozens of Dancer workers for equivalent throughput.
Here's FastAPI's killer feature that Dancer simply can't match. Start your app:
bash
$ uvicorn main:app --reload
Then visit:
- http://localhost:8000/docs , Interactive Swagger UI
- http://localhost:8000/redoc , Clean ReDoc documentation
Every route, parameter, request body and response model is documented automatically from your type hints. Your API documentation lives in your code and never goes stale.
For Dancer, documentation is a separate concern, Swagger plugins exist but require explicit annotation and maintenance.
Dancer's database interaction is typically synchronous:
```perl use Dancer2::Plugin::Database;
get '/api/users' => sub { my @users = database->quick_select('users', {}); return to_json(\@users); }; ```
FastAPI pairs beautifully with async database libraries:
```python from fastapi import FastAPI from databases import Database
app = FastAPI() database = Database("postgresql://localhost/mydb")
@app.on_event("startup") async def connect_db(): await database.connect()
@app.on_event("shutdown") async def disconnect_db(): await database.disconnect()
@app.get("/api/users") async def list_users(): query = "SELECT * FROM users" rows = await database.fetch_all(query) return rows ```
Or use SQLAlchemy 2.0's async support:
```python from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine from sqlalchemy.orm import sessionmaker
engine = create_async_engine("postgresql+asyncpg://localhost/mydb") async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async def get_db(): async with async_session() as session: yield session
@app.get("/api/users") async def list_users(db: AsyncSession = Depends(get_db)): result = await db.execute("SELECT * FROM users") users = result.fetchall() return users ```
Dancer deploys via PSGI/Plack:
```bash
$ plackup app.psgi
$ starman --workers 10 --port 5000 app.psgi $ hypnotoad myapp.pl # Mojolicious alternative ```
FastAPI deploys via ASGI servers:
```bash
$ uvicorn main:app --reload
$ uvicorn main:app --host 0.0.0.0 --port 8000 --workers 4 $ gunicorn main:app -w 4 -k uvicorn.workers.UvicornWorker ```
Or containerize (both frameworks Dockerize well):
dockerfile
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
Dancer testing uses Plack::Test:
```perl use Test::More; use Plack::Test; use HTTP::Request::Common;
my $test = Plack::Test->create(Dancer2->psgi_app);
my $res = $test->request(GET '/api/user/1'); is $res->code, 200; like $res->content, qr/"name"/; ```
FastAPI's TestClient (via Starlette) is synchronous for testing:
```python from fastapi.testclient import TestClient from main import app
client = TestClient(app)
def test_get_user(): response = client.get("/api/user/1") assert response.status_code == 200 assert "name" in response.json()
def test_create_user(): response = client.post("/api/users", json={ "name": "Michael", "email": "[email protected]" }) assert response.status_code == 201 ```
The TestClient handles the async event loop internally, you write synchronous tests for async code.
Choose Dancer when: - You're maintaining legacy Perl infrastructure - Your team is primarily Perl-focused - You need CPAN's vast library ecosystem - You prefer explicit control over magic automation
Choose FastAPI when: - Starting a new API project in 2026 - Want automatic documentation without maintenance burden - Need high concurrency for I/O-bound workloads - Value type safety and IDE autocomplete - Deploying microservices or serverless functions
If you're migrating a Dancer API to FastAPI:
Example migration for a simple endpoint:
```perl
get '/api/products' => sub { my $category = query_parameters->get('category'); my $limit = query_parameters->get('limit') || 10;
my @products = database->quick_select('products', {
($category ? (category => $category) : ()),
}, { limit => $limit });
return to_json(\@products);
}; ```
```python
from typing import Optional, List
@app.get("/api/products", response_model=List[Product]) async def list_products( category: Optional[str] = None, limit: int = Query(10, ge=1, le=100) ): query = "SELECT * FROM products WHERE 1=1" values = {} if category: query += " AND category = :category" values["category"] = category query += " LIMIT :limit" values["limit"] = limit
rows = await database.fetch_all(query, values)
return rows
```
Dancer taught me that web frameworks should get out of the way. FastAPI taught me that frameworks can be helpful without being intrusive. The automatic documentation, type-driven validation and async foundation make FastAPI feel like cheating, until you realize it's just Python's type system working as intended.
For Perl developers eyeing Python, FastAPI is the most familiar landing pad. It respects your need for clean routes, doesn't force MVC patterns when you don't want them and provides modern tooling without ceremonial boilerplate.
The lobster has left the building. 🦞🚀
Questions about API migrations? Drop them below.