Skip to content

Fix: Tortoise ORM Not Working — Model Registration, Async Init, and Relationship Errors

FixDevs ·

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' registered

Or 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 config

Or async session lifecycle in FastAPI causes connection leaks:

@app.get("/users")
async def list_users():
    return await User.all()
# After many requests, connection pool exhausted

Tortoise 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' registered

Common 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 index

Field types:

FieldType
IntFieldINTEGER
BigIntFieldBIGINT
CharField(max_length=N)VARCHAR(N)
TextFieldTEXT
BooleanFieldBOOLEAN
DatetimeFieldTIMESTAMP
DateFieldDATE
JSONFieldJSON / JSONB
UUIDFieldUUID
FloatFieldFLOAT
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 query

select_related for FK joins (single JOIN query):

heroes = await Hero.all().select_related("team")
for hero in heroes:
    print(hero.team.name)   # No additional query

prefetch_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 aerich

Initialize:

aerich init -t settings.TORTOISE_ORM
# Creates aerich's config in pyproject.toml

aerich init-db
# Creates initial migration matching current models

After model changes:

aerich migrate --name "add_email_to_user"
# Generates migration file

aerich upgrade
# Applies the migration

Workflow:

# 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 downgrade

Common 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 head

Fix 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):

LookupMeaning
__exactEquals (default if no lookup)
__iexactCase-insensitive equals
__containsLIKE %value%
__icontainsCase-insensitive contains
__startswithLIKE value%
__endswithLIKE %value
__inIN list
__not_inNOT IN list
__gt, __gte, __lt, __lteComparisons
__isnullIS NULL / IS NOT NULL
__notNOT 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 continues

Common 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.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles