Preventing SQL Injection in Django

A practical security guide to avoiding SQL injection in Django: safe ORM usage, parameterizing raw queries, and avoiding extra() pitfalls.

Abstract Django ORM and raw SQL query defensive shield with red validation motifs

Django is widely celebrated for its robust built-in security features. Its Object-Relational Mapper (ORM) automatically abstracts database interactions and constructs parameterized queries for standard operations like filter(), exclude(), and get(). Because of these built-in protections, many developers assume that a Django application is completely immune to SQL injection.

However, when engineering teams resort to raw SQL execution, dynamic clause building, or legacy QuerySet methods, dangerous injection vulnerabilities frequently emerge.

To understand the underlying threat models and attacker techniques, read our foundational guide on how SQL injection works and why it remains dangerous. If your stack also incorporates Node.js services, review our companion guide on preventing SQL injection in Node.js and Express.

In this tutorial, we analyze how SQL injection manifests in Django and how to defend your application.

Why Django applications remain susceptible to SQLi

The Django ORM sanitizes values passed into standard QuerySet filters:

# SECURE: Django ORM parameterizes the query automatically
users = User.objects.filter(username=request.POST.get('username'))

Here, the ORM handles escaping and parameter binding natively. SQL injection occurs in Django when developers bypass ORM query construction or misuse specific QuerySet methods.

1. Misusing RawSQL and string interpolation

When custom database logic requires raw SQL, developers often use RawSQL or Manager.raw() with Python f-strings:

# DANGEROUS: Python string interpolation creates SQLi
from django.db.models.expressions import RawSQL

user_input = request.GET.get('order_by', 'id')
# Vulnerable string formatting:
users = User.objects.annotate(
    val=RawSQL(f"SELECT score FROM leaderboards WHERE category = '{user_input}'", [])
)

If user_input contains ' OR 1=1 --, the SQL logic is altered.

2. Legacy QuerySet.extra() risks

The extra() method was historically used for appending raw SQL fragments. Because it makes it trivial to inject untrusted input into select, where, or order_by clauses, the Django core team explicitly deprecated it:

# DANGEROUS: Legacy extra() with unparameterized where clause
Entry.objects.extra(where=[f"headline = '{request.GET.get('title')}'"]) # VULNERABLE

Safe raw queries with Manager.raw()

When a complex analytical query cannot be expressed using standard ORM expressions, use Model.objects.raw() with the params parameter:

# SECURE: Model.raw() with parameter binding
from myapp.models import Product

def get_products_by_category(category_name):
    query = """
        SELECT id, name, price, stock 
        FROM myapp_product 
        WHERE category = %s AND active = %s
    """
    params = [category_name, True]
    
    # Django parameterizes %s using the underlying database driver
    return Product.objects.raw(query, params)

[!IMPORTANT] Always pass arguments as a list or tuple to params. Never format parameters using Python % string formatting or f-strings before passing them to raw().

Executing custom SQL via database cursors

For operations that do not map directly to models (such as bulk data pipelines or reporting views), use Django's database connection cursor:

# SECURE: Using connection.cursor() with parameterized queries
from django.db import connection

def get_user_statistics(tenant_id, min_purchases):
    query = """
        SELECT user_id, COUNT(*) as total_orders, SUM(amount) as total_spent
        FROM orders
        WHERE tenant_id = %s
        GROUP BY user_id
        HAVING COUNT(*) >= %s
    """
    
    with connection.cursor() as cursor:
        cursor.execute(query, [tenant_id, min_purchases])
        results = cursor.fetchall()
        
    return results

In this pattern:

  • The SQL string contains %s placeholders.
  • cursor.execute() sends the query template and values separately to the database engine.
  • Injection payloads are treated as harmless string literals.

Safeguarding dynamic order_by clauses

A subtle vulnerability in Django occurs when users can specify sorting columns via URL query parameters:

# DANGEROUS: Direct user input into order_by()
sort_field = request.GET.get('sort', 'created_at')
articles = Article.objects.order_by(sort_field) # Potentially exposes internal field paths

While Django's order_by() resists basic string injection, untrusted input can leak relational model structures or cause unexpected exceptions. Implement a strict whitelist:

# SECURE: Whitelisting allowed sorting fields
ALLOWED_SORT_FIELDS = {
    'date': '-created_at',
    'title': 'title',
    'views': '-view_count',
}

sort_key = request.GET.get('sort', 'date')
order_clause = ALLOWED_SORT_FIELDS.get(sort_key, '-created_at')

articles = Article.objects.order_by(order_clause)

To establish dependable code verification habits across your engineering organization, refer to our clean code and code review habits guide.

Common Django database mistakes

Anti-Pattern Root Cause Recommended Fix
Using QuerySet.extra() Legacy API allowing unparameterized SQL Refactor to standard ORM annotations or RawSQL with params
f-strings inside RawSQL Bypassing parameter tuple argument Pass query values inside the second params argument list
Trusting raw column identifiers Assuming parameter binding works on column names Whitelist column and table names using Python set or dict
Direct string substitution in cursor.execute Writing cursor.execute(f"SELECT ... {var}") Pass variables strictly via the second parameter: cursor.execute(sql, [var])
Uncaught database errors returning debug views DEBUG = True enabled on production environments Ensure DEBUG = False and log database exceptions privately

Django security review checklist

Before deploying Django applications to production, verify these security controls:

  • Zero occurrences of QuerySet.extra() exist in the codebase.
  • All cursor.execute() calls supply arguments via the params parameter; zero f-strings or .format() calls.
  • User-controllable sorting parameters are validated against an explicit whitelist of allowed field names.
  • DEBUG is set to False in production settings.py to prevent database schema exposure in tracebacks.
  • Database credentials operate under a role with minimum necessary table and schema permissions.

Bottom line

Django's ORM provides outstanding out-of-the-box protection against SQL injection. By eliminating deprecated methods like extra(), strictly passing arguments through params in raw SQL and cursor calls, and whitelisting dynamic query fields, your Django backend remains resilient against all forms of SQL injection attacks.

Sources: Django Documentation on Performing Raw SQL Queries, OWASP Django Security Checklist, CWE-89: SQL Injection (evaluated November 2026).

Keep reading