Hi, I'm ThadeusB.

I code, I play, I love, I innovate

Why Raw SQL Is My Default Now

I've been writing raw SQL for about two years now and I don't think I'm going back to ORMs.

That probably sounds dramatic. It's not. I just got tired of fighting the abstraction when the thing underneath it, SQL, was never the hard part to begin with.

How I got here

I spent a few years doing Scala + Spark work. DataFrames, Spark SQL internals, query planners, execution strategies, the whole deal. When you live in that world long enough you start to care a lot about what the actual query looks like, how joins are happening, what's getting shuffled where. You learn to read query plans like a second language.

Then I moved into building Python services with FastAPI and FastStream. And at first I reached for SQLAlchemy like everyone does. The problem though? Every time I hit a performance issue or a weird bug, I'd end up digging through the ORM to figure out what SQL it was actually generating. Half my debugging sessions started in framework internals instead of query plans.

After enough of that I thought... why am I adding this layer at all?

The actual problem with ORMs

I'm not saying ORMs are evil. They're fine for CRUD apps where you just need to get something shipped. But for anything beyond basic stuff, the pattern is always the same:

  • You write your ORM query
  • It generates SQL that's close to what you wanted
  • Something is slow or wrong
  • You go figure out what SQL it actually generated
  • You fight the ORM to make it generate the query you wanted in the first place

At that point you've spent more time negotiating with the framework than you would have just writing the SQL yourself. And the SQL you wanted to write was probably 5 lines.

What I do now

aiosql is the sweet spot for me. SQL lives in .sql files with named queries, and your Python code just calls methods. No magic, no hidden query generation.

users.sql

-- name: get_user_by_id^
SELECT id, email, status, created_at
FROM users
WHERE id = :id;

app.py

import aiosql
from dataclasses import dataclass

@dataclass
class User:
    id: int
    email: str
    status: str
    created_at: str

queries = aiosql.from_path("users.sql", "asyncpg")

async def load_user(conn, user_id: int) -> User | None:
    row = await queries.get_user_by_id(conn, id=user_id)
    return User(**row) if row else None

That's it. SQL stays in SQL files where I can read it, version it, and run EXPLAIN on it. Models are just dataclasses or Pydantic at the boundary. No ORM lifecycle, no session management, no lazy loading surprises.

With FastAPI and FastStream the handlers stay dead simple. I can look at any endpoint and know exactly what query is running. When something breaks I go straight to the query plan instead of spelunking through SQLAlchemy internals.

Copy, paste, debug

Here's something that sounds small but matters a lot in practice. When I have a slow query or a bug, I open the .sql file, copy the query, paste it into DBeaver, and I'm debugging in a real SQL environment. I can tweak joins, check the explain plan, test with different parameters, all in a tool built specifically for that job.

With an ORM you don't have that. You're either adding logging to dump the generated SQL, or stepping through framework code trying to figure out what query it even built. By the time you've extracted the actual SQL from the ORM, I've already found and fixed the problem.

Migrations without the ORM too

"But what about migrations?" This is the first thing people ask.

We still use Alembic. It's a great migration tool. But we don't use SQLAlchemy models to generate them. We write the migrations in pure SQL.

def upgrade():
    op.execute("""
        CREATE TABLE user_profiles (
            id SERIAL PRIMARY KEY,
            user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
            bio TEXT,
            created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
        )
    """)
    op.execute("CREATE INDEX idx_user_profiles_user_id ON user_profiles(user_id)")

def downgrade():
    op.execute("DROP TABLE IF EXISTS user_profiles")

No model metadata, no autogenerate magic. Just op.execute() with the DDL you actually want. You know exactly what's running because you wrote it. The migration file reads like a changelog of your schema, not a diff of Python class attributes.

Alembic doesn't need SQLAlchemy models at all. It just needs a database connection. The autogenerate feature is convenient for simple CRUD apps, but once your schema has any complexity, you end up hand-editing the generated migrations anyway. Might as well just write them yourself from the start.

AI is way better at SQL than your ORM

This one is newer but it's real. AI coding agents are significantly better at writing raw SQL than they are at navigating ORM-specific APIs.

Think about it. SQL has been around since the 1970s. There are decades of examples, tutorials, Stack Overflow answers, documentation, and open source code all using the same language. When you ask an AI to write a SELECT with a JOIN and a GROUP BY, it has an enormous corpus to draw from.

Now ask that same AI to write a SQLAlchemy query with relationship(), backref, lazy='selectin', a custom hybrid_property, and your project's specific session management pattern. Good luck. Every project configures their ORM differently. The AI doesn't know if you're using scoped sessions or async sessions, whether your models use declarative_base or DeclarativeBase, what your naming conventions are, or how your relationships are set up.

On the npm side it's even worse. TypeORM pulls about 3M weekly downloads. mysql2 (the raw driver) pulls about 7M. That's double the training data before you even count all the raw SQL examples from other languages and platforms. The AI is just going to be better at the thing the internet has more examples of.

Raw SQL is a universal language. Every ORM is a snowflake.

When ORMs are fine

If your app is mostly straightforward CRUD and your team just needs to ship fast, an ORM is probably the right call. Django ORM, SQLAlchemy, ActiveRecord, they all work great for that. I'm not going to pretend raw SQL is the answer for everything.

The line for me is when the ORM starts hiding complexity instead of reducing it. Once I'm spending more time understanding the ORM's behavior than the actual data problem, it's time to drop down.

Why it matters

The biggest win for me hasn't been performance (though that's nice). It's cognitive load. I open a .sql file and I can see exactly what's happening. No generated queries to decode, no framework behavior to second-guess. When something is slow I run EXPLAIN ANALYZE and I'm looking at the actual problem in 30 seconds.

After doing Spark SQL internals for years, that kind of visibility is hard to give up. Turns out I don't want to give it up on the app side either.