You might have heard this advice before,
Just use SQL; no need for ORMs.
At first glance, that sounds elegant. SQL is expressive, close to the metal, and avoids the “magic” of an ORM.
But as systems grow, accidental complexity sneaks in, the kind that comes not from business logic, but from all the supporting structure you build around “just SQL.”
The Simple Case SQL Feels Easy Peasy
Let’s say you want to fetch all users,
import sqlite3
conn = sqlite3.connect("users.db")
cursor = conn.cursor()
cursor.execute("SELECT username, email FROM users")
rows = cursor.fetchall()
for row in rows:
print(row)
Simple and clear just SQL, no magic.
Now, you add a filter
email = "contact.syedjafer@gmail.com"
cursor.execute(f"SELECT username, email FROM users WHERE email = '{email}'")
rows = cursor.fetchall()
Still fine, right?
Well… not really.
SQL Injection Appears
Let’s imagine a malicious user enters this input,
email = "'; DROP TABLE users; --"
That innocent-looking string would destroy your table.
So you switch to parameterized queries,
email = "'; DROP TABLE users; --"
cursor.execute("SELECT username, email FROM users WHERE email = ?", (email,))
rows = cursor.fetchall()
Now you’re safe
But as soon as you add more optional filters like username, created_at, age, etc. your SQL builder starts to look… less simple.
The Complexity Creeps In
You now need to dynamically build WHERE clauses,
filters = {"username": "syed", "age": 28}
query = "SELECT username, email, age FROM users WHERE 1=1"
params = []
for key, value in filters.items():
query += f" AND {key} = ?"
params.append(value)
cursor.execute(query, params)
rows = cursor.fetchall()
You just wrote your own mini ORM.
Tomorrow, you’ll add support for LIKE, >, <, BETWEEN, and pagination.
You’ll refactor it into a helper class.
You’ll test it.
And slowly… you’ll realize, It’s no longer “just SQL.”
Accidental Complexity Explained
In other words plain SQL is perfect until your usecase stops being trivial.
When filters, sorting, joins, and pagination start piling up, you end up re-implementing features ORMs already solved years ago.
That’s called accidental complexity complexity born out of solving infrastructure problems, not business problems
Take is , Use SQL for complex queries and ORM for the rest.
from sqlalchemy import create_engine, Column, Integer, String
from sqlalchemy.orm import declarative_base, Session
engine = create_engine("sqlite:///users.db")
Base = declarative_base()
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True)
username = Column(String)
email = Column(String)
age = Column(Integer)
# Querying with ORM
with Session(engine) as session:
users = session.query(User).filter(User.email == "syed@example.com").all()
for user in users:
print(user.username, user.email)
No manual query strings.
Automatic escaping.
Refactor-friendly.
Of course, you pay with abstraction overhead the ORM hides the SQL layer from you.
But when your system grows, this tradeoff often becomes worth it.
| When to Use Plain SQL | When to Use ORM |
|---|---|
| Small scripts or one-off tools | Large apps with many models |
| Static queries | Complex, dynamic filtering |
| You need total control over performance | You want fast iteration and less boilerplate |
| Database skills are strong | You’re working in a team with mixed expertise |
| Schema changes are rare | Schema evolves frequently |
SQL is fine. ORMs are fine.
The question is, What’s the right tool for your current context?
So next time someone says “Just use SQL“, smile and ask , “Sure, but for how long?”
