Fix: Tortoise ORM Not Working — Model Registration, Async Init, and Relationship Errors
Quick Answer
How to fix Tortoise ORM errors — Tortoise.init not called, no module imported model, fetch_related missing, aerich migration setup, FastAPI integration patterns, and ConfigurationError missing connection.
The Error
You define a Tortoise model but queries fail:
from tortoise.models import Model
from tortoise import fields
class User(Model):
id = fields.IntField(pk=True)
name = fields.CharField(max_length=100)
await User.all()
# ConfigurationError: No app with name 'models' registeredOr your relationship attribute is missing fetched data:
user = await User.get(id=1)
print(user.posts)
# AttributeError: 'User' object has no attribute 'posts'
# (or returns a reverse manager, not a list)Or Tortoise.init fails silently:
await Tortoise.init(...)
await User.all()
# Returns empty list even though DB has rows — wrong DB?Or migrations break in aerich:
$ aerich init -t settings.TORTOISE_ORM
$ aerich init-db
# Error: No app with name 'models' found in configOr async session lifecycle in FastAPI causes connection leaks:
@app.get("/users")
async def list_users():
return await User.all()
# After many requests, connection pool exhaustedTortoise ORM is the Django-inspired async ORM for Python — clean model syntax (fields.IntField(), Meta class, objects manager), async-native from day one. Used widely with FastAPI and aiohttp. But the model registration system (Tortoise.init’s modules parameter), relationship pre-fetching, and aerich migrations have specific failure modes that differ from SQLAlchemy/Django. This guide covers each.
Why This Happens
Tortoise needs to know which Python modules contain your models — they must be listed in Tortoise.init(modules=...). Models defined in unlisted modules aren’t registered and can’t be queried. The default app name is “models” but you can have multiple apps with separate model groups.
Relationships are lazy by default and require explicit fetch_related() or prefetch_related() to load. Unlike SQLAlchemy where you can selectinload in the query, Tortoise prefers post-fetch loading via these dedicated methods.
Fix 1: Initialization and Module Registration
from tortoise import Tortoise
async def init_db():
await Tortoise.init(
db_url="postgresql://user:pass@localhost/mydb",
modules={"models": ["myapp.models"]},
)
# Generate schemas (for dev — use aerich migrations in production)
await Tortoise.generate_schemas()
async def close_db():
await Tortoise.close_connections()modules is a dict mapping app name → list of module paths:
await Tortoise.init(
db_url="postgresql://...",
modules={
"models": ["myapp.models.user", "myapp.models.post"],
"analytics": ["myapp.analytics.models"],
},
)Without explicit module listing, models aren’t registered:
# myapp/models.py
class User(Model):
name = fields.CharField(max_length=100)
# main.py
import myapp.models # Import isn't enough
await Tortoise.init(db_url="...") # No modules — User not registered
await User.all()
# ConfigurationError: No app with name 'models' registeredCommon Mistake: Importing the module instead of listing it in Tortoise.init. Tortoise scans the modules listed in modules= and registers every Model subclass it finds. A plain import doesn’t trigger this scan. Always pass module paths as strings to Tortoise.init.
Verify registration:
from tortoise import Tortoise
await Tortoise.init(...)
print(Tortoise.apps)
# {'models': {'User': <class 'myapp.models.User'>, ...}}Fix 2: Configuring with TORTOISE_ORM Dict
For larger projects, use a config dict (also required by aerich):
# settings.py
TORTOISE_ORM = {
"connections": {
"default": "postgresql://user:pass@localhost/mydb",
},
"apps": {
"models": {
"models": ["myapp.models", "aerich.models"],
"default_connection": "default",
},
},
"use_tz": False,
"timezone": "UTC",
}# main.py
from settings import TORTOISE_ORM
from tortoise import Tortoise
await Tortoise.init(config=TORTOISE_ORM)aerich.models is required if you use aerich migrations — it stores migration state. Always include it in the apps list.
Multiple databases:
TORTOISE_ORM = {
"connections": {
"primary": "postgresql://primary-host/db",
"readonly": "postgresql://replica-host/db",
"analytics": "postgresql://analytics-host/db",
},
"apps": {
"models": {
"models": ["myapp.models"],
"default_connection": "primary",
},
"analytics": {
"models": ["myapp.analytics_models"],
"default_connection": "analytics",
},
},
}Connection string formats:
# PostgreSQL via asyncpg
"postgres://user:pass@localhost:5432/mydb"
# MySQL via asyncmy
"mysql://user:pass@localhost:3306/mydb"
# SQLite
"sqlite://db.sqlite3"
# Detailed dict format
{
"engine": "tortoise.backends.asyncpg",
"credentials": {
"host": "localhost",
"port": 5432,
"user": "postgres",
"password": "...",
"database": "mydb",
"minsize": 1,
"maxsize": 10,
"ssl": False,
},
}Fix 3: Model Definition Patterns
from tortoise.models import Model
from tortoise import fields
class Team(Model):
id = fields.IntField(pk=True)
name = fields.CharField(max_length=100, unique=True)
created_at = fields.DatetimeField(auto_now_add=True)
class Meta:
table = "teams" # Custom table name (default: lowercase class name)
def __str__(self):
return self.name
class Hero(Model):
id = fields.IntField(pk=True)
name = fields.CharField(max_length=100)
team = fields.ForeignKeyField(
"models.Team", # "app_name.ModelName"
related_name="heroes",
on_delete=fields.CASCADE,
)
power = fields.IntField(default=0)
class Meta:
table = "heroes"
indexes = [("team_id", "name")] # Composite indexField types:
| Field | Type |
|---|---|
IntField | INTEGER |
BigIntField | BIGINT |
CharField(max_length=N) | VARCHAR(N) |
TextField | TEXT |
BooleanField | BOOLEAN |
DatetimeField | TIMESTAMP |
DateField | DATE |
JSONField | JSON / JSONB |
UUIDField | UUID |
FloatField | FLOAT |
DecimalField(max_digits, decimal_places) | NUMERIC |
ForeignKeyField(...) | FOREIGN KEY |
ManyToManyField(...) | M2M intermediate table |
Foreign key format: "app_name.ModelName" as a string (forward reference, like Django).
Common Mistake: Using ForeignKey(Team) (passing the class directly). Tortoise expects the string "models.Team". Direct class references work only if the related model is imported before the model with the FK — but circular imports make this fragile. Always use string references.
Fix 4: Relationships and Prefetching
Forward relationship (FK):
hero = await Hero.get(id=1)
team = await hero.team # Awaits — fetches the FK target
print(team.name)Reverse relationship (related_name):
team = await Team.get(id=1)
# WRONG — heroes isn't a list, it's a manager
print(team.heroes)
# <tortoise.queryset.QuerySet>
# CORRECT — query through the manager
heroes = await team.heroes.all()
for hero in heroes:
print(hero.name)Prefetch relationships to avoid N+1 queries:
# Without prefetch — N+1 (one query per team's heroes)
teams = await Team.all()
for team in teams:
heroes = await team.heroes.all() # Separate query per team!
# WITH prefetch — 2 queries total (teams + all heroes via single IN clause)
teams = await Team.all().prefetch_related("heroes")
for team in teams:
heroes = team.heroes # Already fetched — no queryselect_related for FK joins (single JOIN query):
heroes = await Hero.all().select_related("team")
for hero in heroes:
print(hero.team.name) # No additional queryprefetch_related for reverse and M2M (separate queries with IN):
teams = await Team.all().prefetch_related("heroes")Pro Tip: Use select_related for forward (FK) relationships and prefetch_related for reverse (related_name) and M2M relationships. Picking the wrong one (e.g., prefetch_related("team") for a FK) silently runs less efficient queries. The same principles apply to Django ORM — Tortoise inherited the API.
Common Mistake: Forgetting to await the manager call. team.heroes returns a queryset, not a list. await team.heroes.all() gives you the list. Even after prefetch_related, team.heroes after prefetch is a list, but without prefetch you must await .all().
Fix 5: aerich for Migrations
pip install aerichInitialize:
aerich init -t settings.TORTOISE_ORM
# Creates aerich's config in pyproject.toml
aerich init-db
# Creates initial migration matching current modelsAfter model changes:
aerich migrate --name "add_email_to_user"
# Generates migration file
aerich upgrade
# Applies the migrationWorkflow:
# 1. Edit models
# 2. Generate migration
aerich migrate --name "add_email_column"
# 3. Inspect the migration in migrations/models/
# 4. Apply
aerich upgrade
# Roll back if needed
aerich downgradeCommon Mistake: Forgetting to add aerich.models to your TORTOISE_ORM apps list. aerich stores its state in a database table; without registering its model, you get cryptic errors about missing app or table. Always include:
TORTOISE_ORM = {
"apps": {
"models": {
"models": ["myapp.models", "aerich.models"], # Include aerich.models
"default_connection": "default",
},
},
}Inspect migration:
aerich history # List applied migrations
aerich heads # Show current headFix 6: FastAPI Integration
from contextlib import asynccontextmanager
from fastapi import FastAPI
from tortoise.contrib.fastapi import RegisterTortoise
TORTOISE_ORM = {
"connections": {"default": "postgresql://user:pass@localhost/mydb"},
"apps": {
"models": {
"models": ["myapp.models", "aerich.models"],
"default_connection": "default",
},
},
}
@asynccontextmanager
async def lifespan(app: FastAPI):
async with RegisterTortoise(
app,
config=TORTOISE_ORM,
generate_schemas=False, # Use aerich migrations instead
add_exception_handlers=True,
):
yield
app = FastAPI(lifespan=lifespan)
@app.get("/users")
async def list_users():
return await User.all().values("id", "name")
@app.post("/users")
async def create_user(name: str, email: str):
user = await User.create(name=name, email=email)
return await user.to_dict()RegisterTortoise handles connection lifecycle — opens at startup, closes at shutdown. Pair with add_exception_handlers=True to map Tortoise errors to proper HTTP responses (404 for not-found, etc.).
Pydantic schemas for API responses:
from tortoise.contrib.pydantic import pydantic_model_creator
UserPydantic = pydantic_model_creator(User, name="User")
UserCreate = pydantic_model_creator(User, name="UserCreate", exclude_readonly=True)
@app.get("/users", response_model=list[UserPydantic])
async def list_users():
return await UserPydantic.from_queryset(User.all())
@app.post("/users", response_model=UserPydantic)
async def create_user(user_in: UserCreate):
user_obj = await User.create(**user_in.model_dump())
return await UserPydantic.from_tortoise_orm(user_obj)For FastAPI dependency patterns that integrate with Tortoise, see FastAPI dependency injection error.
Fix 7: Querying
# Get one
user = await User.get(id=1) # Raises DoesNotExist if 0 rows
user = await User.get_or_none(id=1) # Returns None if 0 rows
user = await User.get_or_create(email="[email protected]", defaults={"name": "Alice"})
# Filter
users = await User.filter(name__icontains="alice")
users = await User.filter(age__gte=18).filter(active=True)
# Exclude
users = await User.exclude(banned=True)
# Order, limit
users = await User.all().order_by("-created_at").limit(10)
# Count
count = await User.filter(active=True).count()
# Exists
exists = await User.filter(email="[email protected]").exists()
# Aggregation
from tortoise.functions import Count, Avg, Sum
result = await User.annotate(
post_count=Count("posts")
).filter(post_count__gt=5).all()
stats = await User.aggregate(avg_age=Avg("age"), total=Count("id"))
print(stats["avg_age"], stats["total"])Lookup operators (Django-style):
| Lookup | Meaning |
|---|---|
__exact | Equals (default if no lookup) |
__iexact | Case-insensitive equals |
__contains | LIKE %value% |
__icontains | Case-insensitive contains |
__startswith | LIKE value% |
__endswith | LIKE %value |
__in | IN list |
__not_in | NOT IN list |
__gt, __gte, __lt, __lte | Comparisons |
__isnull | IS NULL / IS NOT NULL |
__not | NOT equals |
Bulk operations:
# Bulk create (single INSERT)
await User.bulk_create([
User(name="Alice", email="[email protected]"),
User(name="Bob", email="[email protected]"),
])
# Bulk update
await User.filter(active=False).update(banned=True)Fix 8: Transactions
from tortoise.transactions import in_transaction
# Decorator
from tortoise.transactions import atomic
@atomic()
async def create_user_with_profile(user_data, profile_data):
user = await User.create(**user_data)
await Profile.create(user=user, **profile_data)
return user
# Context manager
async def transfer(from_id, to_id, amount):
async with in_transaction() as conn:
from_user = await User.get(id=from_id).using_db(conn)
to_user = await User.get(id=to_id).using_db(conn)
from_user.balance -= amount
to_user.balance += amount
await from_user.save(using_db=conn)
await to_user.save(using_db=conn)Savepoints (nested transactions):
async with in_transaction() as conn:
await User.create(...).using_db(conn)
try:
async with in_transaction(connection_name=conn) as nested:
await risky_operation()
except Exception:
pass # Inner rolled back, outer continuesCommon Mistake: Calling in_transaction() but forgetting to pass the connection to subsequent ORM calls via .using_db(conn). Without it, those calls use the default connection — outside your transaction. The atomicity guarantee is silently broken. Always pass the transaction connection explicitly.
Still Not Working?
Tortoise vs SQLModel vs SQLAlchemy
- Tortoise — Django-like API, async-native, clean models. Best when you want Django ergonomics in async Python.
- SQLModel — Pydantic-aligned, FastAPI-first. See SQLModel not working.
- SQLAlchemy — Most mature, more features, async support. See SQLAlchemy not working.
Tortoise is great if you came from Django and want to keep the manager-based API. SQLModel feels more natural in modern type-annotated FastAPI code. SQLAlchemy wins on complex schemas and advanced features.
Signal Hooks
from tortoise.signals import post_save, pre_save
@post_save(User)
async def user_post_save(sender, instance, created, using_db, update_fields):
if created:
await send_welcome_email(instance.email)Useful for cross-cutting concerns (audit logs, cache invalidation, event emissions).
Connection Pool Sizing
TORTOISE_ORM = {
"connections": {
"default": {
"engine": "tortoise.backends.asyncpg",
"credentials": {
"host": "localhost",
"port": 5432,
"user": "postgres",
"password": "...",
"database": "mydb",
"minsize": 5,
"maxsize": 20,
},
},
},
"apps": {...},
}For asyncpg-specific pool configuration, see asyncpg not working.
Testing with In-Memory SQLite
import pytest
from tortoise import Tortoise
@pytest.fixture(autouse=True)
async def init_db():
await Tortoise.init(
db_url="sqlite://:memory:",
modules={"models": ["myapp.models"]},
)
await Tortoise.generate_schemas()
yield
await Tortoise.close_connections()For pytest async fixture patterns, see pytest fixture not found.
Type Hints and IDE Support
Tortoise generates types dynamically — IDE autocomplete can be spotty. Use tortoise-cli to generate stub files:
pip install tortoise-orm[stubs]Or manually annotate hot paths:
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from myapp.models import User as UserModel
async def get_user(user_id: int) -> "UserModel":
return await User.get(id=user_id)For mypy patterns that work with Tortoise’s dynamic types, see Python mypy type error.
Migration to Alembic
If you outgrow aerich, you can switch to Alembic by exporting the schema and recreating with SQLAlchemy. This is a big change — usually only worth it if you’re also migrating off Tortoise. For Alembic patterns, see Alembic not working.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Peewee Not Working — Connection Pooling, Field Errors, and Migration Setup
How to fix Peewee errors — OperationalError database is locked, connection already open, field type mismatch, model meta database missing, N+1 queries, and peewee-migrate setup.
Fix: asyncpg Not Working — Connection Pool, Prepared Statements, and Transaction Errors
How to fix asyncpg errors — connection refused localhost 5432, pool exhausted timeout, prepared statement does not exist, type codec not registered, JSON automatic conversion, and transaction rollback on exception.
Fix: httpx Not Working — Async Client, Timeout, and Connection Pool Errors
How to fix httpx errors — RuntimeError event loop is closed, ReadTimeout exception, ConnectionResetError, async client not closing properly, HTTP/2 not enabled, SSL verify failed, and proxy not working.
Fix: SQLAlchemy Not Working — DetachedInstanceError, Pool Exhausted, and MissingGreenlet
How to fix SQLAlchemy 2.x errors — DetachedInstanceError from lazy loading, QueuePool limit exceeded, MissingGreenlet in async context, N+1 queries, IntegrityError rollback, and Alembic migration failures.