Migrating from fastapi-admin to OpsDeck
The original fastapi-admin library (3,800 GitHub stars) has not had a release since August 2021. It requires Tortoise ORM and Redis, and it is incompatible with SQLAlchemy and modern FastAPI versions. This guide covers the direct migration path.
What you are migrating away from
fastapi-admin has three hard dependencies that do not exist in OpsDeck:
- Tortoise ORM — fastapi-admin is built exclusively on Tortoise ORM. OpsDeck uses async SQLAlchemy 2.x.
- Redis — fastapi-admin requires a running Redis instance for session management. OpsDeck uses itsdangerous-signed session cookies.
- Aerich — the Tortoise ORM migration tool. You will migrate to Alembic.
Step 1: Replace the ORM
fastapi-admin uses Tortoise ORM models:
# fastapi-admin (Tortoise ORM)
from tortoise import fields, models
class User(models.Model):
username = fields.CharField(max_length=50, unique=True)
email = fields.CharField(max_length=200, unique=True)
is_active = fields.BooleanField(default=True)
created_at = fields.DatetimeField(auto_now_add=True)
class Meta:
table = "users"
OpsDeck uses SQLAlchemy declarative models:
# OpsDeck (SQLAlchemy 2.x)
from sqlalchemy import String, Boolean, DateTime
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from datetime import datetime
class Base(DeclarativeBase):
pass
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
username: Mapped[str] = mapped_column(String(50), unique=True)
email: Mapped[str] = mapped_column(String(200), unique=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
Step 2: Replace Redis with signed cookies
fastapi-admin required Redis for session storage:
# fastapi-admin — Redis required
import aioredis
from fastapi_admin.providers.login import UsernamePasswordProvider
redis = await aioredis.create_redis_pool("redis://localhost")
await app.init(
redis=redis,
admin_path="/admin",
...
)
OpsDeck uses signed session cookies — no Redis, no external process:
# OpsDeck — no Redis needed
from opsdeck import OpsDeck
admin = OpsDeck(
app,
engine=engine,
secret_key="your-32-char-secret-key", # Signs session cookies
auth_model=AdminUser,
)
Step 3: Replace the admin setup
fastapi-admin initialization:
# fastapi-admin
from fastapi_admin.app import app as admin_app
from fastapi_admin.providers.login import UsernamePasswordProvider
@app.on_event("startup")
async def startup():
await Tortoise.init(config=TORTOISE_ORM)
await admin_app.init(
admin_path="/admin",
engine=TortoiseEngine(),
providers=[
UsernamePasswordProvider(
admin_model=Admin,
login_logo_url="...",
)
],
redis=redis,
)
OpsDeck initialization:
# OpsDeck
from fastapi import FastAPI
from sqlalchemy.ext.asyncio import create_async_engine
from opsdeck import OpsDeck
app = FastAPI()
engine = create_async_engine("postgresql+asyncpg://user:pass@localhost/db")
admin = OpsDeck(
app,
engine=engine,
secret_key="your-32-char-secret-key",
title="My Admin",
auth_model=AdminUser, # optional — removes auth if omitted
audit_model=AuditLog, # optional — enables audit logging
)
# Register models explicitly
admin.register(User)
admin.register(Product)
# Or auto-discover all models
admin.auto_discover(Base)
Step 4: Replace the admin user model
fastapi-admin defined admin users via Tortoise ORM with a specific mixin:
# fastapi-admin
from fastapi_admin.models import AbstractAdmin
class Admin(AbstractAdmin):
last_login = fields.DatetimeField(description="Last Login", default=datetime.datetime.now)
email = fields.CharField(max_length=200, default="")
...
class Meta:
table = "admin"
OpsDeck uses an AdminUserMixin for SQLAlchemy:
# OpsDeck
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from sqlalchemy import String
from opsdeck.auth.models import AdminUserMixin
class Base(DeclarativeBase):
pass
class AdminUser(AdminUserMixin, Base):
__tablename__ = "admin_users"
id: Mapped[int] = mapped_column(primary_key=True)
username: Mapped[str] = mapped_column(String(100), unique=True)
# password_hash is provided by AdminUserMixin
is_active: Mapped[bool] = mapped_column(default=True)
is_superuser: Mapped[bool] = mapped_column(default=False)
Step 5: Replace Aerich with Alembic
fastapi-admin used Aerich for Tortoise ORM migrations. SQLAlchemy projects use Alembic:
Then in alembic/env.py:
Generate and apply the initial migration:
Step 6: Migrate your data
fastapi-admin and OpsDeck use completely different ORMs, so there is no automatic schema migration. Your data stays in the same database. The migration is only in the application code.
For each Tortoise ORM model:
1. Create the equivalent SQLAlchemy model
2. Ensure the __tablename__ matches your existing table name
3. Map column types: CharField → String, IntField → Integer, BooleanField → Boolean, DatetimeField → DateTime
4. Run alembic upgrade head against your existing database — Alembic will detect the existing tables and only create missing ones
What you gain
After migrating:
- No Redis — simpler infrastructure, one fewer service to run and monitor
- Audit logging — built-in create/update/delete audit trail via
audit_model - Row-level scoping — multi-tenant data isolation via
row_scope - Many-to-many relationships — work correctly out of the box
- Excel export —
.xlsxin addition to CSV (requirespip install openpyxl) - Active maintenance — releases and bug fixes in 2026
Minimum working example after migration
from fastapi import FastAPI
from sqlalchemy import String, Boolean
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from opsdeck import OpsDeck
from opsdeck.auth.models import AdminUserMixin
from opsdeck.audit.models import AuditLog
app = FastAPI()
engine = create_async_engine("sqlite+aiosqlite:///./app.db")
class Base(DeclarativeBase):
pass
class AdminUser(AdminUserMixin, Base):
__tablename__ = "admin_users"
id: Mapped[int] = mapped_column(primary_key=True)
username: Mapped[str] = mapped_column(String(100), unique=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
is_superuser: Mapped[bool] = mapped_column(Boolean, default=False)
class AdminAuditLog(AuditLog, Base):
__tablename__ = "admin_audit_log"
id: Mapped[int] = mapped_column(primary_key=True)
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
username: Mapped[str] = mapped_column(String(50), unique=True)
email: Mapped[str] = mapped_column(String(200), unique=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
def __admin_repr__(self) -> str:
return self.username
admin = OpsDeck(
app,
engine=engine,
secret_key="change-me-in-production",
title="My Admin",
auth_model=AdminUser,
audit_model=AdminAuditLog,
)
admin.auto_discover(Base)
@app.on_event("startup")
async def startup():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
Getting help
If you run into issues during migration, open an issue on GitHub with the label migration. Include your original Tortoise ORM model definitions and what you have converted them to.