# Tortoise Dev

> Use when the user asks to "create a tortoise model", "new tortoise project", "tortoise orm", "async orm", "aerich migration", "fastapi tortoise", "tortoise model abstract", "tortoise base class", or mentions Tortoise ORM project structure, model organization, querysets, signals, transactions, or testing. Provides opinionated enterprise patterns: model-per-file, abstract Base interfaces for mocks/transitions, strict class member ordering, FastAPI lifespan, built-in Tortoise migrations (never Aerich), Pydantic v2, pytest.

- Skill: `sergio-bershadsky/tortoise-dev` (Agent Skill, multi-file: 6 files)
- Install (CLI): `npx skillmds@latest add sergio-bershadsky/tortoise-dev`
- Raw SKILL.md: https://api.skillmd.com/api/skills/sergio-bershadsky/tortoise-dev/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: AI & ML
- Author: sergio-bershadsky (https://skillmd.com/u/sergio-bershadsky)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/sergio-bershadsky/tortoise-dev

---


# Tortoise ORM Enterprise Patterns

Opinionated, production-grade development patterns for [Tortoise ORM](https://tortoise.github.io/)
(async Python ORM). Apply these to every Tortoise codebase unless the project's own `CLAUDE.md`
overrides them.

## Rule Priority (read first, every time)

When advice conflicts, resolve in this order:

1. **User's project rules** (this repo's `CLAUDE.md`, explicit instructions in the conversation) — highest priority
2. **Official Tortoise ORM docs** (https://tortoise.github.io/)
3. **Context7-sourced patterns** (`/tortoise/tortoise-orm`)
4. **Other internet consensus** (projectrules.ai, blog posts)

If the user states a rule (e.g. "use UUID primary keys"), it wins over everything below.

## Check the LATEST version first (always)

Tortoise ORM moves fast and its tooling has **changed across versions** — most notably, schema
migrations moved from the separate **Aerich** tool to a **built-in `tortoise` migration CLI in
≥ 1.1**. So before relying on any version-specific behaviour (migrations above all), **verify the
current version and confirm against the live docs** — do not trust this skill's version numbers blindly:

- Check the installed + latest release: `python -c "import tortoise; print(tortoise.__version__)"`,
  `pip index versions tortoise-orm` (or PyPI <https://pypi.org/project/tortoise-orm/>).
- Read the live docs/changelog: <https://tortoise.github.io/> and the migration page
  <https://tortoise.github.io/migration.html>. Cross-check with Context7 `/tortoise/tortoise-orm`.
- **Prefer the latest stable**, pin a known-good version, and when the live docs disagree with this
  skill, the **live docs win** (Rule Priority #2). If a newer version changes a workflow here, follow
  the new docs and flag the drift.

## The Five Hard Rules (non-negotiable)

These come straight from the user's ruleset and override any pattern below. **Never break them.**

| # | Rule | Why it matters |
|---|------|----------------|
| 0 | **Models live in a `models/` subpackage of the sub-application** (never `models.py`) | Lets the package grow without refactor; mirrors Django/FastAPI conventions |
| 1 | **One file == one model** | Stable git blame, trivial review diffs, no circular-import games |
| 2 | **Every concrete model has an abstract `Base*` interface** usable as a mock base or transition model | Makes business logic testable without a DB; supports zero-downtime model rewrites |
| 3 | **`class Meta` is ALWAYS the first member** of any model class | Reader sees table/index/ordering before any field |
| 4 | **Class member order is enforced:** Meta → fields → class/private methods → public methods | Predictable scan order; reduces review friction |

> Rule 2 is the one most teams skip. Don't. See `references/models.md` for the full pattern,
> including how `Base*` doubles as a Protocol-like interface for service-layer mocking.

## When to Use

- Bootstrapping a new Tortoise ORM project (FastAPI, Starlette, Sanic, Quart, AIOHTTP, Nexios).
- Adding a model, refactoring an existing single-file `models.py`.
- Wiring Tortoise's built-in migrations into CI.
- Writing tests that need DB isolation without slow integration fixtures.
- Reviewing a PR that touches Tortoise models.

## Project Layout

```
project/
├── pyproject.toml                # uv + dependency groups
├── uv.lock
├── docker/                       # all docker artifacts live here
│   ├── Dockerfile
│   ├── docker-compose.yml
│   └── entrypoint.sh
├── config/
│   ├── __init__.py
│   ├── settings.py               # Dynaconf or pydantic-settings
│   └── tortoise.py               # TORTOISE_ORM dict (the `tortoise` CLI reads this via -c)
├── apps/
│   └── billing/                  # one sub-application
│       ├── __init__.py
│       ├── models/               # SUBPACKAGE — Rule 0
│       │   ├── __init__.py       # re-exports
│       │   ├── base.py           # cross-cutting abstract base (BaseModel) — never `_base.py`
│       │   ├── invoice.py        # one file == one model — Rule 1
│       │   │                      # contains BaseInvoice + Invoice — Rule 2
│       │   └── payment.py
│       ├── schemas/              # Pydantic v2 in/out DTOs
│       ├── services/             # business logic (depends on Base* interfaces)
│       ├── repositories/         # optional: data-access abstraction
│       └── routers/              # FastAPI / framework routes
├── migrations/                   # `tortoise makemigrations` output (built-in, not Aerich)
└── tests/
```

### Pin versions

`pyproject.toml`:

```toml
[project]
name = "myproject"
requires-python = ">=3.12"
dependencies = [
    "tortoise-orm[asyncpg]>=1.1",      # asyncpg for postgres; >=1.1 ships the built-in `tortoise` migration CLI
    "fastapi>=0.115",
    "pydantic>=2.7",
]

[dependency-groups]
dev = ["ruff>=0.5", "mypy>=1.10", "ipython"]
test = ["pytest>=8", "pytest-asyncio>=0.23", "asynctest", "freezegun"]
```

> **No `aerich`.** Tortoise ≥ 1.1 has a built-in migration system; `aerich`, `aerich.models`,
> and `[tool.aerich]` must not appear anywhere. The `tortoise` CLI ships with `tortoise-orm`
> and resolves config from the `-c <dotted.path>` flag (see Migrations below).

## Models — Rules 0–4 in Practice

The full pattern lives in `references/models.md`. Here is the shape every model file
**must** take:

```python
# apps/billing/models/invoice.py
"""Invoice model and its abstract interface."""
from __future__ import annotations

from decimal import Decimal
from typing import ClassVar

from tortoise import fields
from tortoise.models import Model

from .base import BaseModel


# Rule 2: abstract interface — used as Mock base AND as a transition model
# during schema rewrites. Service-layer code accepts BaseInvoice, not Invoice.
class BaseInvoice(BaseModel):
    # Rule 3: class Meta ALWAYS first
    class Meta:
        abstract = True

    # Rule 4 step 2: fields
    number: str = fields.CharField(max_length=32, unique=True)
    amount: Decimal = fields.DecimalField(max_digits=12, decimal_places=2)
    paid: bool = fields.BooleanField(default=False)

    # Rule 4 step 3: class / private methods (alphabetical)
    def __str__(self) -> str:
        return self.number

    def _format_amount(self) -> str:
        return f"{self.amount:.2f}"

    # Rule 4 step 4: public methods (alphabetical)
    def is_overdue(self, today) -> bool:  # pure, mockable
        raise NotImplementedError


class Invoice(BaseInvoice):
    class Meta:
        table = "invoice"
        table_description = "Customer invoices"
        ordering = ["-created_at"]
        indexes = (("paid", "created_at"),)

    customer: fields.ForeignKeyRelation["Customer"] = fields.ForeignKeyField(
        "models.Customer",
        related_name="invoices",
        on_delete=fields.OnDelete.RESTRICT,
    )
    due_at = fields.DatetimeField(null=True)

    def __repr__(self) -> str:
        return f"<Invoice {self.number} {self._format_amount()}>"

    def is_overdue(self, today) -> bool:
        return self.due_at is not None and not self.paid and self.due_at < today
```

`models/__init__.py` re-exports both the concrete model and its base so service code can import
the base without reaching into module internals:

```python
from .base import BaseModel
from .invoice import BaseInvoice, Invoice
from .payment import BasePayment, Payment

__all__ = ["BaseModel", "BaseInvoice", "Invoice", "BasePayment", "Payment"]
```

### Why `Base*` matters (Rule 2 deep dive)

`Base*` classes are abstract Tortoise models (`Meta.abstract = True`). They have **no table**,
so they can be:

- **Subclassed by mocks** in tests — give the mock a `Meta` and a hand-rolled `save()` stub.
- **Subclassed by a Transition model** during a schema rewrite — point services at
  `Invoice2` (which inherits `BaseInvoice`) while the old `Invoice` is still around.
- **Used as type hints** for services: `async def settle(inv: BaseInvoice) -> None`. The
  service code doesn't care which concrete subclass it gets.

A `Base*` declares fields, signatures, and pure helpers. The concrete model adds relations,
table config, and overrides behavior that requires the DB.

## Cross-Cutting Base (`base.py`)

```python
# apps/billing/models/base.py
from tortoise import fields
from tortoise.models import Model


class BaseModel(Model):
    class Meta:
        abstract = True

    id = fields.UUIDField(primary_key=True)
    created_at = fields.DatetimeField(auto_now_add=True)
    updated_at = fields.DatetimeField(auto_now=True)
```

> Don't put soft-delete on the cross-cutting base unless every model in the project actually
> needs it. Prefer a separate `BaseSoftDelete` mixin you opt into per model.

## Configuration

Keep the Tortoise config in one place (the `tortoise` migration CLI reads it via `-c`;
FastAPI lifespan reads it). The built-in migrator is wired with a per-app `"migrations"`
package key — **not** an `aerich.models` pseudo-app:

```python
# config/tortoise.py
from config.settings import settings

TORTOISE_ORM = {
    "connections": {"default": settings.DATABASE_URL},
    "apps": {
        "models": {
            "models": [
                "apps.billing.models",
                "apps.users.models",
            ],
            "default_connection": "default",
            "migrations": "migrations",   # built-in migrations package for this app
        }
    },
    "use_tz": True,
    "timezone": "UTC",
}
```

**Never** call `Tortoise.generate_schemas()` in production code paths — it bypasses migrations.
Use it only in test fixtures and SQLite-memory examples.

## FastAPI Integration (lifespan-style only)

```python
# main.py
from contextlib import asynccontextmanager
from fastapi import FastAPI
from tortoise import Tortoise
from tortoise.contrib.fastapi import tortoise_exception_handlers

from config.tortoise import TORTOISE_ORM


@asynccontextmanager
async def lifespan(app: FastAPI):
    await Tortoise.init(config=TORTOISE_ORM)
    yield
    await Tortoise.close_connections()


app = FastAPI(
    lifespan=lifespan,
    exception_handlers=tortoise_exception_handlers(),
)
```

Avoid `register_tortoise(generate_schemas=True)` outside of dev — it'll happily skip your
migrations.

## Querying — fast path summary

Full patterns in `references/queries.md`. The high-leverage rules:

- **Always `prefetch_related` / `select_related`** for relations you'll touch — N+1 is the
  default failure mode.
- **`bulk_create` / `bulk_update`** for >5 rows; never loop `await .save()`.
- **Use `.only()` / `.values()`** when you don't need the full instance.
- **Filter with `Q` and `F`**; do not concatenate raw SQL into `.raw()`.
- **`exists()` / `count()`** before fetching full result sets just to check.

```python
# Good
invoices = await Invoice.filter(paid=False).select_related("customer").prefetch_related("payments")

# Bad — loads everything, N+1 on customer
for inv in await Invoice.all():
    print(inv.customer.name)  # silent extra query each iter (and will fail — relation not awaited)
```

## Transactions

```python
from tortoise.transactions import atomic, in_transaction

@atomic()
async def settle_invoice(invoice_id: str) -> None:
    inv = await Invoice.select_for_update().get(id=invoice_id)
    inv.paid = True
    await inv.save(update_fields=["paid", "updated_at"])
    await Payment.create(invoice=inv, amount=inv.amount)
```

- Use `@atomic()` for whole functions, `async with in_transaction()` for inline blocks.
- **Do not** nest transactions inside `asyncio.gather` — connections are stateful and
  sequential.
- For row-level locking, use `.select_for_update()`.

## Signals (use sparingly)

Signals are great for audit logs and cache invalidation; **don't** put business rules in them
— they're invisible from a model file.

```python
from tortoise.signals import post_save

@post_save(Invoice)
async def _invoice_audit(sender, instance, created, using_db, update_fields):
    await AuditLog.create(entity="invoice", entity_id=instance.id, created=created)
```

## Migrations (built-in Tortoise CLI — never Aerich)

> **Aerich is forbidden here.** Tortoise ORM ≥ 1.1 ships a built-in migration system and
> `tortoise` CLI, which the official docs call the recommended path (Aerich is "a legacy
> alternative"). See <https://tortoise.github.io/migration.html>. Do **not** introduce
> `aerich`, `aerich.models`, `[tool.aerich]`, or `aerich upgrade`.

The global `-c <dotted.path.to.TORTOISE_ORM>` flag comes **before** the subcommand:

```bash
# one-time: create the migrations package, generate + apply the initial migration
uv run tortoise -c config.tortoise.TORTOISE_ORM init
uv run tortoise -c config.tortoise.TORTOISE_ORM makemigrations
uv run tortoise -c config.tortoise.TORTOISE_ORM migrate

# per change (app label is the Tortoise app key — here `models`)
uv run tortoise -c config.tortoise.TORTOISE_ORM makemigrations --name add_invoice_due_at
uv run tortoise -c config.tortoise.TORTOISE_ORM migrate

# roll back the last applied migration of an app in dev
uv run tortoise -c config.tortoise.TORTOISE_ORM downgrade models

# inspect: applied history, on-disk heads, or SQL for a migration without running it
uv run tortoise -c config.tortoise.TORTOISE_ORM history
uv run tortoise -c config.tortoise.TORTOISE_ORM sqlmigrate models 0001_initial
```

- Commit migrations. Review them like code.
- Use `RunPython` / `RunSQL` for data migrations; never edit schema and data in the same auto-generated migration.
- In CI: `tortoise -c config.tortoise.TORTOISE_ORM migrate` runs before the app boots.
- `migrate` and `upgrade` are aliases; this skill standardizes on `migrate`.

### Deployment integration — propose a migration service/hook

**Before adding or modifying models, scan the repo for deployment manifests.** If any of
these exist, **proactively propose** wiring `tortoise ... migrate` as a one-shot migration
service/hook so the user never has to remember to run it by hand:

| Detected file(s) | What to propose |
|---|---|
| `docker-compose.yml`, `docker-compose.*.yml`, `compose.yaml` | A one-shot `migrate` service that runs `tortoise -c config.tortoise.TORTOISE_ORM migrate` and exits; mark `app` as `depends_on: { migrate: { condition: service_completed_successfully } }` |
| `Chart.yaml`, `helm/`, `charts/`, `values.yaml` | A Helm `pre-install` + `pre-upgrade` Job (or an init container on the Deployment) running `tortoise ... migrate` |
| `kustomization.yaml` only | A Kubernetes `Job` resource or an init container — same shape, no Helm hook annotations |
| `Procfile` (Heroku/Render/Fly) | A `release:` process running `tortoise ... migrate` |
| GitHub Actions / GitLab CI manifests | A pre-deploy job step; only after the container-level hook exists, not as a substitute |

Always ask the user before generating these files — they're shared-infra changes. Show a
draft, list the files you'll add/modify, then wait for approval.

**Docker Compose pattern:**

```yaml
# docker/docker-compose.yml
services:
  migrate:
    build: { context: .., dockerfile: docker/Dockerfile }
    command: ["uv", "run", "tortoise", "-c", "config.tortoise.TORTOISE_ORM", "migrate"]
    env_file: ../.env
    depends_on:
      db: { condition: service_healthy }
    restart: "no"

  app:
    build: { context: .., dockerfile: docker/Dockerfile }
    depends_on:
      migrate: { condition: service_completed_successfully }
      db:      { condition: service_healthy }
    ports: ["8000:8000"]
```

`restart: "no"` is essential — a migration container must not loop on success.

**Helm pattern (pre-install + pre-upgrade Job):**

```yaml
# charts/<app>/templates/migrate-job.yaml
apiVersion: batch/v1
kind: Job
metadata:
  name: {{ include "app.fullname" . }}-migrate-{{ .Release.Revision }}
  annotations:
    "helm.sh/hook": pre-install,pre-upgrade
    "helm.sh/hook-weight": "-5"
    "helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded
spec:
  backoffLimit: 0           # fail fast; don't replay a broken migration
  template:
    spec:
      restartPolicy: Never
      containers:
        - name: tortoise-migrate
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
          command: ["uv", "run", "tortoise", "-c", "config.tortoise.TORTOISE_ORM", "migrate"]
          envFrom:
            - secretRef: { name: {{ include "app.fullname" . }}-db }
```

`backoffLimit: 0` makes a failed migration abort the release — that's the behavior you want.

**Init-container alternative** (when you can't add Helm hooks): put `tortoise ... migrate` in
an `initContainers` entry on the Deployment. Trade-off: it runs on every pod start instead of
once per release, so prefer the Job approach — it runs migrations once per release and avoids
racing concurrent pod starts.

Full migration patterns: `references/migrations.md`.

## Testing

Use the modern `tortoise_test_context()` pattern (Tortoise 1.0+), **not** the legacy
`TestCase` / `initializer`. Full setup in `references/testing.md`.

- Unit tests for service-layer logic: target `BaseInvoice` (the abstract interface), no DB needed.
- Integration tests: ephemeral SQLite-memory + `generate_schemas` in a fixture, or testcontainers postgres.

## Anti-patterns (catch these in review)

| Anti-pattern | Why it's bad | Fix |
|---|---|---|
| `models.py` as a single file | Violates Rule 0/1 | Convert to `models/` subpackage |
| Concrete model with no `Base*` | Violates Rule 2; untestable in unit tests | Extract abstract interface |
| `class Meta` not first | Violates Rule 3 | Move it up |
| `Tortoise.init` per request | Connection storm | Init once in lifespan |
| `generate_schemas()` in prod | Bypasses migrations | Use `tortoise migrate` |
| `aerich` anywhere (dep, `aerich.models`, `[tool.aerich]`, `aerich upgrade`) | Legacy tool; forbidden in this codebase | Use the built-in `tortoise` migration CLI |
| Raw f-string SQL | SQL injection | Use ORM / parameterized `.raw()` |
| `for x in qs: await x.related` | N+1 | `prefetch_related` / `select_related` |
| Forgetting `await` | Coroutine warnings, silent no-ops | Lint with ruff `ASYNC` rules |
| Catching bare `Exception` | Masks `DoesNotExist`, `IntegrityError` | Catch specific Tortoise exceptions |
| Mutating model in signal handlers | Hidden side effects | Move to explicit service methods |
| Soft delete on every model | Bloats schema | Opt-in via `BaseSoftDelete` mixin |

## Procedure for Creating a New Model

1. **Identify the sub-app.** If it doesn't exist, create `apps/<name>/{models,schemas,services,routers}/` with `__init__.py` in each.
2. **Create `apps/<name>/models/<entity>.py`.**
3. **Define `Base<Entity>`** first — abstract, declares fields + pure helpers. `Meta.abstract = True`. `class Meta` is the first member.
4. **Define `<Entity>`** — inherits `Base<Entity>`, adds relations + concrete `Meta` (table, indexes, ordering).
5. **Order members:** Meta → fields → `__str__`/`__repr__`/private → public methods.
6. **Re-export** `Base<Entity>` and `<Entity>` from `models/__init__.py`.
7. **Register** the models module in `config/tortoise.py` if it's a new app.
8. **`tortoise -c config.tortoise.TORTOISE_ORM makemigrations --name add_<entity>`** then `tortoise -c config.tortoise.TORTOISE_ORM migrate`.
9. **Add tests** against `Base<Entity>` for pure logic; integration test for the concrete model.
10. **Check for deployment manifests** (Helm chart, docker-compose, Procfile, kustomize). If present and there is no migration service/hook yet, propose adding one (see "Deployment integration" above) so `tortoise ... migrate` runs automatically before the app starts.

## Reference Files

| File | Topic |
|------|-------|
| `references/models.md` | Field types, relations, inheritance, abstract bases, constraints, indexes |
| `references/queries.md` | QuerySet API, prefetch, F/Q expressions, bulk ops, annotate/aggregate, raw SQL |
| `references/migrations.md` | Built-in `tortoise` CLI setup, data migrations, downgrade strategy, CI integration |
| `references/testing.md` | pytest + tortoise_test_context, fixtures, mocking via Base* |
| `references/fastapi.md` | Lifespan, exception handlers, Pydantic creators, dependency-injected sessions |

## File-Naming Convention

**Never use leading-underscore filenames** (`_base.py`, `_utils.py`, `_helpers.py`). Python's
underscore-prefix convention applies to *symbols* (functions, attributes), not to modules.
A module is public infrastructure — if it's not meant to be imported, it shouldn't exist.

Use plain names: `base.py`, `utils.py`, `helpers.py`. Mark intra-module symbols private with
a leading underscore on the symbol itself (`_internal_helper`), and curate the public
surface with `__all__` in `__init__.py`.

## Rules Recap

1. **User project rules win.** Read `CLAUDE.md` before applying any pattern here.
2. **Rules 0–4 are non-negotiable.** No exceptions, no shortcuts.
3. **No private filenames.** `base.py`, never `_base.py`. Privacy is for symbols, not modules.
4. **Models are testable through their `Base*` interface.** If you can't unit-test it without a DB, the abstraction is wrong.
5. **Migrations are source code.** Commit, review, never hand-edit applied ones.
6. **Async-first.** Every DB call is `await`-ed; never mix sync sessions.

