← Back to DevBytes

Migrating from Legacy Frameworks to Tortoise-ORM

What is Tortoise-ORM?

Tortoise-ORM is an asynchronous Object-Relational Mapping library for Python, designed to work seamlessly with modern async frameworks like FastAPI, Starlette, and Quart. Inspired by Django's ORM syntax but built from the ground up for asyncio, it provides a clean, intuitive interface for interacting with relational databases such as PostgreSQL, MySQL, and SQLite. Unlike many legacy ORMs that rely on synchronous, blocking I/O, Tortoise-ORM leverages Python's native async/await patterns, making it an ideal choice for high-concurrency applications and microservices.

At its core, Tortoise-ORM offers:

Why Migrating from Legacy Frameworks Matters

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

Legacy ORMs like SQLAlchemy (synchronous core), Django ORM (sync-only), Peewee, or custom raw-SQL abstractions have served the Python community well for over a decade. However, as the ecosystem shifts toward asynchronous programming — driven by the rise of FastAPI, asyncio web servers, and event-driven architectures — these synchronous libraries become bottlenecks. They force developers to run database queries in thread pools, introduce context-switching overhead, and complicate error handling in async contexts.

Migrating to Tortoise-ORM unlocks several tangible benefits:

Whether you are refactoring a monolithic Django application into FastAPI microservices, or replacing a SQLAlchemy-based API with a modern async backend, understanding the migration path is essential for minimizing downtime and avoiding data corruption.

Pre-Migration Assessment and Planning

Before writing any migration code, conduct a thorough audit of your existing data layer. Document every model, their relationships, custom query methods, raw SQL usage, and any database-specific features (triggers, stored procedures, custom indexes). This audit serves as your migration roadmap and helps you identify potential blockers early.

Key questions to answer during assessment:

Create an inventory spreadsheet or document mapping each legacy model to its future Tortoise-ORM equivalent. Note any field type transformations, default value changes, or naming convention adjustments you plan to make. This document becomes your single source of truth throughout the migration process.

Step-by-Step Migration Process

1. Setting Up Tortoise-ORM in Your Project

Install Tortoise-ORM and its dependencies. For PostgreSQL with async support, you will typically use asyncpg; for MySQL, aiomysql; and for SQLite, the built-in aiosqlite driver.

pip install tortoise-orm
pip install aerich          # for migrations
pip install asyncpg         # for PostgreSQL
pip install aiomysql        # for MySQL
pip install aiosqlite       # for SQLite

Initialize Tortoise-ORM early in your application's lifecycle, typically in the startup event of your web framework. Below is a FastAPI integration example:

# app/main.py
from fastapi import FastAPI
from tortoise import Tortoise
from tortoise.contrib.fastapi import register_tortoise

app = FastAPI()

# Option 1: Using register_tortoise (simpler)
register_tortoise(
    app,
    db_url="postgres://user:password@localhost:5432/mydb",
    modules={"models": ["app.models"]},
    generate_schemas=True,
    add_exception_handlers=True,
)

# Option 2: Manual initialization for full control
@app.on_event("startup")
async def init_db():
    await Tortoise.init(
        db_url="postgres://user:password@localhost:5432/mydb",
        modules={"models": ["app.models"]},
    )
    await Tortoise.generate_schemas()

@app.on_event("shutdown")
async def close_db():
    await Tortoise.close_connections()

2. Translating Legacy Models to Tortoise-ORM Models

This is the heart of the migration. You need to recreate each legacy model as a Tortoise-ORM model class. The syntax is deliberately similar to Django's ORM, so if you are migrating from Django the transition is surprisingly smooth. Let us examine concrete translation examples for three common legacy frameworks.

Migrating from SQLAlchemy (Sync) to Tortoise-ORM

Consider a typical synchronous SQLAlchemy model with relationships:

