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:
- Async-first design — every database operation is non-blocking
- Familiar Django-like syntax — model definitions, querysets, and managers feel instantly recognizable
- Robust migration system — powered by Aerich, an async migration tool akin to Django's makemigrations/migrate
- Comprehensive relationship support — ForeignKey, ManyToMany, and OneToOne fields with eager/lazy loading
- Pluggable database backends — PostgreSQL, MySQL/MariaDB, SQLite, and even Microsoft SQL Server via community drivers
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:
- True async throughput — handle thousands of concurrent database connections without thread exhaustion
- Simplified code — no more
await sync_to_asyncwrappers or executor pools scattered throughout your codebase - Modern Python alignment — fully compatible with type hints, dataclass patterns, and Pydantic models for serialization
- Reduced latency — async queries combined with connection pooling yield faster response times under load
- Future-proof architecture — align your stack with the direction Python web development is heading
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:
- Which database backend are you using, and is it fully supported by Tortoise-ORM and its drivers?
- Do you rely on database-specific features like PostgreSQL's
JSONBoperations, full-text search, or geospatial fields? - How many models exist, and what are the dependency chains between them?
- Are there circular foreign key relationships that need careful handling?
- What is the volume of data — will migration require batch processing to avoid downtime?
- Does the application use multiple databases, read replicas, or sharding?
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.