skip to content

In a Django code review you find Person.objects.raw(f"SELECT * FROM myapp_person WHERE last_name = '{name}'"); what is wrong, and how do you fix it?

level: seniorimportance: must knowfreq 60%

answer

  1. string built before Django sees it
  2. the params argument
  3. unquoted %s on every backend
  4. LIKE wildcards go in the value

basics

~20 s

The f-string splices user input into the SQL text, so a crafted name rewrites the query. Pass it through params with an unquoted placeholder: raw("... WHERE last_name = %s", [name]); the driver then sends it as data.

solid answer

~50 s

The f-string runs before Django sees anything, so `raw()` receives finished SQL and has no way to tell data from code — a `name` like `x' OR '1'='1` changes the statement. The fix is the `params` argument: `Person.objects.raw("SELECT * FROM myapp_person WHERE last_name = %s", [name])`. Django uses `%s` (or `%(key)s` with a dict) on every backend, and the placeholder must stay **unquoted** — `'%s'` is also vulnerable, because the quoting is the driver's job. The same rule holds for `cursor.execute(sql, params)` and for `RawSQL(sql, params)`, where `params` is deliberately required. Two edge cases: for `LIKE`, put the wildcards in the value (`[f"%{term}%"]`), and a literal `%` in SQL that also takes params must be written `%%`. Table and column names cannot be parameters at all, so they come from an allowlist in code, never from the request.

code

python · 20 lines
python
from django.db import connection

from myapp.models import Person

SORTABLE = {"name": "last_name", "joined": "date_joined"}


def search(term, sort):
    column = SORTABLE.get(sort, "last_name")  # identifier from an allowlist
    return Person.objects.raw(
        f"SELECT * FROM myapp_person WHERE last_name LIKE %s ORDER BY {column}",
        [f"%{term}%"],  # value, wildcards included, bound by the driver
    )


def bump(person_id):
    with connection.cursor() as cursor:
        cursor.execute(
            "UPDATE myapp_person SET visits = visits + 1 WHERE id = %s", [person_id]
        )

go deeper

for a junior

Recall that values go in the params argument with a bare %s placeholder, never inside the SQL string.

for a middle

Explain why quoted placeholders, % formatting and f-strings all fail, and how LIKE wildcards and literal %% are handled.

for a senior

Show you can audit a codebase: find every raw(), cursor.execute(), RawSQL and extra() call, fix identifiers with allowlists, and review Func extras.

for a principal

Argue for guardrails beyond review: a lint rule or search in CI for interpolated raw SQL, and a policy that raw SQL lives in a few reviewed manager methods.