# Legacy: SQLAlchemy models (sync)
from sqlalchemy import Column, Integer, String, ForeignKey, DateTime, create_engine
from sqlalchemy.orm import relationship, declarative_base, sessionmaker

Base = declarative_base()

class Author(Base):
    __tablename__ = "authors"
    id = Column(Integer, primary_key=True)
    name = Column(String(255), nullable=False)
    bio = Column(String(1000))
    created_at = Column(DateTime)

class Book(Base):
    __tablename__ = "books"
    id = Column(Integer, primary_key=True)
    title = Column(String(255), nullable=False)
    author_id = Column(Integer, ForeignKey("authors.id"))
    author = relationship("Author", back_populates="books")
    published_date = Column(DateTime)

Author.books = relationship("Book", order_by=Book.id, back_populates="author")

The equivalent Tortoise-ORM models look like this:

# New: Tortoise-ORM models (async)
from tortoise.models import Model
from tortoise import fields

class Author(Model):
    id = fields.IntField(primary_key=True)
    name = fields.CharField(max_length=255)
    bio = fields.TextField(null=True)
    created_at = fields.DatetimeField(auto_now_add=True)

    # Reverse relation will be accessible via "books" on Author instances
    books: fields.ReverseRelation["Book"]

    class Meta:
        table = "authors"

class Book(Model):
    id = fields.IntField(primary_key=True)
    title = fields.CharField(max_length=255)
    author: fields.ForeignKeyRelation[Author] = fields.ForeignKeyField(
        "models.Author", related_name="books"
    )
    published_date = fields.DatetimeField(null=True)

    class Meta:
        table = "books"

Notice how Tortoise-ORM uses type annotations for relationship fields, providing excellent IDE support. The related_name parameter mirrors Django's convention and creates the reverse relationship automatically.

Migrating from Django ORM to Tortoise-ORM

Django models translate almost directly, with minimal syntactic changes:

# Legacy: Django models
from django.db import models

class Category(models.Model):
    name = models.CharField(max_length=100)
    slug = models.SlugField(unique=True)
    parent = models.ForeignKey(
        'self', on_delete=models.CASCADE, null=True, related_name='children'
    )

class Product(models.Model):
    name = models.CharField(max_length=200)
    category = models.ForeignKey(Category, on_delete=models.CASCADE, related_name='products')
    price = models.DecimalField(max_digits=10, decimal_places=2)
    in_stock = models.BooleanField(default=True)
    created_at = models.DateTimeField(auto_now_add=True)
# New: Tortoise-ORM equivalent
from tortoise.models import Model
from tortoise import fields

class Category(Model):
    id = fields.IntField(primary_key=True)
    name = fields.CharField(max_length=100)
    slug = fields.CharField(max_length=100, unique=True)
    parent: fields.ForeignKeyNullableRelation["Category"] = fields.ForeignKeyField(
        "models.Category", null=True, related_name="children"
    )

    children: fields.ReverseRelation["Category"]

    class Meta:
        table = "categories"

class Product(Model):
    id = fields.IntField(primary_key=True)
    name = fields.CharField(max_length=200)
    category: fields.ForeignKeyRelation[Category] = fields.ForeignKeyField(
        "models.Category", related_name="products"
    )
    price = fields.DecimalField(max_digits=10, decimal_places=2)
    in_stock = fields.BooleanField(default=True)
    created_at = fields.DatetimeField(auto_now_add=True)

    class Meta:
        table = "products"

The key differences are: CharField instead of CharField (same name, different module), explicit IntField primary keys (Tortoise does not auto-create an id field unless you specify it), and the requirement to use ForeignKeyNullableRelation type annotation for nullable foreign keys.

Migrating from Peewee to Tortoise-ORM

Peewee is another popular lightweight sync ORM. Here is a translation example:

# Legacy: Peewee models
from peewee import Model, CharField, ForeignKeyField, DecimalField, DateTimeField
from playhouse.db_url import connect

