Dropping to raw SQL, and when it is the right call
The ORM is excellent, but it is not the whole database. Occasionally a query is genuinely easier, clearer, or faster written as SQL — and Django lets you drop down when you need to. This lesson is the honest guide to raw SQL in Django: the two ways to do it, the one rule you must never break (parameterise, always), and the judgement of when raw SQL is the right call versus when reaching for it means you have not learnt the ORM well enough yet.
First: you need this less than you think
Start with the honest framing. Beginners reach for raw SQL because they do not yet know the ORM feature that
does the job — aggregation, annotation, F(), Q(), conditional expressions, window functions (which the
ORM supports). Ninety-plus percent of the time, "I'll just write SQL" is a signal to check whether the ORM
already has what you need, because the ORM version is safer (parameterised automatically), database-portable,
and integrated with the rest of your models. So the first rule of raw SQL is: be sure the ORM genuinely
cannot express it before you drop down. The earlier lessons in this module exist partly so you do not reach
for raw SQL out of not knowing better.
When raw SQL is genuinely the right call
That said, there are real cases where SQL is the honest choice:
- A complex query the ORM expresses awkwardly — heavy analytical queries, recursive CTEs, some window functions, database-specific features the ORM does not wrap.
- Performance-critical queries where you need a specific execution plan the ORM will not produce, and you have measured that it matters.
- A bulk operation far more efficient as one hand-written statement than as ORM calls.
- Database-specific features — a PostgreSQL extension, a specialised function — with no ORM equivalent.
In these cases raw SQL is not a failure; it is the right tool, used deliberately, for a query you have determined the ORM handles poorly. The skill is knowing the difference between "the ORM can't" and "I don't know how to make the ORM do it".
Way one: Manager.raw() — SQL that returns model instances
When your SQL returns rows of a model, Model.objects.raw() maps them back to model instances:
patients = Patient.objects.raw(
"SELECT id, name, city FROM patients_patient WHERE city = %s",
["Pune"],
)
for p in patients:
print(p.name) # real Patient objects
# verified: ['P0', 'P2', 'P4']
The result is a RawQuerySet of actual Patient objects — you get your model back, with its methods and
__str__, even though you wrote the SQL. The query must include the primary key (id) for Django to build
the instances. Use .raw() when the result is a set of one model's rows and you want them as objects.
Way two: connection.cursor() — SQL for anything else
When the result is not model rows — a cross-model report, an aggregate shape, a bulk UPDATE/DELETE — go
straight to a database cursor:
from django.db import connection
with connection.cursor() as cursor:
cursor.execute(
"SELECT city, COUNT(*) FROM patients_patient GROUP BY city ORDER BY city",
)
rows = cursor.fetchall()
# verified: [('Mumbai', 1), ('Nagpur', 1), ('Pune', 3)]
connection.cursor() gives you the raw database connection: execute() runs the SQL, fetchall() /
fetchone() return plain tuples (not model objects). This is for anything that does not map to a single
model — the escape hatch beneath everything the ORM does. The with block ensures the cursor is closed.
The one rule you must never break: parameterise
Look at both examples: the value is passed as a parameter (%s with ["Pune"]), never formatted into
the string. This is not a style preference — it is the difference between safe code and a SQL injection
vulnerability. Never, ever do this:
# CATASTROPHIC — never format user input into SQL
cursor.execute(f"SELECT * FROM patients_patient WHERE city = '{city}'") # SQL INJECTION
If city comes from a user and contains '; DROP TABLE patients_patient; --, you have handed an attacker
your database. The safe form passes the value separately so the database treats it strictly as data, never
as SQL:
cursor.execute("SELECT * FROM patients_patient WHERE city = %s", [city]) # SAFE — parameterised
The %s is Django's placeholder (regardless of database), and the list supplies the values. Every value
that varies — especially anything from a user — goes in the parameter list, never in the string. This is
the single most important rule of writing SQL by hand, and it is exactly the protection the ORM gives you
automatically (which is a strong reason to prefer the ORM). When you write raw SQL, that protection is now
your responsibility.
Keep raw SQL contained
A final piece of judgement: when you do write raw SQL, keep it in one place — a manager method or a
clearly-named function — not scattered inline through views. Patient.objects.raw(...) behind a
Patient.objects.in_city_raw(city) method (or a small function in the app) means the SQL lives in one
reviewable spot, the rest of the code calls a named thing, and if the ORM later grows the feature you need,
you change one function. Raw SQL is a sharp tool: use it deliberately, parameterise it always, and contain
it — and most days, reach for the ORM instead.
Check your work
The first rule. You need raw SQL less than you think — confirm the ORM genuinely cannot express it
(aggregation, F/Q, conditional expressions, window functions) before dropping down.
When it is right. Complex analytical queries, measured performance needs, efficient bulk statements, and database-specific features with no ORM equivalent — deliberate, not a failure.
Manager.raw(). Runs SQL that returns rows of one model and maps them to model instances (must include
the primary key). Verified: raw(... WHERE city = %s, ["Pune"]) → ['P0','P2','P4'] as Patient objects.
connection.cursor(). For results that are not model rows (reports, aggregates, bulk writes); returns
plain tuples via fetchall(). Verified: a GROUP BY returned [('Mumbai',1),('Nagpur',1),('Pune',3)].
The non-negotiable rule. Parameterise — pass values as %s placeholders with a values list, never
format them into the SQL string, or you create a SQL-injection hole. This is the protection the ORM gives
automatically.
Containing it. Keep raw SQL in one named place (a manager method or function), not scattered inline.
Practice
- Use
Patient.objects.raw("... WHERE city = %s", ["Pune"])and confirm you getPatientobjects (call a method or__str__on them). Reproduce['P0','P2','P4']. - Use
connection.cursor()to run theGROUP BY cityreport; confirm the tuple result. - Write an injectable version with an f-string and a malicious
cityvalue in a throwaway database; observe the danger, then rewrite it parameterised. (Never do this against real data.) - Take a report you wrote with raw SQL and try to express it with
values().annotate(); decide which is clearer for that case. - Move a raw query behind a named manager method and update its caller to use the method.
- For three queries, decide honestly: ORM or raw SQL, and why — naming the ORM feature that handles the ones that do not need raw SQL.
Official documentation
- Django — Performing raw SQL queries —
Manager.raw()andconnection.cursor(). - Django —
raw()and preventing SQL injection — Why and how to parameterise. - Django — Executing custom SQL directly — Using a database cursor safely.
Next: function-based views and URLs.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship