Most of what you need from a database in Django, you should get from the ORM. But sometimes a query is faster, clearer, or only expressible in raw SQL: complex reporting joins, window functions your version of the ORM cannot express cleanly, vendor-specific features, bulk writes, or calling a stored procedure. Django gives you two escape hatches for that: Manager.raw(), which still returns model instances, and connection.cursor(), for arbitrary SQL that is not tied to a model.
The one rule that overrides everything else: always pass values as query parameters, never build SQL with f-strings, %, or .format(). String-formatting user input into SQL is the classic SQL-injection hole. Use %s placeholders and a params list, and let the database driver escape the values for you.
This guide uses Django 5.x and Python 3.11+. We have shipped and maintained Django systems for 12+ years across 50+ projects at MicroPyramid, and the safe-by-default patterns below are the ones we reach for in production.
The example model
All examples assume this model. Django names the table payroll_employee by default (app label + model name lowercased); check yours with Employee._meta.db_table.
# payroll/models.py
from django.db import models
class Employee(models.Model):
name = models.CharField(max_length=100)
position = models.CharField(max_length=100)
department = models.CharField(max_length=100)
salary = models.IntegerField()
def __str__(self):
return self.nameManager.raw() returns model instances
Manager.raw() runs your SQL and maps each row back to a model instance, returning a lazy RawQuerySet. Reach for it when you want full Employee objects but need SQL the ORM cannot express comfortably.
Three things to remember:
- Include the primary key. The query must select the model's primary-key column so Django can build instances.
- Pass params with
params=[...]. Use%splaceholders, never string formatting. - You can map mismatched column names with
translations={...}, and select fewer columns to defer the rest (deferred fields load lazily on first access, which costs extra queries).
from .models import Employee
# Basic raw query -> RawQuerySet of Employee instances
employees = Employee.objects.raw("SELECT * FROM payroll_employee")
for emp in employees:
print(emp.name, emp.salary)
# Parameterised: the value is passed separately, never formatted into the string
name = "Ravi"
employees = Employee.objects.raw(
"SELECT * FROM payroll_employee WHERE name = %s",
params=[name],
)
# Map a SQL alias back onto a model field with translations
employees = Employee.objects.raw(
"SELECT id, name AS full_name, salary FROM payroll_employee",
translations={"full_name": "name"},
)
# Select fewer columns -> position/department are deferred (loaded on access)
employees = Employee.objects.raw("SELECT id, name, salary FROM payroll_employee")connection.cursor() for arbitrary SQL
When the result does not map to a single model -- bulk updates, multi-table reports, INSERT/DELETE, stored procedures -- drop to a database cursor. Always use it as a context manager so the cursor is closed for you.
cursor.execute(sql, params) runs the statement; cursor.fetchone() returns a single row (or None), cursor.fetchall() returns a list of rows, and cursor.rowcount tells you how many rows a write affected. Rows come back as plain tuples by default.
from django.db import connection
# A write: note salary arithmetic happens in the database
with connection.cursor() as cursor:
cursor.execute(
"UPDATE payroll_employee SET salary = salary + %s WHERE department = %s",
[5000, "Engineering"],
)
print(cursor.rowcount, "rows updated")
# A read: fetchone / fetchall return tuples
with connection.cursor() as cursor:
cursor.execute(
"SELECT name, salary FROM payroll_employee WHERE salary > %s",
[50000],
)
first = cursor.fetchone() # ('Ravi', 60000) or None
rest = cursor.fetchall() # [('John', 80000), ...]Tuples get unreadable fast. This helper -- straight from the Django docs -- turns a cursor's rows into a list of dictionaries keyed by column name:
def dictfetchall(cursor):
"""Return all rows from a cursor as a list of dicts."""
columns = [col[0] for col in cursor.description]
return [dict(zip(columns, row)) for row in cursor.fetchall()]
with connection.cursor() as cursor:
cursor.execute("SELECT name, salary FROM payroll_employee")
rows = dictfetchall(cursor)
# [{'name': 'Ravi', 'salary': 60000}, {'name': 'John', 'salary': 80000}]
# Bulk insert many rows in one round trip with executemany()
with connection.cursor() as cursor:
cursor.executemany(
"INSERT INTO payroll_employee (name, position, department, salary) "
"VALUES (%s, %s, %s, %s)",
[
("Asha", "Engineer", "Engineering", 62000),
("John", "Manager", "Operations", 80000),
],
)Always parameterise: stop SQL injection
This is the section that matters most. Never interpolate values into a SQL string with f-strings, the % operator, or str.format(). If any part of that string comes from a user, an attacker can rewrite your query. Pass values through the params argument instead and the database driver quotes and escapes them safely.
name = request.GET["name"] # untrusted input
# WRONG -- every one of these is a SQL-injection hole
with connection.cursor() as cursor:
cursor.execute("SELECT * FROM payroll_employee WHERE name = '%s'" % name)
cursor.execute(f"SELECT * FROM payroll_employee WHERE name = '{name}'")
cursor.execute("SELECT * FROM payroll_employee WHERE name = '{}'".format(name))
# RIGHT -- value passed as a parameter; the driver escapes it
with connection.cursor() as cursor:
cursor.execute("SELECT * FROM payroll_employee WHERE name = %s", [name])
# Same rule for Manager.raw()
Employee.objects.raw("SELECT * FROM payroll_employee WHERE name = %s", params=[name])Two subtleties that trip people up:
- The placeholder is always
%s, for every database backend and every column type -- and you do not put quotes around it. This is DB-API parameter substitution performed by the driver, not Python's%string operator, so pre-quoting ('%s') actually breaks it. - You cannot parameterise identifiers -- table names, column names,
ORDER BYdirections. Those are not data, so%swill not work. When a column or sort order must be dynamic, validate it against an allowlist of known-safe values before composing the string:
ALLOWED_SORT = {"name", "salary", "position"}
sort = request.GET.get("sort", "name")
if sort not in ALLOWED_SORT: # reject anything not on the allowlist
sort = "name"
with connection.cursor() as cursor:
# the column name is now known-safe; values still go through params
cursor.execute(
f"SELECT id, name, salary FROM payroll_employee "
f"WHERE department = %s ORDER BY {sort}",
["Engineering"],
)Prefer the ORM first
Before you write any raw SQL, check whether the ORM already does the job. In our experience the overwhelming majority of "I need raw SQL" cases are covered by these tools -- and they stay parameterised, portable across databases, and type-aware automatically:
aggregate()andannotate()for totals, counts, and per-group rollups -- see our Django aggregation guide.F()expressions for database-side arithmetic without race conditions, andQ()objects for complexAND/ORlogic.Funcand thedjango.db.models.functionslibrary for database functions (Coalesce,Lower,TruncMonth, ...).- Conditional
Case/Whenexpressions and filtered aggregates (Count(..., filter=Q(...))). Windowexpressions for window functions,RawSQL()to drop a parameterised SQL fragment into an otherwise-ORM query,QuerySet.alias()for intermediate values, and.explain()for the query plan.QuerySet.extra()is a legacy escape hatch the Django team discourages and aims to deprecate -- avoid it in new code and useRawSQL(),Func, orannotate()instead.
For broader query-tuning patterns, see our Django ORM query optimization guide.
from django.db.models import (
Sum, Avg, Count, F, Q, Value, Case, When, Window,
)
from django.db.models.functions import RowNumber
from django.db.models.expressions import RawSQL
# aggregate() -> one summary dict; annotate() -> per-row / per-group values
Employee.objects.aggregate(total=Sum("salary"), avg=Avg("salary"))
Employee.objects.values("department").annotate(headcount=Count("id"))
# F() does the maths in the database (atomic, no read-modify-write race)
Employee.objects.update(salary=F("salary") + 5000)
# Q() for OR/AND; filtered aggregate for conditional counts
Employee.objects.filter(Q(department="Engineering") | Q(salary__gte=80000))
Employee.objects.aggregate(seniors=Count("id", filter=Q(salary__gte=80000)))
# Case/When for inline branching
Employee.objects.annotate(
band=Case(
When(salary__gte=80000, then=Value("senior")),
default=Value("standard"),
)
)
# Window function -- no raw SQL needed
Employee.objects.annotate(
rank=Window(expression=RowNumber(), order_by=F("salary").desc())
)
# RawSQL: a parameterised raw fragment inside an ORM query
Employee.objects.annotate(bonus=RawSQL("salary * %s", (0.1,)))ORM vs raw() vs cursor: which to pick
| Approach | Returns | Best for | Parameter safety |
|---|---|---|---|
ORM QuerySet |
Model instances or values() dicts |
~95% of queries: filtering, joins, aggregation, windows | Automatic |
Manager.raw() |
RawQuerySet of model instances |
Custom SQL that still maps onto one model | Pass params=[...] |
connection.cursor() |
Tuples (or your own shape via dictfetchall) |
Bulk writes, multi-table reports, stored procedures, non-model SQL | Pass [params] |
Rule of thumb: stay in the ORM; use .raw() when you still want model objects; use a cursor when you do not.
Querying multiple databases
If your project defines more than one database in DATABASES, grab a cursor for a specific connection from connections. For Manager.raw(), route with .using("alias").
from django.db import connections
# Read from a replica connection by alias
with connections["replica"].cursor() as cursor:
cursor.execute("SELECT COUNT(*) FROM payroll_employee")
(total,) = cursor.fetchone()
# .raw() on a specific database
Employee.objects.using("replica").raw("SELECT * FROM payroll_employee")Transactions and stored procedures
Raw SQL through a cursor bypasses the ORM: it does not call Model.save(), does not fire signals, and is not wrapped in a transaction on its own (unless you run with ATOMIC_REQUESTS). When several statements must succeed or fail together, wrap them in transaction.atomic() so any exception rolls the whole block back. Call stored procedures with cursor.callproc().
from django.db import connection, transaction
# Group writes so they commit or roll back as a unit
with transaction.atomic():
with connection.cursor() as cursor:
cursor.execute(
"UPDATE payroll_employee SET salary = salary + %s WHERE department = %s",
[5000, "Engineering"],
)
cursor.execute(
"INSERT INTO audit_log (action, created_at) VALUES (%s, NOW())",
["salary_raise"],
)
# if either statement raises, both are rolled back
# Call a stored procedure with positional params
with connection.cursor() as cursor:
cursor.callproc("recalculate_bonuses", [2026])
results = cursor.fetchall()Performance and debugging
Before and after dropping to raw SQL, measure. QuerySet.explain() returns the database's query plan straight from the ORM. connection.queries lists the SQL Django actually ran -- but only when DEBUG=True, and it grows for the life of the process, so call reset_queries() around the code you are profiling. To preview the SQL a queryset will run without hitting the database, print queryset.query. For per-request SQL profiling in development, install django-debug-toolbar.
from django.db import connection, reset_queries
# Query plan, straight from the ORM
print(Employee.objects.filter(salary__gte=50000).explain(verbose=True))
# What SQL did Django run? (DEBUG=True only)
reset_queries()
list(Employee.objects.all())
print(connection.queries) # [{'sql': 'SELECT ...', 'time': '0.001'}]
# Preview the SQL for a queryset without executing it
print(Employee.objects.filter(salary__gte=50000).query)The pattern that keeps Django systems both fast and safe is simple: reach for the ORM first, drop to raw SQL only where it clearly earns its keep, and parameterise every single query. If you want another set of eyes on a slow report, a tricky migration, or a security review of hand-written SQL, our Python development team has done this across 50+ projects and is happy to help.
Frequently Asked Questions
When should I use raw SQL instead of the Django ORM?
Use raw SQL only when the ORM genuinely cannot express the query cleanly or efficiently: complex reporting joins, vendor-specific features, set-based bulk writes, or calling a stored procedure. For roughly 95% of queries the ORM is faster to write, safer, and portable across databases. Reach for Manager.raw() when you still want model instances, and connection.cursor() when the result does not map to a model.
How do I avoid SQL injection in Django raw queries?
Always pass values as query parameters and never build SQL with f-strings, the % operator, or str.format(). Use %s placeholders with a params list -- cursor.execute(sql, [value]) or Model.objects.raw(sql, params=[value]) -- and the database driver escapes the values for you. The %s placeholder is the same for every backend and must not be quoted.
What is the difference between .raw() and connection.cursor()?
Manager.raw() runs your SQL and maps each row back to a model instance, returning a lazy RawQuerySet; the query must include the primary key. connection.cursor() runs arbitrary SQL and returns plain tuples (or whatever shape you build), and it bypasses the ORM entirely. Use .raw() when you want model objects, and a cursor for bulk writes, multi-table reports, stored procedures, or any SQL not tied to a single model.
How do I get dictionaries instead of tuples from a cursor?
A cursor returns tuples by default. Build a small helper that reads column names from cursor.description and zips them with each row: columns = [col[0] for col in cursor.description] then [dict(zip(columns, row)) for row in cursor.fetchall()]. Call it after cursor.execute(...) to get a list of dictionaries keyed by column name.
Can I parameterise a table or column name?
No. Parameters (%s) are for values only, not for SQL identifiers like table names, column names, or ORDER BY directions. When an identifier must be dynamic, validate it against an allowlist of known-safe names and only then compose it into the SQL string -- never take a table or column name directly from user input.
Is QuerySet.extra() still okay to use?
Avoid it in new code. .extra() is a legacy escape hatch the Django team discourages and aims to deprecate. Prefer the modern alternatives: annotate() with expressions, Func, Window, or RawSQL() to inject a parameterised SQL fragment into an otherwise-ORM query. If you truly need full control, Manager.raw() or a cursor is clearer and better supported.