skip to content

In Django, how does a QueryDict handle a repeated key such as ?status=paid&status=refunded, and why can't a view modify request.GET?

level: juniorimportance: should knowfreq 46%

answer

  1. a list behind every key
  2. indexing gives the last
  3. getlist for all values
  4. copy() before you change

basics

~20 s

A QueryDict stores a list per key: indexing and get() return the last value, getlist() returns all of them. request.GET and request.POST are immutable, so changes raise AttributeError; call copy() to get a mutable version.

solid answer

~30 s

`request.GET` and `request.POST` are `QueryDict` objects, a `MultiValueDict` that keeps a list of values per key. For `?status=paid&status=refunded`, `request.GET["status"]` and `.get("status")` return the **last** value, `"refunded"`, while `.getlist("status")` returns `["paid", "refunded"]`. A missing key with `[]` raises `MultiValueDictKeyError`, a `KeyError` subclass that becomes a 500, so optional parameters use `get()`. The request's instances are immutable: assignment, `pop()` or `update()` raise `AttributeError: This QueryDict instance is immutable`, because they record what the client sent. To build a modified query string, for a pagination link for example, call `copy()`, which returns a mutable deep copy, change it, then `urlencode()` it.

go deeper

for a junior

Recall that indexing and get() return the last value, getlist() returns all, and that request.GET cannot be changed in place.

for a middle

Explain the MultiValueDict structure, MultiValueDictKeyError, why the request's QueryDicts are immutable, and how copy() and urlencode() build new query strings.

for a senior

Review views for silent last-value-wins bugs on repeated parameters, crashes from indexing optional keys, and in-place mutation attempts in middleware.

for a principal

Push query and form parsing through forms or validation layers so repeated-key semantics and type conversion are handled once, not in every view.

## `QueryDict` in one paragraph `request.GET` and `request.POST` are instances of **`django.http.QueryDict`**, a subclass of `MultiValueDict`, which is itself a `dict` subclass that stores a **list of values per key**. Query strings and form bodies can legitimately repeat a key, as in `?status=paid&status=refunded` from a filter with several checkboxes ticked, and a plain dict would lose all but one value. ## Reading values For the request `/payments/?status=paid&status=refunded`: | Expression | Result | |---|---| | `request.GET["status"]` | `"refunded"`: the **last** value | | `request.GET.get("status")` | `"refunded"`; a missing key returns `None` or the given default | | `request.GET.getlist("status")` | `["paid", "refunded"]` | | `request.GET.getlist("missing")` | `[]` | | `request.GET["missing"]` | raises `MultiValueDictKeyError`, a subclass of `KeyError` | | `request.GET.dict()` | `{"status": "refunded"}`: one value per key | | `list(request.GET.lists())` | `[("status", ["paid", "refunded"])]` | Two consequences matter in views: - **Use `getlist()` for multi-select filters.** `get()` silently drops every value but the last. - **Prefer `get()` over indexing for optional parameters.** An uncaught `MultiValueDictKeyError` becomes a 500, not a 400, so `request.GET["page"]` on a URL without `?page=` crashes the view. ## Why it is immutable The `QueryDict` objects on the request are created **immutable**. Any mutating call, whether `request.GET["page"] = "2"`, `setlist()`, `pop()` or `update()`, raises `AttributeError: This QueryDict instance is immutable`. The data describes what the client sent; letting one piece of code rewrite it would make every later reader, from middleware to templates, see something the client never sent. ## `copy()` and building new query strings When you need a modified version, typically to build a pagination or filter link that keeps the other parameters, take a copy: ```python params = request.GET.copy() # mutable deep copy params["page"] = "2" # replaces all values for "page" params.setlist("status", ["paid"]) next_url = f"{request.path}?{params.urlencode()}" ``` - **`copy()`** always returns a **mutable** deep copy, whatever the original's state. - **`urlencode()`** serializes every value of every key back into a query string, repeats included. - **`QueryDict(mutable=True)`** creates an empty mutable instance, and `QueryDict("a=1&a=2")` parses a string. ## How the query string is parsed `request.GET` is built from the raw `QUERY_STRING` when first accessed, with a few rules that explain odd results: | Query string | `request.GET` | |---|---| | `?q=` | `{"q": [""]}`: blank values are kept, so `get("q")` returns `""`, not `None` | | `?flag` | `{"flag": [""]}`: a key without `=` also gets an empty string | | `?a=1&a=2&a=3` | `{"a": ["1", "2", "3"]}`, in the order sent | | `?name=J%C3%BCrgen` | percent-decoded using `request.encoding`, `DEFAULT_CHARSET` (`"utf-8"`) unless changed | | more than 1,000 parameters | `TooManyFieldsSent`, answered with a 400 | The last row is the `DATA_UPLOAD_MAX_NUMBER_FIELDS` setting, 1000 by default, which caps how many fields any `QueryDict` parse may create, GET or POST. It protects the server from requests crafted to make parsing expensive. Blank values catch people out: `if request.GET.get("q") is not None` is true for `?q=`, so a search view should test for an empty string as well. ## Where `QueryDict` also appears - `request.POST` for form-encoded and multipart POSTs, with the same API. - Form binding: `PaymentFilterForm(request.GET)` hands the whole `QueryDict` to a form, whose multi-value widgets call `getlist()` for you. - Tests: the test client builds query strings from dicts and lists, so `self.client.get("/payments/", {"status": ["paid", "refunded"]})` produces the repeated key. ## Pitfalls interviewers probe 1. **`dict(request.GET)` is not `request.GET.dict()`.** The `dict` constructor copies the underlying storage, giving `{"status": ["paid", "refunded"]}`, while the `.dict()` method gives one value per key, `{"status": "refunded"}`. 2. **Mutating in middleware.** Code that tries to "normalize" parameters in place hits the immutability error; attach a derived value to the request instead. 3. **Integer parsing.** Values are always strings; `int(request.GET.get("page", 1))` still raises `ValueError` on `?page=abc`, which needs handling. 4. **Last value wins silently.** A client sending `?amount=10&amount=1000` gets `"1000"` from `get()`; validate through a form when it matters.

  • In Django, how do you build a 'next page' link that keeps every other query parameter from request.GET?
    Copy, modify, encode: `params = request.GET.copy()`, `params["page"] = str(page + 1)`, then `f"?{params.urlencode()}"`. `copy()` gives a mutable deep copy, assignment replaces every value for that key, and `urlencode()` writes repeated keys back out.
  • In Django, what does request.GET.dict() return for ?status=paid&status=refunded?
    `{"status": "refunded"}`: one value per key, the last one, because `dict()` builds the result through normal item access. Use `lists()` or `getlist()` when every value matters.

saying these in an interview costs you the question

  • request.GET['status'] returns the first value when a key repeats.
  • request.GET.get() returns a list when a key has several values.
  • A view can assign request.GET['page'] to rewrite the query for later code.
  • request.GET['missing'] returns None when the key is absent.
  • QueryDict.copy() returns another immutable QueryDict.