Tortoise-ORM from Scratch: Everything You Need to Know About
When it comes to Python ORMs, Django ORM and SQLAlchemy have long dominated the landscape. However, with the rise of asynchronous Python frameworks like FastAPI, Starlette, and Sanic, developers found themselves in need of an ORM that fully embraces asyncio. Enter Tortoise-ORM — a modern, async-first ORM inspired by Django's elegant syntax but built from the ground up to work seamlessly with Python's asynchronous ecosystem.
In this tutorial, we'll walk through everything you need to know about Tortoise-ORM, from installation and model definition to advanced queries, relationships, migrations, and best practices. By the end, you'll be equipped to integrate Tortoise-ORM into your next async Python project with confidence.
What Is Tortoise-ORM?
Tortoise-ORM is an open-source, asynchronous Object-Relational Mapper for Python. It was created to fill the gap left by Django ORM, which is inherently synchronous and difficult to use in async contexts. Tortoise borrows heavily from Django's model definition syntax, making it instantly familiar to anyone who has worked with Django, but it reimagines the internals to be fully non-blocking.
Key characteristics of Tortoise-ORM include:
- Async-native: Every database operation is a coroutine, designed to run within an event loop.
- Django-like syntax: Model definitions, field types, and querysets feel familiar to Django developers.
- Multiple database backends: Supports PostgreSQL, MySQL/MariaDB, and SQLite out of the box.
- Lightweight: Minimal dependencies and a small footprint compared to SQLAlchemy.
- Type-annotated: Works well with modern Python type hints and IDE autocompletion.
- Migration support: Ships with
Aerich, a migration tool similar to Django's migration system.
Why Tortoise-ORM Matters
Traditional ORMs like Django ORM and SQLAlchemy (in its synchronous mode) block the event loop when they perform database operations. In an async application, this means that while one request waits for a database query to return, no other request can be processed — effectively defeating the entire purpose of using async. Workarounds like running synchronous ORM calls in thread pools exist, but they add complexity and overhead.
Tortoise-ORM solves this problem natively. Because every query is an await-able coroutine, the event loop is free to handle other tasks while the database does its work. This makes Tortoise an ideal choice for high-concurrency applications built on async frameworks.
Additionally, Tortoise's Django-inspired API means teams migrating from Django to FastAPI or similar frameworks face a much smaller learning curve. The mental model transfers directly: define models, use querysets, and let the ORM handle SQL generation.
Getting Started: Installation and Setup
Let's begin by installing Tortoise-ORM. The base package includes SQLite support, but for PostgreSQL or MySQL, you'll need to install the appropriate adapter.
# Install Tortoise-ORM with SQLite support (included by default)
pip install tortoise-orm
# For PostgreSQL support
pip install tortoise-orm asyncpg
# For MySQL support
pip install tortoise-orm aiomysql
# For Aerich (migrations)
pip install aerich
Once installed, the first step in any Tortoise project is initializing the ORM and registering your models. Tortoise needs to know which models exist and which database to connect to. This is done through the Tortoise.init() method.
from tortoise import Tortoise
async def init():
await Tortoise.init(
db_url="sqlite://db.sqlite3",
modules={"models": ["app.models"]}
)
# Generate schema (only for development; use migrations in production)
await Tortoise.generate_schemas()
The db_url parameter follows a standard format: database_type://user:password@host:port/database_name. The modules dictionary maps a namespace (typically "models") to the Python module or package that contains your model definitions. Tortoise will scan that module and register all Model subclasses it finds.
For SQLite, the URL is simply sqlite://path/to/database.sqlite3. For in-memory SQLite (useful for testing), use sqlite://:memory:.
Defining Models
Models in Tortoise are Python classes that inherit from tortoise.models.Model. Each class attribute represents a database column and is defined using a field class from tortoise.fields. Let's look at a practical example.
from tortoise.models import Model
from tortoise import fields
class Author(Model):
id = fields.IntField(pk=True)
name = fields.CharField(max_length=100)
email = fields.CharField(max_length=200, unique=True)
bio = fields.TextField(null=True)
created_at = fields.DatetimeField(auto_now_add=True)
def __str__(self):
return self.name
class Meta:
table = "authors"
Let's break down the key elements:
fields.IntField(pk=True)— Defines an integer primary key. Every model needs a primary key field.fields.CharField(max_length=100)— A varchar field. Themax_lengthparameter is required.unique=True— Ensures no two records share the same value for this field.null=True— Allows the field to be NULL in the database.auto_now_add=True— Automatically sets the field to the current timestamp when the record is created.class Meta— Optional inner class for metadata. Thetableattribute explicitly sets the database table name. Without it, Tortoise derives the table name from the model class name.
Common Field Types
Tortoise provides a rich set of field types to cover most database column types:
from tortoise import fields
# Numeric fields
id = fields.IntField(pk=True)
age = fields.SmallIntField()
big_number = fields.BigIntField()
price = fields.DecimalField(max_digits=10, decimal_places=2)
rating = fields.FloatField()
# Text fields
title = fields.CharField(max_length=200)
description = fields.TextField()
slug = fields.CharField(max_length=100, unique=True)
# Boolean and date/time fields
is_active = fields.BooleanField(default=True)
created_at = fields.DatetimeField(auto_now_add=True)
updated_at = fields.DatetimeField(auto_now=True)
published_on = fields.DateFieldField(null=True)
# Binary and JSON fields
data = fields.JSONField(default=dict)
file_content = fields.BinaryField(null=True)
# UUID field
uuid = fields.UUIDField(pk=True)
The auto_now=True flag on DatetimeField updates the timestamp every time the record is saved, while auto_now_add=True only sets it on creation. The UUIDField is particularly useful when you want globally unique identifiers as primary keys instead of auto-incrementing integers.
Database Connections
In a real application, you need to manage the database connection lifecycle properly. Tortoise provides Tortoise.init() to set up the connection and Tortoise.close_connections() to tear it down. Let's look at how this integrates with different frameworks.
Standalone Asyncio Application
import asyncio
from tortoise import Tortoise
async def main():
await Tortoise.init(
db_url="sqlite://db.sqlite3",
modules={"models": ["__main__"]}
)
await Tortoise.generate_schemas()
# Your application logic here
# ...
await Tortoise.close_connections()
if __name__ == "__main__":
asyncio.run(main())
Integration with FastAPI
FastAPI is one of the most popular async frameworks, and Tortoise pairs with it beautifully. The recommended approach is to use FastAPI's lifespan events to initialize and close the Tortoise connection.
from contextlib import asynccontextmanager
from fastapi import FastAPI
from tortoise import Tortoise
@asynccontextmanager
async def lifespan(app: FastAPI):
await Tortoise.init(
db_url="postgres://user:password@localhost:5432/mydb",
modules={"models": ["app.models"]}
)
await Tortoise.generate_schemas()
yield
await Tortoise.close_connections()
app = FastAPI(lifespan=lifespan)
@app.get("/authors")
async def list_authors():
from app.models import Author
authors = await Author.all()
return [author.name for author in authors]
Alternatively, you can use the tortoise.contrib.fastapi module, which provides a register_tortoise helper that handles the lifecycle automatically:
from fastapi import FastAPI
from tortoise.contrib.fastapi import register_tortoise
app = FastAPI()
register_tortoise(
app,
db_url="postgres://user:password@localhost:5432/mydb",
modules={"models": ["app.models"]},
generate_schemas=True,
add_exception_handlers=True,
)
The add_exception_handlers=True parameter adds handlers that convert Tortoise's DoesNotExist and IntegrityError exceptions into proper HTTP 404 and 409 responses respectively.
CRUD Operations
Now let's explore the core of any ORM: Create, Read, Update, and Delete operations. Tortoise's API is clean and intuitive, closely mirroring Django's queryset interface.
Creating Records
There are two primary ways to create records: using the create() class method or instantiating a model and calling save().
from app.models import Author
# Method 1: Using create() — creates and saves in one step
author = await Author.create(
name="Jane Austen",
email="jane@example.com",
bio="English novelist known for Pride and Prejudice."
)
# Method 2: Instantiate then save
author2 = Author(name="George Orwell", email="george@example.com")
await author2.save()
# Method 3: Using get_or_create — returns (instance, created_bool)
author3, created = await Author.get_or_create(
email="isaac@example.com",
defaults={"name": "Isaac Asimov", "bio": "Science fiction author."}
)
The get_or_create method is particularly useful for avoiding duplicate records. It attempts to find a record matching the keyword arguments; if none exists, it creates one using both the lookup arguments and the defaults dictionary.
Reading Records
Tortoise offers several methods for querying data, each suited to different scenarios.
# Get all records
authors = await Author.all()
# Get a single record by primary key
author = await Author.get(id=1)
# Get a single record by any field (raises DoesNotExist if not found)
author = await Author.get(email="jane@example.com")
# Filter records
active_authors = await Author.filter(is_active=True)
# Get the first matching record (returns None if not found)
author = await Author.filter(name="Jane Austen").first()
# Count records
count = await Author.filter(is_active=True).count()
# Check if any records exist
exists = await Author.filter(name="Jane Austen").exists()
# Get specific fields only (returns a list of dicts)
names = await Author.all().values("name", "email")
# Order results
authors = await Author.all().order_by("-created_at") # descending
# Limit and offset (pagination)
page = await Author.all().offset(20).limit(10)
One important note: unlike Django, where querysets are lazily evaluated, Tortoise querysets must be awaited. The filter() and all() methods return a queryset object, but you need to await it (or call a terminal method like count() or first()) to actually execute the query.
Updating Records
# Update a single instance
author = await Author.get(id=1)
author.bio = "Updated biography text."
await author.save()
# Bulk update via queryset
await Author.filter(is_active=False).update(is_active=True)
# Update specific fields only (more efficient)
author = await Author.get(id=1)
author.name = "New Name"
await author.save(update_fields=["name"])
The save(update_fields=[...]) approach is useful when you only want to persist changes to specific columns, avoiding unnecessary updates to other fields (like auto_now timestamps that you don't want to trigger).
Deleting Records
# Delete a single instance
author = await Author.get(id=1)
await author.delete()
# Bulk delete via queryset
await Author.filter(is_active=False).delete()
Relationships
Real-world data models involve relationships between entities. Tortoise supports three main types: ForeignKey, ManyToMany, and OneToOne.
ForeignKey Relationships
A ForeignKey represents a many-to-one relationship. For example, a book belongs to one author, but an author can have many books.
from tortoise.models import Model
from tortoise import fields
class Author(Model):
id = fields.IntField(pk=True)
name = fields.CharField(max_length=100)
email = fields.CharField(max_length=200, unique=True)
def __str__(self):
return self.name
class Book(Model):
id = fields.IntField(pk=True)
title = fields.CharField(max_length=200)
author = fields.ForeignKeyField("models.Author", related_name="books")
published_date = fields.DateFieldField(null=True)
price = fields.DecimalField(max_digits=10, decimal_places=2, default=0)
def __str__(self):
return self.title
The first argument to ForeignKeyField is a string in the format "namespace.ModelName". The related_name parameter defines the reverse relation — how you access books from an author instance.
Working with foreign keys:
# Create an author
author = await Author.create(name="J.K. Rowling", email="jk@example.com")
# Create a book linked to the author
book = await Book.create(title="Harry Potter", author=author, price=29.99)
# Access the foreign key (requires prefetching or await)
book_obj = await Book.get(id=1)
book_author = await book_obj.author # Await the related object
# Access reverse relation
author_obj = await Author.get(id=1)
author_books = await author_obj.books.all() # Get all books by this author
# Filter using foreign key
books_by_author = await Book.filter(author_id=author.id).all()
Prefetching Related Data
Without prefetching, accessing related objects triggers a separate query for each access (the N+1 problem). Tortoise provides prefetch_related and fetch_related to solve this.
# Prefetch when querying
authors = await Author.all().prefetch_related("books")
for author in authors:
print(f"{author.name} has written:")
for book in author.books:
print(f" - {book.title}")
# Fetch related on a single instance
author = await Author.get(id=1)
await author.fetch_related("books")
for book in author.books:
print(book.title)
ManyToMany Relationships
A ManyToMany relationship connects two models where each instance of one model can be associated with multiple instances of the other, and vice versa. For example, a book can have multiple categories, and a category can contain multiple books.
class Category(Model):
id = fields.IntField(pk=True)
name = fields.CharField(max_length=100, unique=True)
def __str__(self):
return self.name
class Book(Model):
id = fields.IntField(pk=True)
title = fields.CharField(max_length=200)
author = fields.ForeignKeyField("models.Author", related_name="books")
categories = fields.ManyToManyField("models.Category", related_name="books",
through="book_categories")
def __str__(self):
return self.title
Working with ManyToMany fields:
# Create categories
fiction = await Category.create(name="Fiction")
scifi = await Category.create(name="Science Fiction")
# Create a book
book = await Book.create(title="Dune", author=author)
# Add categories to the book
await book.categories.add(fiction)
await book.categories.add(scifi)
# Or add multiple at once
await book.categories.add(fiction, scifi)
# Remove a category
await book.categories.remove(fiction)
# Get all categories for a book
categories = await book.categories.all()
# Get all books in a category
category = await Category.get(name="Fiction")
books = await category.books.all()
# Clear all categories
await book.categories.clear()
The optional through parameter lets you specify a custom name for the intermediary table. If omitted, Tortoise generates one automatically.
OneToOne Relationships
A OneToOne relationship is essentially a ForeignKey with a uniqueness constraint. It's useful when one model extends another, such as a user profile extending a user.
class User(Model):
id = fields.IntField(pk=True)
username = fields.CharField(max_length=50, unique=True)
class Profile(Model):
id = fields.IntField(pk=True)
user = fields.OneToOneField("models.User", related_name="profile")
bio = fields.TextField(null=True)
avatar_url = fields.CharField(max_length=500, null=True)
# Usage
user = await User.create(username="alice")
profile = await Profile.create(user=user, bio="Hello world!")
# Access from user side
user = await User.get(username="alice")
await user.fetch_related("profile")
print(user.profile.bio)
# Access from profile side
profile = await Profile.get(id=1)
user = await profile.user
Advanced Querying
Tortoise supports a rich filtering syntax that allows you to express complex conditions using Django-style field lookups with double-underscore separators.
Filter Lookups
# Exact match (default)
await Book.filter(title="Dune")
# Case-insensitive exact match
await Book.filter(title__iexact="dune")
# Contains (case-sensitive)
await Book.filter(title__contains="Harry")
# Contains (case-insensitive)
await Book.filter(title__icontains="harry")
# Starts with / ends with
await Book.filter(title__startswith="Harry")
await Book.filter(title__endswith="Potter")
# In a list of values
await Book.filter(title__in=["Dune", "Foundation", "1984"])
# Comparison operators
await Book.filter(price__gt=20) # greater than
await Book.filter(price__gte=20) # greater than or equal
await Book.filter(price__lt=50) # less than
await Book.filter(price__lte=50) # less than or equal
await Book.filter(price__range=(10, 50)) # between 10 and 50
# Date lookups
await Book.filter(published_date__year=2023)
await Book.filter(published_date__month=6)
await Book.filter(published_date__day=15)
# Null checks
await Book.filter(published_date__isnull=True)
# Related field lookups (traverse relationships)
await Book.filter(author__name="J.K. Rowling")
await Book.filter(author__name__icontains="rowling")
Combining Filters with Q Objects
For complex OR conditions or nested logic, Tortoise provides Q objects, similar to Django.
from tortoise.expressions import Q
# OR condition: books by Jane Austen OR priced under 15
books = await Book.filter(
Q(author__name="Jane Austen") | Q(price__lt=15)
)
# AND condition (same as chaining filters)
books = await Book.filter(
Q(price__gt=20) & Q(title__icontains="potter")
)
# Negation
books = await Book.filter(
~Q(author__name="George Orwell")
)
# Combining AND, OR, and NOT
books = await Book.filter(
(Q(price__lt=20) | Q(author__name="Jane Austen")) & ~Q(title__contains="Twilight")
)
Aggregation and Annotation
Tortoise supports aggregation functions like Sum, Count, Avg, Min, and Max through its expression API.
from tortoise.functions import Count, Sum, Avg, Max, Min
from tortoise.expressions import F
# Count books per author
authors = await Author.annotate(book_count=Count("books")).filter(book_count__gt=5)
# Sum of all book prices for an author
authors = await Author.annotate(
total_revenue=Sum("books__price")
).filter(total_revenue__gt=1000)
# Average price
avg_price = await Book.annotate(avg=Avg("price")).values("avg")
# Max and min price
stats = await Book.annotate(
max_price=Max("price"),
min_price=Min("price")
).values("max_price", "min_price")
# Group by
from tortoise.functions import Count
result = await Book.annotate(
count=Count("id")
).group_by("author_id").values("author_id", "count")
Raw SQL Queries
When the ORM's query builder isn't enough, Tortoise allows you to execute raw SQL while still returning model instances.
from tortoise.models import Model
# Raw query returning model instances
books = await Book.raw("SELECT * FROM books WHERE price > %s", [20])
# Raw query returning dictionaries
from tortoise import connections
conn = connections.get("default")
results = await conn.execute_query(
"SELECT title, COUNT(*) as cnt FROM books GROUP BY title HAVING cnt > 1"
)
for row in results:
print(row)
Transactions
Transactions ensure that a group of database operations either all succeed or all fail, maintaining data integrity. Tortoise provides a clean context manager for transactions.
from tortoise.transactions import in_transaction
async def transfer_ownership(book_id, new_author_id):
async with in_transaction():
book = await Book.get(id=book_id)
old_author = await book.author
book.author_id = new_author_id
await book.save()
# If anything above fails, the entire transaction rolls back
# If we reach here, all changes are committed
You can also use atomic() as a decorator:
from tortoise.transactions import atomic
@atomic()
async def create_author_with_books(author_data, books_data):
author = await Author.create(**author_data)
for book_data in books_data:
await Book.create(author=author, **book_data)
return author
Migrations with Aerich
In production, you should never use generate_schemas() to manage your database schema. Instead, use Aerich, Tortoise's migration tool. Aerich tracks schema changes in migration files, similar to Django's migration system.
Setting Up Aerich
First, create a configuration file named aerich.ini or use a pyproject.toml section. Here's the pyproject.toml approach:
[tool.aerich]
tortoise_orm = "app.db.TORTOISE_ORM"
location = "./migrations"
src_folder = "."
The tortoise_orm key points to a variable in your code that contains the Tortoise configuration dictionary. Define it like this:
# app/db.py
TORTOISE_ORM = {
"connections": {
"default": "postgres://user:password@localhost:5432/mydb"
},
"apps": {
"models": {
"models": ["app.models", "aerich.models"],
"default_connection": "default",
}
},
}
Notice that "aerich.models" is included in the models list — this is required for Aerich to track its own migration state.
Running Migrations
# Initialize Aerich (creates the migrations directory and aerich table)
aerich init -t aerich.ini
# Initialize the database
aerich init-db
# Create a new migration after changing models
aerich migrate --name added_book_field
# Apply pending migrations
aerich upgrade
# Rollback the last migration
aerich downgrade
# View migration history
aerich history
The workflow is straightforward: modify your models, run aerich migrate to generate a migration file, then run aerich upgrade to apply it. The generated migration files are Python scripts that you can review and edit before applying.
Model Validation and Pydantic Integration
Tortoise provides built-in support for generating Pydantic models from your Tortoise models. This is incredibly useful when building APIs, as it gives you automatic request/response validation and OpenAPI schema generation.
from tortoise.contrib.pydantic import pydantic_model_creator, pydantic_queryset_creator
from app.models import Author, Book
# Create a Pydantic model for a single Author
Author_Pydantic = pydantic_model_creator(Author)
# Create a Pydantic model for a queryset of Authors
AuthorIn_Pydantic = pydantic_model_creator(Author, name="AuthorIn", exclude_readonly=True)
# Create a Pydantic model for a list of Authors
AuthorList_Pydantic = pydantic_queryset_creator(Author)
The exclude_readonly=True flag excludes fields like id, created_at, and other auto-managed fields, making the resulting model suitable for input validation (POST/PUT bodies).
Using these in a FastAPI endpoint:
from fastapi import FastAPI, HTTPException
app = FastAPI()
@app.post("/authors", response_model=Author_Pydantic)
async def create_author(author: AuthorIn_Pydantic):
author_obj = await Author.create(**author.dict())
return await Author_Pydantic.from_tortoise_orm(author_obj)
@app.get("/authors/{author_id}", response_model=Author_Pydantic)
async def get_author(author_id: int):
author = await Author.get_or_none(id=author_id)
if not author:
raise HTTPException(status_code=404, detail="Author not found")
return await Author_Pydantic.from_tortoise_orm(author)
@app.get("/authors", response_model=AuthorList_Pydantic)
async def list_authors():
return await Author.all()
Best Practices
Now that we've covered the fundamentals, let's discuss best practices that will help you write maintainable, performant Tortoise-ORM code.
1. Always Use Migrations in Production
Never call Tortoise.generate_schemas() in production. It's a destructive operation that can drop and recreate tables. Use Aerich migrations for all schema changes. Reserve generate_schemas() for development, testing, and prototyping only.
2. Avoid the N+1 Query Problem
Always use prefetch_related or fetch_related when you know you'll be accessing related objects in a loop. Without prefetching, each access to a related field triggers a separate database query.
# BAD: N+1 queries
authors = await Author.all()
for author in authors:
books = await author.books.all() # One query per author!
# GOOD: 2 queries total
authors = await Author.all().prefetch_related("books")
for author in authors:
for book in author.books:
print(book.title)
3. Use Selective Field Retrieval
When you only need a few fields from a large table, use only() or values() to avoid fetching unnecessary data.
# Only fetch the fields you need
authors = await Author.all().only("name", "email")
# Or get plain dictionaries instead of model instances
names = await Author.all().values("name", "email")
4. Use Transactions for Multi-Step Operations
Any time you perform multiple write operations that should be atomic, wrap them in a transaction. This prevents partial updates if something fails midway.
5. Organize Models in a Dedicated Module
Keep all your model definitions in a single models.py file or a models/ package. This makes it easier for Tortoise to discover them and for developers to find them. If you split models across multiple files, use a models/__init__.py that imports all of them.
# models/__init__.py
from .author import Author
from .book import Book
from .category import Category
__all__ = ["Author", "Book", "Category"]
6. Use Environment Variables for Database URLs
Never hardcode database credentials in your source code. Use environment variables or a configuration library.
import os
from tortoise import Tortoise
DB_URL = os.getenv("DATABASE_URL", "sqlite://db.sqlite3")
async def init_db():
await Tortoise.init(
db_url=DB_URL,
modules={"models": ["app.models"]}
)
7. Handle DoesNotExist Gracefully
The get() method raises DoesNotExist if no record is found. Use get_or_none() when you want to handle missing records without try/except blocks.
# Instead of this:
try:
author = await Author.get(id=999)
except Author.DoesNotExist:
author = None
# Do this:
author = await Author.get_or_none(id=999)
8. Use Indexes for Frequently Queried Fields
If you frequently filter or order by a particular field, add an index to improve query performance.
class Book(Model):
id = fields.IntField(pk=True)
title = fields.CharField(max_length=200, index=True)
isbn = fields.CharField(max_length=13, unique=True)
class Meta:
table = "books"
indexes = [("title", "published_date")] # Composite index
9. Leverage Connection Pooling for Production
For production applications with PostgreSQL or MySQL, configure connection pooling to reuse database connections efficiently.
TORTOISE_ORM = {
"connections": {
"default": {
"engine": "tortoise.backends.asyncpg",
"credentials": {
"host": "localhost",
"port": "5432",
"user": "user",
"password": "password",
"database": "mydb",
},
"minsize": 5,
"maxsize": 20,
}
},
"apps": {
"models": {
"models": ["app.models", "aerich.models"],
"default_connection": "default",
}
},
}
10. Write Tests with an In-Memory Database
For unit tests, use SQLite in-memory mode for speed. Tortoise makes this easy with its test utilities.
import pytest
from tortoise import Tortoise
@pytest.fixture
async def db():
await Tortoise.init(
db_url="sqlite://:memory:",
modules={"models": ["app.models"]}
)
await Tortoise.generate_schemas()
yield
await Tortoise.close_connections()
@pytest.mark.asyncio
async def test_create_author(db):
author = await Author.create(name="Test Author", email="test@test.com")
assert author.id is not None
assert author.name == "Test Author"
Conclusion
Tortoise-ORM is a powerful, elegant, and genuinely async ORM that brings the developer-friendly syntax of Django to the asynchronous Python world. Throughout this tutorial, we've covered everything from installation and model definition to CRUD operations, relationships, advanced querying, transactions, migrations with Aerich, Pydantic integration, and production best practices. The ORM's intuitive API, combined with its full async support, makes it an excellent choice for any modern Python project — especially those built on FastAPI, Starlette, or other async frameworks. While it may not have the decades of maturity or the sheer feature depth of SQLAlchemy, Tortoise more than makes up for it with simplicity, readability, and a design philosophy that gets out of your way. Whether you're building a small API or a large-scale async application, Tortoise-ORM provides the tools you need to work with databases efficiently and elegantly. Start small, follow the best practices outlined here, and you'll find that Tortoise grows with your project's needs.