Skip to content

Coming from FastAPI, Django or Flask

Translation tables and side-by-side code for developers arriving from FastAPI, Django or Flask — what transfers unchanged, what has a different name, and which habits do not survive the move.

Almost everything you know transfers. This page is about the small part that does not, so you can stop looking for it.

Pick the section for where you are coming from; the last two sections apply to everybody.

The request layer will feel familiar immediately: typed parameters, Pydantic validation, async handlers, generated OpenAPI. The differences are mostly naming, plus one real change — the handler signature.

FastAPISillo
FastAPI()SilloApp()
APIRouter(prefix="/v1")Router(prefix="/v1")
app.include_router(r)app.mount_router(r) — the prefix lives on the Router
def f(item: Item) for a body@app.post(..., request_model=Item)
response_model=Modelresponse_model=Model (plus response_model_many=True for lists)
Depends(fn)Depend(fn)
Query, Path, Header, Cookie, Form, Filethe same names, imported from sillo
Request as a parameterrequest and response, always the first two parameters
JSONResponse({...}, status_code=201)response.json({...}, status_code=201)
HTTPException(status_code=404, detail=...)HTTPException(detail=..., status=404) from sillo.exceptions
@app.on_event("startup") / lifespan@app.on_startup and @app.on_shutdown
Security(...), OAuth2PasswordBearerauth=useAuth(...) on the route
BackgroundTasksbackground work, queues and a scheduler
TestClientTestClient / AsyncTestClient from sillo.testclient
SQLAlchemy, Alembic, python-jose, passlibfirst-party: Record, migrations, JWT, hashing

The same endpoint in both:

# FastAPI
from fastapi import FastAPI, Depends
from pydantic import BaseModel
app = FastAPI()
class CreateProject(BaseModel):
name: str
@app.post("/teams/{team_id}/projects", status_code=201)
async def create(team_id: int, body: CreateProject, db=Depends(get_db)):
return {"team_id": team_id, "project": body.model_dump()}
# Sillo
from sillo import SilloApp, Depend
from pydantic import BaseModel
app = SilloApp()
class CreateProject(BaseModel):
name: str
@app.post("/teams/{team_id}/projects", request_model=CreateProject)
async def create(request, response, team_id: int, project: CreateProject, db=Depend(get_db)):
return response.json(
{"team_id": team_id, "project": project.model_dump()},
status_code=201,
)

Two things to notice. The body model is declared on the decorator rather than inferred from a parameter’s type — Sillo does not guess which parameter is the body. And request and response come first, always, rather than being injected by type annotation.

Where to go next: RoutingHandlersValidation. Roughly an hour, and you will be productive.

The ORM will feel like home — Tortoise borrows Django’s query API on purpose. The application layer will not: there is no settings module, no urls.py, no app registry, and handlers are async.

DjangoSillo
settings.pya Config subclass — a Pydantic model, validated at startup
os.environ / django-environ.env loading, typed through the same Config
urls.py, path(), include()route decorators, and Router for grouping
manage.pythe sillo CLI
makemigrations / migratesillo db:make / sillo db:migrate (plus db:plan, db:rollback, db:status)
models.Modelsillo.record.Model
Model.objects.filter(...)Model.filter(...) — no manager in between
Q, F, name__icontainsQ, F, name__icontains — unchanged
select_related / prefetch_relatedeager loading
MIDDLEWARE listapp.use(fn) and BaseMiddleware
django.contrib.authfirst-party auth, users, permissions and groups
@login_required, @permission_requiredauth=useAuth(...) on the route
django.contrib.adminthe built-in admin (it becomes warder in 1.0)
Django templatesJinja templating, or Inertia, or JSON
DRF serializersPydantic models
Celery + beatqueues, jobs and the scheduler, first-party
Django ChannelsWebSockets, with channels and groups built in
startappnothing — organise modules however you like

The largest adjustment is not the ORM, it is that there is no project skeleton the framework insists on. sillo-start gives you a working application to copy, but nothing scans for an apps.py or requires a particular directory name. See Project Structure for the layout the starter uses and why.

The second adjustment is async. Django’s ORM has a sync core with async wrappers; Record is async all the way down, so every query is awaited:

# Django
posts = Post.objects.filter(author=user).select_related("author")[:10]
# Sillo
posts = await Post.filter(author=user).select_related("author").limit(10)

Where to go next: ConfigurationRoutingthe ORM manual. The ORM is the part you will read fastest.

You are trading a small core plus extensions for a large core. The routing will feel similar; the request object is the main day-one difference.

FlaskSillo
Flask(__name__)SilloApp()
@app.route("/", methods=["GET"])@app.get("/")
BlueprintRouter
the request globalrequest, passed to the handler
grequest.state
jsonify({...})response.json({...})
abort(404)raise HTTPException(detail="...", status=404)
@app.before_requestmiddleware via app.use
app.configa Config model
Flask-SQLAlchemy + Flask-MigrateRecord and sillo db:*
Flask-Loginauthentication and sessions
Flask-Cachingcaching
Flask-WTF / marshmallowPydantic and request_model=
Celeryqueues and jobs
Gunicorn + wsgi.pyuvicorn (or granian) + an ASGI app

The important one is the request global. Flask binds request to the current context so any function deep in your call stack can reach it; Sillo passes it explicitly as a parameter and does not provide a global equivalent. Functions that need request data take it as an argument, or receive it through dependency injection. This is more typing and considerably less debugging.

Where to go next: RoutingRequest InformationSending Responses.

Regardless of where you are arriving from.

Synchronous I/O in a handler. requests.get(...), a sync database driver, time.sleep, a blocking file read of any size — each one stops the event loop and every concurrent request behind it. This is the single most common production surprise. Concurrency covers the thread pool and when to reach for it.

Reaching for the request from anywhere. There is no thread-local request, no current_app, no g visible from an arbitrary module. Pass the request object, or declare a dependency.

Import-time side effects. Registering things by importing a module — Django apps, Flask extensions bound at import — is not how anything here works. Routes, middleware, startup hooks and jobs are all registered by calling something on the app.

Config as a module of globals. Config is a validated Pydantic model. A missing or malformed value fails at startup with a message naming the field, rather than at 3am with an AttributeError in a worker.

Assuming migrations are magic. db:make writes a migration from your model changes and db:plan shows you what it would run. Read the plan. See Migrations.

You do not need the whole manual. Pick the shape of what you are building:

And What’s in the Box if you want to know what you just installed before you start using it.