I used SQLAlchemy for 4 years. Then I tested another ORM for one project — and never went back.
SQLAlchemy was my go-to ORM for almost four years. I used it in every project and never really thought about changing it because it worked well and almost everyone used it.
But after some time, problems started becoming obvious. Simple things began taking too much code. Async setup was confusing. New developers on the team needed weeks just to understand how sessions worked.
SQLAlchemy was still powerful, but it no longer felt simple or fast to work with.
So I decided to test other options properly. I tried five different Python ORMs using the same FastAPI app and the same PostgreSQL database. Same project, same logic, same real production-type setup.
One of those ORMs worked so well that it replaced SQLAlchemy completely in my workflow.
Here’s what worked well in real projects — and what caused problems.
The Test Setup
The app I tested was close to a real production app. It had users, products, orders, and relationships between different tables. I tested every ORM on the same FastAPI app and PostgreSQL database.
I checked five main things:
- Basic CRUD operations
- Complex queries with joins and filters
- Bulk inserts and updates
- Async performance with multiple requests
- Database migrations
The database had around 100,000 rows in each table. The exact numbers will change in production, but the overall differences between the ORMs stayed very similar.
SQLAlchemy
SQLAlchemy is the most popular Python ORM and probably the most powerful one too. It gives developers a lot of control over database queries and supports both raw SQL-style queries and model-based ORM queries.
What it does well
SQLAlchemy was easily the best when queries became complicated.
I tested queries with multiple table joins, filters, and aggregations, and SQLAlchemy handled them without problems. It gave full control over the generated SQL and was flexible enough for almost every situation.
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 with asyncpg works well after everything is set up properly. The newer 2.0 version also feels cleaner and easier than the older SQLAlchemy API.
Where it becomes difficult
The setup process is still complicated.
To use async SQLAlchemy correctly, you have to configure the database engine, session handling, and dependency injection separately. New developers on the team often struggled to understand how sessions work — especially when to commit changes, refresh data, or handle expired sessions.
Database migrations with Alembic are powerful, but they also add extra setup and more things to learn.
Performance
SQLAlchemy was very fast for complex queries.
But for simple operations, the session system added a little extra overhead and latency.
Verdict
SQLAlchemy is still one of the best choices for large and complex applications, especially if the team already knows SQLAlchemy well.
But for new projects, it is not always the fastest or simplest option for developer productivity.
Tortoise ORM
Tortoise ORM was designed mainly for async Python applications. Its API feels very similar to Django ORM, so developers who have used Django can start using it quickly.
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 biggest advantage of Tortoise ORM is that it was built for async applications from the start.
All database operations use await by default, so it is much harder to accidentally block the event loop — which is one of the most common performance problems in FastAPI apps.
The API also feels very similar to Django ORM. Because of that, developers who already know Django can learn Tortoise very quickly and usually become comfortable with it within a few hours.
orders = await Order.filter(status="pending").prefetch_related("user", "items")
Some queries that needed five lines of code in SQLAlchemy could be written in just one line in Tortoise ORM. Over a large codebase, that difference saves a lot of time and reduces complexity.
Where it struggles
Tortoise ORM starts showing limitations when queries become very complex.
For advanced aggregations and custom SQL queries, I still had to use raw SQL, and the raw query support did not feel as polished or flexible as SQLAlchemy.
Its migration tool, Aerich, works fine for basic cases but does not feel as reliable as Alembic. During one complex schema migration, it generated incorrect SQL and I had to fix it manually.
Performance
Tortoise ORM performed very well for simple and medium-sized queries.
But on heavy joins and more complex queries, SQLAlchemy was slightly faster.
Verdict
Tortoise ORM is a great choice for async FastAPI applications where developer experience and clean code matter more than handling extremely complex queries.
Peewee
Peewee is the lightweight option among all the ORMs I tested. It keeps the API small, avoids unnecessary complexity, and focuses mainly on simplicity.
from peewee import *
database = PostgresqlDatabase("mydb", user="user", password="pass")
class Order(Model):
status = CharField()
created_at = DateTimeField()
class Meta:
database = database
What it does well
Peewee is extremely simple and easy to learn. You can understand most of the API in just a few hours.
For small apps, scripts, or quick projects that need a database, it is one of the fastest ways to get working code.
Where it struggles
The biggest problem is that Peewee does not support async natively.
In async FastAPI applications, every database query blocks the event loop, which hurts performance when many requests come in at the same time.
I also tested it with peewee-async, but the experience did not feel smooth. It felt more like a workaround than a proper async solution.
Performance
Peewee was fast for simple queries and small workloads.
But under heavy async traffic and concurrent requests, performance problems became very noticeable.
Verdict
Peewee is great for scripts and small synchronous applications.
But for modern async production APIs, it is not a good fit.
SQLModel
SQLModel was created by the same developer behind FastAPI and is built to work smoothly with Pydantic v2.
It uses SQLAlchemy internally, but the API feels much cleaner and more modern, with model definitions that look very similar to Pydantic models.
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: float
What it does well
The best thing about SQLModel is that the same model works for both the database schema and Pydantic validation.
In FastAPI projects, this removes a lot of duplicate code. You define your data model once, and you can use it for database operations, request validation, and API responses.
@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 order
The integration between SQLModel and FastAPI feels very smooth. Validation, API responses, and database models all work together using the same model class.
After using it, I realized how much time was being wasted maintaining separate database models and Pydantic schemas in other ORMs.
Where it struggles
SQLModel is still newer compared to SQLAlchemy, so some advanced features are not as polished yet.
When I worked with more complex relationships and edge cases, I sometimes had to drop down to raw SQLAlchemy code.
The documentation is good for normal use cases, but advanced topics still have gaps.
Async support also depends on SQLAlchemy underneath, so some of the async setup complexity still exists behind the cleaner API.
Performance
Performance was almost identical to SQLAlchemy because SQLModel uses SQLAlchemy internally.
Verdict
SQLModel feels like the most natural ORM for FastAPI projects using Pydantic.
For many applications, removing duplicate models is already a big enough reason to switch.
Piccolo ORM
Piccolo ORM was the biggest surprise in this test.
I originally expected it to be a small niche ORM, but it ended up having one of the best developer experiences for async 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 ORM was designed for async applications from the beginning. Async support is built in properly, so there is no need for extra wrappers or workarounds.
The query syntax is also clean and easy to read, which makes database code feel much simpler and more organized.
orders = await Order.select().where(
Order.status == "pending"
).order_by(Order.created_at, ascending=False).limit(20)
What impressed me the most was Piccolo ORM Admin.
It comes with a built-in admin panel that automatically works with your database models. One thing I missed after moving away from Django was the Django admin interface.
Piccolo brings that same experience to async FastAPI apps without needing extra setup or third-party tools.
from piccolo_admin.endpoints import create_admin
from piccolo.engine import engine_finder
app.mount("/admin/", create_admin(tables=[Order, User, Product]))
Just a few lines of code gave me a fully working admin panel ready for production.
Piccolo ORM also had the cleanest migration system out of all the ORMs I tested. The migration files looked like normal Python code and were much easier to understand compared to large auto-generated migration scripts.
Where it struggles
The biggest downside is the smaller community.
Unlike SQLAlchemy or Django ORM, there are fewer tutorials, fewer Stack Overflow answers, and less community support when you run into unusual problems.
Complex relationships and multi-table joins also became more verbose than expected.
Performance
Piccolo was the fastest ORM in almost every async test I ran.
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 average
- Tortoise: 87ms average
- SQLModel: 91ms average
- SQLAlchemy: 94ms average
The async performance difference became very noticeable under heavy load. In real production traffic, 71ms vs 94ms is a meaningful improvement.
Verdict
Piccolo ORM ended up feeling like the most complete async-first ORM I tested.
The built-in admin panel alone makes it extremely useful for teams building internal dashboards and APIs together.
Which ORM Replaced SQLAlchemy
For my own projects, Piccolo replaced SQLAlchemy completely.
Not because SQLAlchemy is bad — it is still one of the best and most powerful ORMs in Python.
But Piccolo solved the exact problems I kept running into.
The built-in admin panel removed the need to think about adding Django just for admin tools. The async-first design made FastAPI apps feel simpler and easier to manage. And the migration system removed the need for Alembic entirely.
The smaller community is still a real downside. If a team already has deep SQLAlchemy experience, switching may not be worth the cost.
But for a brand-new project, Piccolo’s cleaner developer experience adds up very quickly over time.
Which ORM Should You Use?
Use SQLAlchemy if:
- Your database schema is complex
- Your team already knows SQLAlchemy
- You need maximum query control
Use SQLModel if:
- You use FastAPI with Pydantic
- You want less duplicate code
- You want a cleaner modern API
Use Tortoise ORM if:
- Your team already knows Django ORM
- You want async support with familiar syntax
Use Piccolo ORM if:
- You are starting a new async project
- You want strong async performance
- You want a built-in admin panel
Avoid Peewee for:
- Large async production APIs
It is still great for:
- Small scripts
- Simple apps
- Synchronous projects where simplicity matters most
If this article helped you choose the right ORM, follow me on Medium. I write about real FastAPI and Python production problems, with practical fixes and real performance results.
Have you switched ORMs in a real project? Share your experience in the comments — I read all of them.
Post you also like :- I Tested 4 RAG Architectures. One Survived Production.
Comments
Loading comments…