Let me be clear about what this post is not, because it is not a complaint about ORMs, which are competently built tools that solve a real problem for a certain kind of team in a certain kind of situation, and some of them are well-designed, and some projects ship successfully with them for years.
What I want to talk about is the developer who reaches for one before learning what it replaces, and what that choice costs over the life of a project.
JavaScript Runs on the Server Now
The rise of Node, Deno, Bun, and now Nub, TypeScript everywhere, full-stack JavaScript - all of it has been genuinely good for the industry. Lower barrier to entry. Shared language across the stack. A runtime that keeps improving. I have no argument with any of that.
But something came along with it that I've watched quietly cause problems for years: the idea that if it doesn't look like JavaScript, it should be wrapped in something that does. The database is the most obvious casualty of this instinct. SQL is a different language with a different mental model - set-based, declarative, relational - and a certain category of developer has decided that the natural response to that difference is to make it go away.
user.name = "Aleš";
await user.save();
That line is comfortable because it looks like the rest of the application and doesn't ask you to think about anything that isn't already familiar, and that comfort is precisely the problem, because what it hides is a conversation with a system that has its own rules, its own performance characteristics, and its own ways of failing, none of which have gone away just because you've stopped looking at them.
What You're Not Seeing
When you write user.update(), something has to produce an UPDATE statement. Something has to decide which columns to include. Something has to construct the WHERE clause. Something has to decide whether this runs inside a transaction, and if so, which one. Something has to handle the case where the row was modified by another process between when you read it and when you wrote it.
The ORM does all of this for you, which is its value proposition, but the cost is that it does all of this invisibly, according to rules you may not know and may not have checked, which is the classic dependency trap that applies to any package you reach for without understanding what it does beneath the surface, because you are now subject to its release cycle, its breaking changes, its bugs, its opinions about how your data should move. The ORM is just the most consequential version of this trap, because the thing it's abstracting is your data.
A developer who understands SQL looks at user.update() and asks: what does that actually send to the database? A developer who doesn't understand SQL looks at user.update() and thinks: that updates the user. The gap between those two mental models is where production incidents live.
The N+1 query problem is the most documented version of this gap. You fetch a list of orders. You loop over them to get each customer. The ORM obliges, running one query per order, and the page that renders in 40ms in development renders in 4 seconds in production with real data. The fix is a JOIN. A developer who knows SQL writes the JOIN. A developer who doesn't know SQL googles "ORM eager loading" and learns to configure the abstraction rather than understand the underlying system.
Both can ship the feature. Only one of them controls it.
SQL Is Not Hard
This is the part that needs saying plainly: SQL is not a difficult language to learn, and basic competency is achievable in a weekend, with genuine fluency following from a few months of real use where the relatively small syntax starts to feel natural and the concepts - SELECT, JOIN, GROUP BY, WHERE, transactions - stop looking foreign because they map directly to things you're already doing in your application code, expressed differently.
What SQL requires is a shift in thinking, because it is set-based rather than record-based and you describe what you want rather than how to get it, so a JOIN isn't a loop but a declaration of a relationship, and GROUP BY isn't iteration but aggregation over a set. That shift is the actual learning, and it's worth making not because SQL is beautiful, which it isn't particularly, but because the database is one of the most important systems in most applications and understanding how it works is part of the job.
The developer who has deliberately avoided that shift has made a choice and chosen the ceiling that comes with it: the queries they can't optimize because they can't read an execution plan, the schema they can't fully utilize because they don't know what an index covers, the race condition they can't see because they don't think in transactions.
The Generated SQL Argument
The objection I hear most often is: "but I don't want to write the same SELECT and UPDATE boilerplate for every table." That's a legitimate objection, and it has a legitimate answer that isn't an ORM.
reepolee generates the SQL functions for every resource directly from the schema. get_record_by_id, create_record, update_record, search_records - all of it, written in plain SQL using Bun's native tagged template literals, parameterized correctly, typed from the schema. No ORM. No query builder. No translation layer.
const records = await db`SELECT * FROM users WHERE id = ${id} LIMIT 1`;
That is the query and that is what runs, so when it is slow you look at the query, when it does the wrong thing you read the query, when you need to add a condition you add it to the query, and there is nothing between you and the database but the driver.
The consistency argument that ORMs usually win on - "at least the boilerplate is handled the same way everywhere" - is handled by the generator instead. The generator writes correct SQL once, in a pattern your team reviewed, and applies it to every resource. You get the consistency without the abstraction.
What Fluency Actually Looks Like
A developer who is fluent in SQL thinks differently about data problems. When they need the last order per customer, they reach for a window function rather than fetching everything and sorting in application code. When they need to count and filter in one pass, they use a subquery. When they need to enforce that two writes are atomic, they open a transaction explicitly because they know what that means and why it matters.
They can read an execution plan and understand why a query is slow. They know the difference between an index scan and a full table scan, and what the data volume at which that distinction starts to hurt. They know what a covering index is and when to add one. They know that LIKE '%term%' won't use an index and what the alternatives are.
None of this requires being a DBA, but it does require having spent real time learning the system rather than learning the wrapper around it.
A Quiet Challenge
If you've been working with relational databases for a few years entirely through an ORM, I'd suggest spending a week without one. Write the queries yourself. Read what they produce. Run EXPLAIN on the slow ones. Think about what the database is doing, not just what your application code says.
You might find it uncomfortable at first. That discomfort is information. It's the gap between where you are and where the system actually lives. Most developers who cross that gap don't go back - not because raw SQL is more pleasant to write, but because understanding the system you depend on turns out to be more useful than having a comfortable wrapper around it.
The database has been there since before JavaScript ran on the server, and it will be there after. It has its own rules, its own language, its own performance model. Learning them is not optional work for the truly curious - it is the baseline competency of a developer who works with data and wants to understand what they're doing.
JavaScript runs on the server now. It didn't make the database a JavaScript object.