db = connect("postgresql://user:password@localhost/mydb")

class BaseModel(Model):
    class Meta:
        database = db

class Customer(BaseModel):
    name = CharField(max_length=255)
    email = CharField(max_length=255, unique=True)

class Order(BaseModel):
    customer = ForeignKeyField(Customer, backref='orders')
    total = DecimalField(max_digits=10, decimal_places=2)
    placed_at = DateTimeField()
# New: Tortoise-ORM equivalent
from tortoise.models import Model
from tortoise import fields

class Customer(Model):
    id = fields.IntField(primary_key=True)
    name = fields.CharField(max_length=255)
    email = fields.CharField(max_length=255, unique=True)

    orders: fields.ReverseRelation["Order"]

    class Meta:
        table = "customers"

class Order(Model):
    id = fields.IntField(primary_key=True)
    customer: fields.ForeignKeyRelation[Customer] = fields.ForeignKeyField(
        "models.Customer", related_name="orders"
    )
    total = fields.DecimalField(max_digits=10, decimal_places=2)
    placed_at = fields.DatetimeField()

    class Meta:
        table = "orders"

3. Converting Query Patterns

Query syntax changes significantly between sync and async ORMs. The most important shift is that all queries in Tortoise-ORM must be awaited. Let us compare common query patterns.

Simple CRUD Operations

# Legacy (SQLAlchemy sync)
session = Session()
# CREATE
new_author = Author(name="Jane Austen", bio="English novelist")
session.add(new_author)
session.commit()

# READ
author = session.query(Author).filter(Author.name == "Jane Austen").first()
all_authors = session.query(Author).all()

# UPDATE
author.bio = "Renowned English novelist"
session.commit()

# DELETE
session.delete(author)
session.commit()

# --- Tortoise-ORM equivalent ---

# CREATE
new_author = await Author.create(name="Jane Austen", bio="English novelist")

# READ
author = await Author.filter(name="Jane Austen").first()
all_authors = await Author.all()

# UPDATE
author.bio = "Renowned English novelist"
await author.save()

# Or bulk update:
await Author.filter(name="Jane Austen").update(bio="Renowned English novelist")

# DELETE
await author.delete()
# Or bulk delete:
await Author.filter(name="Jane Austen").delete()

Filtering and Complex Queries

# Legacy (Django ORM sync)
books = Book.objects.filter(
    author__name__icontains="austen",
    published_date__year=2023
).select_related("author").order_by("-published_date")

# --- Tortoise-ORM equivalent ---
books = await Book.filter(
    author__name__icontains="austen",
    published_date__year=2023
).select_related("author").order_by("-published_date").all()

The filtering syntax with double-underscore field lookups is nearly identical, which significantly eases the migration from Django. Tortoise-ORM supports select_related and prefetch_related for eager loading, mirroring Django's performance optimization patterns.

Aggregation and Annotation

# Legacy (Django ORM sync)
from django.db.models import Count, Avg

categories = Category.objects.annotate(
    product_count=Count('products'),
    avg_price=Avg('products__price')
).filter(product_count__gt=5)

# --- Tortoise-ORM equivalent ---
from tortoise.expressions import Count, Avg

categories = await Category.annotate(
    product_count=Count('products'),
    avg_price=Avg('products__price')
).filter(product_count__gt=5).all()

4. Handling Raw SQL and Edge Cases

Many legacy applications contain raw SQL queries for performance-critical paths or database-specific features. Tortoise-ORM provides a raw query interface that is fully async:

# Legacy raw SQL (sync)
results = session.execute(
    "SELECT * FROM books WHERE published_date >= :date",
    {"date": "2023-01-01"}
).fetchall()

# --- Tortoise-ORM raw SQL (async) ---
from tortoise import connections

conn = connections.get("default")
results = await conn.execute_query(
    "SELECT * FROM books WHERE published_date >= $1",
    ["2023-01-01"]
)
# results is a list of dict-like Row objects