## What the snippet actually does ```python Person.objects.raw(f"SELECT * FROM myapp_person WHERE last_name = '{name}'") ``` The **f-string is evaluated by Python first**. By the time `raw()` is called it receives one finished string, with the user's text already inside the SQL. Django, the driver and the database all see a statement whose structure the user helped write. If `name` is `O'Brien`, the query breaks; if it is `x' OR '1'='1`, the query returns every row; on backends that allow stacked statements the damage can be worse. Swapping the f-string for `%` formatting or `.format()` changes nothing — any string building before the call has the same effect. ## The Django fix: `params` Every raw entry point in Django takes the values **separately from the SQL**: | API | Signature | Placeholder | |---|---|---| | `Manager.raw()` | `raw(raw_query, params=(), translations=None)` | `%s` with a list, `%(key)s` with a dict | | `cursor.execute()` | `execute(sql, params=None)` | `%s` / `%(key)s` | | `RawSQL` | `RawSQL(sql, params, output_field=None)` | `%s` — `params` is **required** | The corrected call: ```python Person.objects.raw("SELECT * FROM myapp_person WHERE last_name = %s", [name]) ``` The value travels to the database driver as a parameter and is treated as data. Points reviewers check: - **`%s` on every backend.** Django expects `%s` even on SQLite, whose own driver uses `?`; Django translates. Do not write `?`. - **Never quote the placeholder.** `WHERE last_name = '%s'` looks harmless but the docs call it out as vulnerable: the driver adds its own quoting, and your quotes change how the value is parsed. - **Dict params** use `%(key)s` placeholders with a dict instead of a list. - **Literal percent signs.** When a statement also takes parameters, a literal `%` in the SQL must be doubled: `WHERE code LIKE 'A%%' AND id = %s`. ## Two traps that survive the fix 1. **`LIKE` searches.** Writing `LIKE '%%%s%%'` puts quotes and wildcards around the placeholder and brings back the quoting problem. Put the wildcards in the **value**: `"... WHERE last_name LIKE %s", [f"%{term}%"]`. If the term itself may contain `%` or `_`, escape those in Python first. 2. **Identifiers.** Table names, column names and `ASC`/`DESC` cannot be bound as parameters — placeholders only stand for values. When a report lets the caller pick a sort column, map the request value through a dict of allowed columns defined in code. `connection.ops.quote_name()` quotes a name for the backend, but it is meant for names your code controls, not a sanitiser for request input. ## The same rule in the other raw corners - **`RawSQL`** makes `params` a required positional argument precisely so you have to acknowledge it; the docs show `RawSQL("... othercol = '%s'")` as the unsafe form. - **`QuerySet.extra()`** takes `params=` for `where` and `select_params=` for `select`; values spliced into those strings are as dangerous as in `raw()`. - **`Func` subclasses** interpolate their keyword arguments (`**extra`) straight into the template, so user input must go in as a positional expression, which becomes a bound parameter. ## A review checklist for raw SQL in Django 1. Is the SQL argument a **literal string** (or a string built only from code-controlled pieces such as `Model._meta.db_table`)? 2. Does every value from a request, a form, a file or another system travel in **`params`**? 3. Are all `%s` placeholders **unquoted**, and are literal percent signs doubled when params are present? 4. Are `LIKE` wildcards part of the **bound value**, not the SQL text? 5. Does any identifier (column, table, sort direction) come from a **dict or set defined in code**? 6. Do custom `Func` subclasses keep user input out of keyword arguments? A call site that passes all six checks cannot be injected through the values it binds; the remaining risk sits in whatever produced the SQL string itself. ## Why the ORM is not the problem The ORM's own methods — `filter(last_name=name)`, `Q`, `F` — always pass values as parameters, which is why a codebase that stays inside the QuerySet API rarely has injection bugs. The risk concentrates exactly where developers take over the SQL string. A useful review habit is to search for `raw(`, `cursor.execute(`, `RawSQL(` and `extra(` and check that each call site has a literal SQL string and a separate params argument.

  • Why is WHERE last_name = '%s' with params still unsafe in Django?
    The driver already quotes a string parameter. Wrapping the placeholder in your own quotes means the substituted value is parsed inside a literal you opened, so the protection no longer lines up with the SQL structure. Django's docs name quoted placeholders explicitly as an injection risk; the rule is simply never to quote `%s`.
  • Does switching from an f-string to Python's % operator fix the raw() call?
    No. `"... = %s" % name` formats the string in Python before `raw()` is called, exactly like the f-string. Safety comes only from passing the value in the separate `params` argument so the driver binds it.
  • How do you let a caller choose the ORDER BY column in a raw query?
    Placeholders bind values, not identifiers, so map the request value through a dict of permitted columns defined in code and interpolate only the mapped name. Anything not in the dict falls back to a default or is rejected.

saying these in an interview costs you the question

  • Says wrapping the %s placeholder in quotes makes a raw query safer
  • Believes replacing the f-string with % formatting fixes the injection
  • Uses '?' placeholders in Django raw SQL on SQLite
  • Tries to pass a table or column name through params
  • Writes LIKE '%%%s%%' instead of putting wildcards in the value
  • Thinks Django validates or escapes the SQL string given to raw()