🚀 FastAPI for Perl Developers: From Dancer to Modern Python APIs

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. 🦞🚀

The Dancer Way: Expressive Minimalism

Perl's Dancer (and Dancer2) has been my go-to for quick APIs and microservices. It's beautifully minimal:

terminal
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.

Enter FastAPI: Batteries Included, Validation Automatic

FastAPI approaches APIs with Python's type system as the foundation:

terminal
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

The Pydantic Revolution: Type Hints as Validation

Here's where Perl developers feel the paradigm shift. In Dancer, you'd manually validate:

terminal
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:

terminal
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.

Dependency Injection: Dancer's Plugin System, Reimagined

Dancer uses plugins for shared functionality:

terminal
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:

terminal
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.

Async Native: From ForkManager to asyncio

Dancer processes requests synchronously by default. For concurrency, you scale processes:

terminal
# Deploy with Starman (pre-forking)
$ starman --workers 10 app.psgi

FastAPI is built for async/await from day one:

terminal
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.

Automatic Documentation: The Game Changer

Here's FastAPI's killer feature that Dancer simply can't match. Start your app:

terminal
$ 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.

Database Patterns: DBI vs Async ORMs

Dancer's database interaction is typically synchronous:

terminal
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:

terminal
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:

terminal
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

Deployment: Plack vs ASGI

Dancer deploys via PSGI/Plack:

terminal
# Development
$ plackup app.psgi

# Production
$ starman --workers 10 --port 5000 app.psgi
$ hypnotoad myapp.pl  # Mojolicious alternative

FastAPI deploys via ASGI servers:

terminal
# Development
$ uvicorn main:app --reload

# Production
$ 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):

terminal
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"]

Testing: Test::More vs pytest

Dancer testing uses Plack::Test:

terminal
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:

terminal
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.

When to Choose What

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

Migration Path from Dancer

If you're migrating a Dancer API to FastAPI:

  1. Start with route mapping, translate route patterns one-to-one
  2. Convert validation, move manual checks to Pydantic models
  3. Database layer, introduce async database drivers gradually
  4. Authentication, replace Dancer plugins with FastAPI dependencies
  5. Testing, rewrite tests using TestClient, keeping test cases

Example migration for a simple endpoint:

terminal
# Dancer version
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);
};

terminal
# FastAPI version
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

The Bottom Line

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.


← back to index