SQLAlchemy has been my default ORM for four years. Every project, every team, every production deployment. I never questioned it because it worked and everyone used it.
But then the cracks started showing. Complex queries became verbose. Async setup felt tricky. New developers struggled for weeks just to understand session management. It worked, but it was slowing everything down.
Friend link for nonmembers- https://medium.com/@inprogrammer/i-tested-5-python-orms-one-replaced-sqlalchemy-completely-e423699a1ac8?sk=e87b360934f7ab1529b2901edffc1aa8
So I decided to test it properly. I compared five Python ORMs on the same FastAPI app with PostgreSQL. Same backend, same logic, real production scenarios.
One of them completely replaced SQLAlchemy in my stack.
Here is the breakdown of what actually works in production and what does not.
The Test Setup
The application had a realistic schema with users, orders, products, and relationships between them. I ran five categories of operations on each ORM. Basic CRUD, complex queries with filters and joins, bulk operations, async concurrent requests, and migration management.
Performance numbers are from a local PostgreSQL instance with 100,000 rows per table. Production results will vary but the relative differences hold.
SQLAlchemy
SQLAlchemy is the most powerful and most widely used Python ORM. It has two APIs: the Core for direct SQL construction and the ORM for model-based queries. Most developers use the ORM layer.
What it does well:
SQLAlchemy handles complex queries better than anything else tested. When I needed a query joining four tables with conditional filters and aggregations, it handled it cleanly with full control over the generated SQL.
result = await db.execute(
select(Order)
.join(User, Order.user_id == User.id)
.where(Order.status == "pending")
.options(selectinload(Order.items))
.order_by(Order.created_at.desc())
)
orders = result.scalars().all()The async support via asyncpg works well once configured correctly. The 2.0 API is cleaner than the old version.
Where it falls short:
Setup is verbose. Getting async SQLAlchemy running correctly requires configuring the engine, session factory, and dependency injection separately. New developers consistently struggle with the session lifecycle, especially understanding when to commit, when to refresh, and when sessions expire.
Migration management via Alembic is powerful but adds another tool, another configuration file, and another set of concepts to learn.
Performance: Fast on complex queries. Session overhead adds latency on simple operations.
Verdict: Still the right choice for complex schemas and teams with SQLAlchemy experience. Not the fastest path to productivity for new projects.
Tortoise ORM
Tortoise ORM was built specifically for async Python. It uses a Django-inspired API which means developers familiar with Django's ORM feel immediately comfortable.
from tortoise import fields
from tortoise.models import Model
class Order(Model):
id = fields.IntField(pk=True)
user = fields.ForeignKeyField("models.User", related_name="orders")
status = fields.CharField(max_length=50)
created_at = fields.DatetimeField(auto_now_add=True)
class Meta:
table = "orders"What it does well:
The async-first design means you never accidentally block the event loop. Every database operation is awaitable by default, which eliminates the most common FastAPI performance mistake.
The Django-inspired API reduces onboarding time significantly. Developers who have used Django ORM can be productive in Tortoise within an hour.
orders = await Order.filter(status="pending").prefetch_related("user", "items")One line for a query that requires five lines in SQLAlchemy. The difference compounds across a codebase.
Where it falls short:
Complex queries hit limits faster than SQLAlchemy. When I needed raw SQL for a specific aggregation, Tortoise's raw query API felt less mature than SQLAlchemy's Core.
Migration tooling via Aerich is functional but less battle-tested than Alembic. On one migration involving a complex schema change, it generated incorrect SQL that required manual correction.
Performance: Excellent on simple to medium queries. Slightly slower than SQLAlchemy on complex joins.
Verdict: Strong choice for async FastAPI applications where developer experience matters and queries stay relatively straightforward.
Peewee
Peewee is the minimalist option. Small API, minimal dependencies, and a focus on simplicity over features.
from peewee import *
database = PostgresqlDatabase("mydb", user="user", password="pass")
class Order(Model):
status = CharField()
created_at = DateTimeField()
class Meta:
database = databaseWhat it does well:
Peewee is genuinely simple. The entire API fits in your head after an afternoon of reading. For small applications and scripts that need database access, it is the fastest path from zero to working code.
Where it falls short:
No native async support. Every query blocks the event loop inside an async FastAPI route. Under concurrent load this creates exactly the performance problem that async Python exists to solve.
I tested Peewee with peewee-async as a workaround but the library felt like it was fighting against the framework rather than working with it.
Performance: Fast on simple queries. Serious performance problems under concurrent async load.
Verdict: Good for scripts and small synchronous applications. Not suitable for production async web APIs.
SQLModel
SQLModel is built by the creator of FastAPI and designed to work seamlessly with Pydantic v2. It combines SQLAlchemy under the hood with a Pydantic-style model definition API.
from sqlmodel import Field, SQLModel
class Order(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
user_id: int = Field(foreign_key="user.id")
status: str
amount: floatWhat it does well:
The same model definition serves as both the database schema and the Pydantic validation model. In a FastAPI application this eliminates an entire layer of duplication. You define the data shape once and it works everywhere.
@app.post("/orders", response_model=Order)
async def create_order(order: Order, db: AsyncSession = Depends(get_db)):
db.add(order)
await db.commit()
await db.refresh(order)
return orderThe integration with FastAPI's type system is seamless. Validation, serialization, and database persistence all use the same model. This is the integration I did not know I needed until I used it.
Where it falls short:
SQLModel is relatively young. Some edge cases in complex relationship definitions hit issues that require falling back to raw SQLAlchemy syntax. The documentation covers the common cases well but gaps appear on advanced usage.
Async support works through SQLAlchemy's async engine, which means the same setup complexity exists underneath the cleaner API.
Performance: Matches SQLAlchemy since it uses SQLAlchemy under the hood.
Verdict: The most natural choice for FastAPI applications using Pydantic. The elimination of model duplication alone justifies the switch for most projects.
Piccolo ORM
Piccolo is the one that surprised me.
I included it expecting it to be a niche option. It turned out to be the most complete async-first ORM in the test with the best developer experience for FastAPI applications.
from piccolo.columns import Varchar, Integer, Timestamp
from piccolo.table import Table
class Order(Table):
status = Varchar(length=50)
amount = Integer()
created_at = Timestamp()What it does well:
Piccolo was built async from the ground up. Not retrofitted, not wrapped. Every operation is properly async with no workarounds needed.
The query API is clean and chainable:
orders = await Order.select().where(
Order.status == "pending"
).order_by(Order.created_at, ascending=False).limit(20)What genuinely impressed me was Piccolo Admin. It ships with a built-in admin interface that connects to your models automatically. The one feature I missed most when leaving Django was the admin panel. Piccolo brings it to async FastAPI development without any additional setup.
from piccolo_admin.endpoints import create_admin
from piccolo.engine import engine_finder
app.mount("/admin/", create_admin(tables=[Order, User, Product]))Three lines for a fully functional production admin interface.
Piccolo's migration system is also the cleanest of the five ORMs tested. Migrations are Python files that read like actual code rather than auto-generated scripts that require manual verification.
Where it falls short:
Smaller community than SQLAlchemy or Django ORM. When I hit an unusual query requirement, the documentation gaps were more frequent. Stack Overflow answers for Piccolo specific problems are sparse.
The relationship API for complex multi-table joins requires more verbose syntax than the simple cases suggest.
Performance: The fastest of the five on async concurrent requests. The async-first architecture shows measurable differences under load.
Performance Results
Simple select query: Piccolo 3.1ms, Tortoise 3.8ms, SQLModel 4.1ms, SQLAlchemy 4.2ms
Join query: Piccolo 7.4ms, Tortoise 9.2ms, SQLModel 8.9ms, SQLAlchemy 8.7ms
Bulk insert 1000 rows: Piccolo 241ms, Tortoise 298ms, SQLModel 309ms, SQLAlchemy 312ms
100 concurrent async requests: Piccolo 71ms avg, Tortoise 87ms avg, SQLModel 91ms avg, SQLAlchemy 94ms avg
Piccolo led every category. The concurrent requests difference is the most meaningful in production. 71ms versus 94ms under real load is not a small gap.
Verdict: The most complete async-first ORM available for Python. The built-in admin panel alone changes the calculation for teams building internal tools alongside their APIs.
Which One Actually Replaced SQLAlchemy
Piccolo replaced SQLAlchemy in my production stack. Not because SQLAlchemy is bad but because Piccolo solved the specific problems I was facing.
The admin panel removed the last reason I had to consider adding Django to a project. The async-first architecture eliminated the performance anxiety that came with managing async SQLAlchemy sessions correctly. The migration system removed the Alembic dependency.
The community size concern is real. For a team where SQLAlchemy expertise already exists, the switch cost is hard to justify. For a new project starting from scratch, Piccolo's developer experience advantage compounds over time.
When to Use Each One
Use SQLAlchemy when your schema is complex, your team knows it, and query control matters more than developer experience.
Use SQLModel when you are building a FastAPI application with Pydantic and want to eliminate model duplication. The integration is genuinely seamless.
Use Tortoise ORM when your team has Django experience and you want async support with a familiar API.
Use Piccolo when you need async performance, a built-in admin panel, and are starting a new project without existing ORM investment.
Avoid Peewee for production async web APIs. Use it for scripts and synchronous applications where simplicity matters more than async performance.
CTA
If this helped you choose the right ORM, follow me on Medium. I share real FastAPI and Python production issues with exact fixes and numbers, not theory.
Switched ORMs in production? Drop your experience in the comments, I read every one.
If you are setting up a new FastAPI project and need a complete production toolkit, this guide covers everything: [Best Python FastAPI Production Tools 2026]