What you'll learn
Quick Answer
An ORM maps database rows to objects so you can write application code instead of SQL. It is excellent for ordinary create, read, update and delete work and poor at reporting queries. Its main hazard is the N+1 problem, where accessing a relation inside a loop silently issues one query per row. Use the ORM for most things, drop to parameterised raw SQL for complex reads, and always be able to see the SQL your ORM produces.
What an ORM actually does for you
An ORM takes a row and gives you an object. That sounds small. In practice it also handles a lot of tedious, error-prone work: building parameterised queries, converting types between the database and your language, tracking which loaded objects have changed so it can write only those, and giving you one place to describe your tables.
Compare the same fetch with and without one:
# raw
cur.execute("SELECT id, name, city FROM students WHERE city = %s", ["Pune"])
rows = cur.fetchall() # list of tuples; you index by position
# ORM
students = Student.objects.filter(city="Pune") # a lazy QuerySet of Student objects
The ORM version is shorter, but the real gain is that it survives change. The raw version hands you positional tuples, so the moment somebody writes SELECT * instead of naming the columns, adding a column shifts every index under every caller. It also parameterises by default, which quietly removes the most common SQL injection route in student projects.
Two things ORMs are genuinely good at deserve naming. Migrations give you a versioned history of the schema in git. And a query builder lets you compose filters conditionally without gluing strings together, which is where hand-written SQL usually goes wrong:
qs = Student.objects.all()
if city: qs = qs.filter(city=city)
if course: qs = qs.filter(courses__slug=course)
What an ORM is not is a way to avoid learning SQL. It is a layer over SQL, and every problem you hit with it is a SQL problem wearing a different name. Interviewers know this, which is why the question is rarely "do you know Django ORM" and usually "what query does that produce".
The N+1 query problem
This is the single most common performance bug in ORM code, and the reason it survives review is that nothing in the code looks like a query.
# 1 query for the students...
for student in Student.objects.all():
print(student.college.name) # ...then 1 more query per student
Accessing student.college triggers a lazy load. Twenty students on your laptop, twenty-one queries, imperceptible. Fifty thousand students in production, fifty thousand and one queries, each with its own network round trip. The endpoint does not get gradually slower, it falls over.
The fix is to tell the ORM up front what you will need. Django gives you two tools, and using the wrong one does nothing:
Student.objects.select_related('college') # SQL JOIN; forward FK / one-to-one
Student.objects.prefetch_related('courses') # second query with IN (...); M2M and reverse FK
select_related performs a join and works only where a single join can reach the data. prefetch_related issues one extra query for all the related rows and stitches them together in Python, which is what you need for many-to-many and reverse relations because a join there would multiply your rows.
The same idea in JavaScript ORMs:
// N+1: one query for the users, then one more for every single user
const users = await prisma.user.findMany();
for (const u of users) {
u.orders = await prisma.order.findMany({ where: { userId: u.id } });
}
// fixed: a small fixed number of queries whatever the user count
const usersWithOrders = await prisma.user.findMany({ include: { orders: true } });
Two habits catch this before users do. Log the query count per request in development and treat a number that scales with your result set as a bug. And be suspicious of any attribute access inside a loop that crosses a table boundary, including inside templates, where the loop is often hidden in a for tag rather than in your view.
When to drop to raw SQL
ORMs are built for row-at-a-time object work. They get awkward as soon as the question is analytical.
Signs you have gone past the useful range: you need window functions such as ROW_NUMBER() or RANK(); you are grouping across three joins with conditional aggregates; you want a recursive CTE for a category tree; you need an upsert with ON CONFLICT; or you are updating a million rows and the ORM wants to load them all into memory first. At that point the ORM expression becomes longer and less readable than the SQL, and you no longer know what it will emit.
Write the SQL. Every serious ORM has an escape hatch, and using it is not a defeat:
// Prisma: tagged template, parameters are bound safely
const rows = await prisma.$queryRaw`
SELECT c.city, COUNT(*) AS total
FROM students s JOIN colleges c ON c.id = s.college_id
WHERE s.created_at >= ${since}
GROUP BY c.city ORDER BY total DESC`;
# Django: %s is a placeholder for the driver, NOT Python string formatting
with connection.cursor() as cur:
cur.execute("SELECT city, COUNT(*) FROM students WHERE created_at >= %s GROUP BY city", [since])
rows = cur.fetchall()
The one rule that is not negotiable: never build the query by concatenating or interpolating user input. In Prisma, $queryRaw with a tagged template binds parameters; $queryRawUnsafe with a built-up string does not, and the name is a warning. In Python, writing f"... WHERE city = '{city}'" is a SQL injection hole, and the fact that %s looks like Python formatting makes people reach for the wrong tool. Pass the values as the second argument and let the driver escape them.
A sensible split for most projects: the ORM owns writes and simple reads, raw SQL owns reports, dashboards and anything with aggregates. Keep the raw queries in one module rather than scattered through your views.
Migrations are not free
A migration is a versioned, ordered instruction for changing the database schema, checked into git alongside the code that depends on it. That is a genuinely large win: a teammate pulls your branch, runs one command, and their database matches yours.
The trap is treating generated migrations as something you run without reading. The generator compares your models to the previous state and guesses. It guesses badly in specific cases, and the most expensive one is renaming.
# you renamed the field from `phone` to `mobile` in the model
# the generator may produce:
DROP COLUMN phone;
ADD COLUMN mobile varchar(20);
# schema is correct. Every phone number is gone.
Open the file. Every time. Both Django and Prisma will show you the operations, and a rename is usually available as an explicit operation instead of a drop-and-add.
The second trap is locking. On a table with millions of rows, some changes take a lock that blocks writes while they run, which means a deploy that appears fine in staging causes an outage in production. Adding a column that has to be backfilled with a value touches every existing row, and PostgreSQL will simply refuse ADD COLUMN ... NOT NULL on a populated table unless you also give it a default. Creating an index in PostgreSQL blocks writes for the duration unless you use CREATE INDEX CONCURRENTLY. The exact behaviour differs between PostgreSQL and MySQL and between versions of each, so check your engine's documentation for the operation you are about to run rather than assuming.
Two rules keep you safe. Never edit a migration that has already been applied anywhere but your own machine; write a new one instead, because the applied one is recorded as done and will not run again. And for anything risky, use expand and contract: add the new column, deploy code that writes both, backfill, deploy code that reads the new one, then drop the old column in a later release. Slower, but every step is individually reversible.
You must be able to read the generated SQL
If you cannot see the SQL, you cannot debug the ORM. Turning on query logging is a five-minute setup that pays back permanently.
// Prisma
const prisma = new PrismaClient({ log: ['query'] });
// Sequelize
new Sequelize(url, { logging: console.log });
# Django: inspect one queryset without running it
print(Student.objects.filter(city="Pune").query)
# Django: everything executed on this connection (requires DEBUG = True)
from django.db import connection
print(len(connection.queries))
# SQLAlchemy
engine = create_engine(url, echo=True)
Once you can see the queries, three checks catch most problems. Count them per request; a count that grows with the number of results is N+1. Look at the WHERE clause; ORMs sometimes add joins you did not ask for, or fetch every column when you needed two. And take the slow one and run EXPLAIN ANALYZE on it to see whether the database is scanning a table it should be seeking into.
This is also where the ORM-versus-SQL debate resolves itself. The developers who complain that ORMs are slow are usually describing code where nobody looked at the output. The developers who insist on raw SQL everywhere usually end up writing a worse ORM by hand, with string concatenation and no migrations.
For placements, the practical version of this is simple. Being able to say "that generates a correlated subquery per row, so I rewrote it as a single join and it dropped to one query" is a concrete engineering story. "I used Django ORM" is not. The ORM is a tool for writing SQL faster, and the moment you treat it as a way to avoid SQL, it stops protecting you.
