Skip to content

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:

pip install alembic
alembic init alembic

Then in alembic/env.py:

from your_app.models import Base
target_metadata = Base.metadata

Generate and apply the initial migration:

alembic revision --autogenerate -m "initial"
alembic upgrade head

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: CharFieldString, IntFieldInteger, BooleanFieldBoolean, DatetimeFieldDateTime 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.xlsx in addition to CSV (requires pip 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.