RizTech Academy logo
RizTech Academy
The ORM in DepthLesson 6 of 625 min

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

  1. Use Patient.objects.raw("... WHERE city = %s", ["Pune"]) and confirm you get Patient objects (call a method or __str__ on them). Reproduce ['P0','P2','P4'].
  2. Use connection.cursor() to run the GROUP BY city report; confirm the tuple result.
  3. Write an injectable version with an f-string and a malicious city value in a throwaway database; observe the danger, then rewrite it parameterised. (Never do this against real data.)
  4. Take a report you wrote with raw SQL and try to express it with values().annotate(); decide which is clearer for that case.
  5. Move a raw query behind a named manager method and update its caller to use the method.
  6. 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

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