For stored procedures or database functions, you can continue using raw execution while gradually migrating to model-based queries as Tortoise-ORM's feature set expands. Document these edge cases so they receive attention during testing.

5. Migrating Existing Data

If you are keeping the same database schema and merely switching the application code to Tortoise-ORM, data migration may be minimal. However, if you are also restructuring tables, you need a data migration strategy.

Option A: Dual-write migration — Run both old and new application code in parallel, writing to both databases temporarily. This is the safest approach for zero-downtime deployments but requires infrastructure coordination.

Option B: ETL pipeline — Export data from the legacy database using its native tools (e.g., pg_dump for PostgreSQL), transform if necessary, and load into the new schema. Tortoise-ORM can then operate on the new database.

Option C: In-place schema evolution — Keep the existing database intact and use Tortoise-ORM's generate_schemas or Aerich migrations to evolve it incrementally. This works well when the schema changes are additive (new columns, new tables) rather than destructive.

Here is a practical script for batch-inserting data using Tortoise-ORM's bulk operations, which are critical for large datasets:

# data_migration.py
import asyncio
from tortoise import Tortoise
from app.models import Author, Book

async def migrate_data():
    await Tortoise.init(
        db_url="postgres://user:password@localhost:5432/newdb",
        modules={"models": ["app.models"]},
    )

    # Fetch from legacy source (pseudo-code — adapt to your setup)
    legacy_authors = await legacy_db.fetch_all("SELECT * FROM authors")

    # Bulk insert with Tortoise-ORM
    author_objects = [
        Author(id=row["id"], name=row["name"], bio=row.get("bio"))
        for row in legacy_authors
    ]
    # Use bulk_create for efficiency with large datasets
    await Author.bulk_create(author_objects, batch_size=500)

    print(f"Migrated {len(author_objects)} authors successfully.")
    await Tortoise.close_connections()

asyncio.run(migrate_data())

Best Practices for a Smooth Migration

Keep the Old Schema Initially Compatible

Start by making Tortoise-ORM models that map exactly to the existing database tables. Avoid renaming columns or tables in the first pass. Use the Meta.table attribute to point each model to the correct legacy table name. This minimizes the risk of data access errors during the transition period.

class LegacyUser(Model):
    id = fields.IntField(primary_key=True)
    # If the legacy column is "user_name" not "username"
    user_name = fields.CharField(max_length=100, source_field="user_name")

    class Meta:
        table = "legacy_users_table"  # matches existing table name

The source_field parameter lets you use Python-friendly attribute names while mapping to whatever column names exist in the database.

Write Comprehensive Tests Before and During Migration

Testing is non-negotiable. Write a test suite that validates every model's CRUD operations, every relationship traversal, and every custom query method. Run these tests against both the legacy ORM (as a baseline) and Tortoise-ORM (as the target). Use a dedicated test database with a known dataset to ensure identical results.

# test_migration_parity.py
import pytest
from legacy_app.models import get_author_by_name as legacy_get_author
from new_app.models import get_author_by_name as tortoise_get_author

@pytest.mark.asyncio
async def test_author_query_parity():
    # Both functions should return the same data for the same input
    legacy_result = legacy_get_author("Jane Austen")
    tortoise_result = await tortoise_get_author("Jane Austen")

    assert legacy_result.id == tortoise_result.id
    assert legacy_result.name == tortoise_result.name
    assert legacy_result.bio == tortoise_result.bio

Use Aerich for Migration Management

Aerich is Tortoise-ORM's dedicated migration tool, inspired by Django's makemigrations and migrate commands. Initialize it early and let it track your schema changes:

# Initialize Aerich (run once)
aerich init -t app.models  # -t points to your models module
aerich init-db

# After model changes, generate migrations
aerich migrate --description "added new field to Author"

# Apply migrations
aerich upgrade

# Roll back if needed
aerich downgrade

Aerich stores migration history in a dedicated table, allowing you to version your database schema alongside your application code. This is far safer than relying on generate_schemas=True in production, which does not handle incremental changes.

Gradually Deprecate Legacy Code

Rather than a big-bang rewrite, use the Strangler Fig pattern. Introduce Tortoise-ORM for new endpoints or features first, while legacy code continues to serve existing functionality. Over time, rewrite older endpoints to use Tortoise-ORM, and finally remove the legacy ORM dependency entirely. This approach reduces risk and allows you to catch edge cases incrementally.

Monitor Performance Closely

Async ORMs can expose database performance issues that were previously hidden by thread pool overhead. Use Tortoise-ORM's built-in logging or integrate with tools like OpenTelemetry to monitor query execution times. Watch for N+1 query problems — use select_related and prefetch_related aggressively, just as you would in Django.

# Enable query logging for debugging
from tortoise import Tortoise

await Tortoise.init(
    db_url="postgres://...",
    modules={"models": ["app.models"]},
)
# Set logging level
import logging
logging.basicConfig(level=logging.DEBUG)
# Tortoise will now log all SQL queries

Common Pitfalls and How to Avoid Them

Pitfall 1: Forgetting to await queries. Unlike sync ORMs where calling .all() immediately returns results, Tortoise-ORM querysets are lazy and must be awaited. An un-awaited queryset silently does nothing, leading to confusing bugs. Always use await or handle the coroutine explicitly.

# WRONG — this coroutine is never awaited, so the query never executes
books = Book.filter(author__name="Austen")  # returns a QuerySet, not results

# CORRECT
books = await Book.filter(author__name="Austen").all()

Pitfall 2: Mixing sync and async database connections. If your application still has synchronous code paths that connect to the same database, ensure connection pooling is configured properly. Tortoise-ORM manages its own async connection pool; mixing it with sync pools from SQLAlchemy or psycopg2 can exhaust database connections. Use separate connection limits or migrate sync code to async entirely.

Pitfall 3: Ignoring transaction boundaries. Async code can make it easy to accidentally auto-commit when you intended a multi-statement transaction. Use Tortoise-ORM's explicit transaction context manager:

from tortoise.transactions import in_transaction

async with in_transaction() as conn:
    # All operations within this block share the same transaction
    author = await Author.create(name="New Author")
    book = await Book.create(title="New Book", author=author)
    # If an exception occurs, both creations are rolled back

Pitfall 4: Overlooking type annotations for relationships. Tortoise-ORM relies heavily on type hints for relationship fields. Missing or incorrect type annotations (like fields.ForeignKeyRelation vs fields.ForeignKeyNullableRelation) cause mypy errors and IDE confusion. Always annotate foreign key fields properly, even if Python's runtime does not enforce them.

Pitfall 5: Assuming Django's auto-incrementing ID behavior. In Django, models automatically get an id primary key. In Tortoise-ORM, you must explicitly declare id = fields.IntField(primary_key=True) (or use a UUID field). If you forget, Tortoise-ORM will not generate a primary key, leading to runtime errors.

Conclusion

Migrating from a legacy synchronous ORM to Tortoise-ORM is a strategic investment that pays dividends in application performance, code clarity, and alignment with the modern async Python ecosystem. The process requires careful planning — auditing your existing models, mapping them faithfully to Tortoise-ORM classes, converting queries to async patterns, and validating behavior with comprehensive tests. By leveraging Tortoise-ORM's Django-like syntax, Aerich migrations, and robust relationship handling, teams can execute migrations incrementally and safely. The transition unlocks true asynchronous database access, eliminates thread pool bottlenecks, and positions your application to take full advantage of FastAPI and other async frameworks. Start small, test relentlessly, and let the Strangler Fig pattern guide your rollout. The result is a cleaner, faster, and more maintainable data layer ready for the demands of modern web development.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles