Zero-Abstraction SQL: Why Raw Queries Beat ORMs for Clarity and Control

Boost Clarity and Control with Raw SQL Queries in Rust and Beyond

Zero-Abstraction SQL: Why Raw Queries Beat ORMs for Clarity and Control
Image Credit: islander11

I have been developing software systems for about 30 years, long enough to see plenty of data-access trends come and go: massive ORMs, custom repositories, code-generation tools, and advanced query builders. They all have their merits, but over time I found myself coming back to a simple truth: raw SQL is the most transparent, flexible, and performant way to interact with a database.

This approach, which I call "zero-abstraction SQL", doesn't mean that you ignore helpful libraries or frameworks. It just means that you write your own queries, maintain direct control over them, and let your language's tools help you without adding heavy layers on top. This aligns with my broader philosophy of simplifying systems by reducing unnecessary abstractions, as I’ve discussed in my recent articles on functional cores and imperative shells. You can do this in many ecosystems; I happen to use Rust a lot these days because it pairs incredibly well with a library called SQLx. SQLx allows me to keep my queries in plain text while still benefiting from compile-time checks and async operations. But conceptually, writing raw SQL in any language that supports it can deliver similar clarity and control. For example, JOOQ remains the strongest equivalent to SQLx in Spring Boot.

The Essence of Zero-Abstraction SQL

Plain SQL Statements, Right in the Code

Instead of burying your logic behind an ORM or elaborate DSL, you write each query yourself. This means no unexpected “lazy loading,” no mysterious joins, and no performance bottlenecks you can’t see. If something needs tuning, you can open up your code and modify that exact SQL statement.

Optional Compile-Time Checks

In Rust, SQLx gives me the ability to verify queries at compile time, catching mistakes like typos in column names or mismatched types. This ensures your SQL aligns with the database schema before you even run the application. You can think of this as a best-of-both-worlds scenario: raw SQL with an added safety net. Outside Rust, you might rely on tests or migrations, but the principle remains. Stay close to the database’s reality.

Async I/O Without Reflection

Many modern applications handle high concurrency. With SQLx in Rust, all calls are asynchronous, and there’s no hidden reflection that slows things down. You can adopt a similar mindset in other stacks, using raw SQL with non-blocking I/O frameworks.

Consistent, Predictable Performance

Because you control the queries, you avoid the guesswork of “What SQL is my ORM generating?” or “Why is my code scanning entire tables unexpectedly?” If performance problems do arise, you can pinpoint them quickly. I’ve seen huge teams benefit from having fewer layers in the debugging process.

A Brief Look at the Code

Below is a shortened Rust example using SQLx that illustrates how straightforward this can be. The function registers a new asset, first checking for a naming conflict, then inserting the new row. Even if you’re not a Rust user, the main idea applies in other languages: keep your raw SQL front and center, and apply domain logic separately.

use sqlx::PgPool;
use uuid::Uuid;

pub async fn register_asset(
    pool: &PgPool,
    tenant_id: Uuid,
    name: &str,
) -> Result<(), sqlx::Error> {
    // Check if an asset with the same name exists for the tenant
    let exists = sqlx::query_scalar!(
        "SELECT 1 FROM assets WHERE tenant_id = $1 AND name = $2",
        tenant_id,
        name
    )
    .fetch_optional(pool)
    .await?;

    if exists.is_some() {
        // Return an error or handle it in your own way
        return Err(sqlx::Error::RowNotFound);
    }

    // Insert the new asset
    sqlx::query!(
        r#"
        INSERT INTO assets (id, tenant_id, name)
        VALUES ($1, $2, $3)
        "#,
        Uuid::new_v4(),
        tenant_id,
        name
    )
    .execute(pool)
    .await?;

    Ok(())
}

Notice how there’s no ORM “model” struct. The logic is straightforward, and the queries remain entirely in your control. If you want to change the indexing strategy or use a specific Postgres feature, you can do so by adjusting the SQL directly. Meanwhile, SQLx’s query! macros (like query_scalar! above) will check the validity of columns and types at compile time, giving you confidence in your queries before your app even runs.

But What About Migrations, Schema Changes, and Other Realities?

I think these are natural questions:

Do I lose out on auto-generated schemas or fancy migrations?

Not necessarily. You can still use tools like sqlx-cli or other migration frameworks. The difference is that you remain in the driver’s seat, writing migration files by hand (or selectively using generation tools) rather than leaning on an ORM’s assumptions.

Isn’t raw SQL more prone to errors?

Potentially, yes! If you never validate your queries. But if your environment offers compile-time checks (like Rust+SQLx, or Spirng Boot+JOOQ), or if you maintain thorough tests, you’ll catch mistakes early. Some teams also run static analyzers or database-linting tools to ensure consistency.

How does this scale for big applications?

In large projects, people usually fear “too many raw queries.” But in practice, grouping queries by feature or module, naming them clearly, and establishing a consistent folder structure leads to manageable code. With an ORM, you might have hundreds of model classes or complicated DSL calls. With raw SQL, you have a set of statements that do what they say, in a form that every developer or DBA can read directly.

Why I Stick to It After Many Years

Having worked on all types of projects (monoliths, microservices, data pipelines, real-time systems), I’ve come to realize that massive abstractions often hide more than they help. This echoes my earlier arguments about replacing layered architectures with functional cores and imperative shells, where clarity and control take precedence. Writing raw SQL might look old-school at first, but it fosters genuine clarity and control. Once you get used to it, you’ll notice how much friction disappears. You stop wrestling with mysterious ORM quirks, and instead write exactly the query you need.

Rust has been my go-to language for this pattern because SQLx’s macros provide that wonderful safety net at compile time, along with async performance. But the guiding idea isn’t Rust-specific:

  • keep logic pure,
  • keep the SQL visible,
  • rely on your language’s best tools to catch errors and maintain performance.

I hope this gives a sense of why “zero-abstraction SQL” has become my preferred method. It’s not about reinventing the wheel; it’s about knowing precisely how that wheel turns and being able to optimize it when needed. If you have similar experiences, or if you’re on the fence about letting go of an ORM, give raw SQL a try. You might be surprised at how it simplifies everything in the long run.

This Might Work for Small Projects, But Not for Large Ones…

I can imagine that many developers assume raw SQL quickly becomes unmanageable at scale. My experience has been the opposite: I’ve worked on large, complex enterprise systems where layers of abstractions, frameworks, and generated code ended up obscuring performance pitfalls and increasing complexity.

By keeping queries “simple and pure” and grouping them by feature , you actually reduce the cognitive load. This approach complements the vertical slicing I’ve advocated for in my articles on functional programming, where each feature owns its logic and data access. Instead of chasing down which part of the ORM’s auto-generated code is causing problems, you locate the relevant SQL statement in a well-organized feature slice. This level of clarity scales surprisingly well. A large project that’s split into manageable modules and features can maintain hundreds (or even thousands) of raw SQL statements without confusion. Each statement does what it says on the tin, and you can tweak it as your data patterns evolve.

Rather than adding layers that mask what’s really happening in the database, zero-abstraction SQL keeps the entire team honest about schema design, index usage, and query efficiency. It’s not a shortcut or a hack; it’s simply a direct conversation with your database, making it easier to spot performance issues, maintain code, and onboard new team members who can jump right in without learning an intricate “ORM dialect.”

Cheers!

Subscribe to Rico Fritzsche

Don’t miss out on the latest issues. Sign up now to get access to the library of members-only issues.
jamie@example.com
Subscribe