Filter concepts by levelShowing all levels.

Django · Section 49

DRF Filtering, Search, and Ordering

Level
advanced
Read
30 min
Concepts
3

Filtering happens in `filter_queryset()`, between `get_queryset()` and pagination — an ordering that is the whole safety argument, because a backend can only narrow the set authorization already fixed. `DjangoFilterBackend` (from `django_filters.rest_framework`, not DRF itself) turns query parameters into a declared surface: `filterset_fields` for exact matches, its dict form for ranges, and a `FilterSet` class when a parameter needs its own public name, a lookup other than exact, or a method for something the ORM cannot say declaratively. Building `filter(**query_params)` by hand instead exposes every model field and every relation, and turns a typo into a `FieldError` 500. DRF ships two more backends: `SearchFilter`, one `?search=` parameter across `search_fields`, where an unprefixed entry compiles to the unindexable `icontains` and the prefixes `^`, `=`, `@`, `$` select something cheaper or more precise; and `OrderingFilter`, which must be given an explicit `ordering_fields`, since its default allows sorting by any serializer-readable field — a leak the DRF docs call out by name. A custom backend is a single `filter_queryset()` method, which is how a project-wide rule reaches every list endpoint at once. Finally, the threat model: filtering is not an injection risk, it is a cost risk, so validate parameter names (DRF ignores unknown ones silently), bound date ranges, restrict sorting to indexed columns, prefer cursor pagination over deep offsets, and keep `statement_timeout` underneath it all.

What is true here

  1. Filter backends run after get_queryset() and before pagination, so they narrow and never widen.
  2. A FilterSet is a closed, declared list of parameters with type coercion and 400-level errors; filter(**query_params) is not.
  3. SearchFilter without a prefix means icontains — a full scan that stops scaling around a million rows.
  4. Leaving ordering_fields unset allows ordering by any serializer-readable field, which the docs flag as a data leak.
  5. Unknown query parameters are ignored with a 200 — validate names, or a client typo silently returns everything.

What you will be able to do

  • Expose a filter surface as a declaration rather than as ad-hoc parsing inside `get_queryset()`
  • Choose between `filterset_fields`, its dict form, and a full `FilterSet` class
  • Use `search_fields` prefixes deliberately, and recognise when search has outgrown `SearchFilter`
  • Write a custom filter backend to enforce a cross-cutting rule on every list endpoint
  • Bound the expensive query shapes — wide ranges, deep offsets, unindexed sorts — with explicit 400s
The list pipeline — where filtering sits, and what each stage may do
the ceiling — neverwidened after this400 on aninvalid value

GET /orders/?…

the caller's query string

get_queryset()

authorization — sets the widest visible set

filter_queryset()

every backend in filter_backends, in order

DjangoFilterBackend

declared parameters, coerced values, 400 on bad input

SearchFilter

?search= across search_fields

OrderingFilter

?ordering= within ordering_fields

Pagination

cuts a page from the filtered, ordered set

Serializer → response

  • GET /orders/?… — the caller's query string
    • leads to get_queryset()
  • get_queryset() — authorization — sets the widest visible set
    • leads to filter_queryset() (the ceiling — never widened after this)
  • filter_queryset() — every backend in filter_backends, in order
    • leads to DjangoFilterBackend
  • DjangoFilterBackend — declared parameters, coerced values, 400 on bad input
    • leads to SearchFilter
    • on error, leads to GET /orders/?… (400 on an invalid value)
  • SearchFilter — ?search= across search_fields
    • leads to OrderingFilter
  • OrderingFilter — ?ordering= within ordering_fields
    • leads to Pagination
  • Pagination — cuts a page from the filtered, ordered set
    • leads to Serializer → response
  • Serializer → response

Declaring the filter surface

Query parameters, django-filter, and the FilterSet as a closed list of what an endpoint accepts.

Query parameters, django-filter, and FilterSet

coreintermediate

Filtering narrows a list endpoint from the URL: `GET /orders/?status=paid&created_after=2026-01-01`. DRF runs this through *filter backends* — classes listed in `filter_backends` that each get a chance to transform the queryset before pagination. You can do it by hand by reading `self.request.query_params` inside `get_queryset()`, and for one or two parameters that is perfectly reasonable. Beyond that, `django-filter` supplies `DjangoFilterBackend`: declare `filterset_fields = ["status", "customer"]` for plain equality matching, or write a `FilterSet` class when a parameter needs a lookup other than exact — a range, a date comparison, a name that differs from the model field.

Think of it as

A filter backend is a queryset-to-queryset function that runs after `get_queryset()` and before pagination, and that ordering is the whole design. `get_queryset()` decides what this caller is *allowed* to see; filter backends decide which slice of that they *asked* for. Keeping those separate is what makes filtering safe to expose: no query parameter can widen the set, because the permitted set was fixed before any backend ran. The step up from hand-rolled parsing to a `FilterSet` is about surface area, not elegance. Hand-parsing means you write the "is this parameter present", "is this value a valid date", "which lookup does this map to" logic yourself, once per parameter, and the failure mode of getting it wrong is a 500 rather than a 400. A `FilterSet` is a declaration: it names exactly which parameters exist, what each one maps to, and how each value is coerced — so an unknown parameter is ignored and a malformed one is a validation error, both for free.

python
filter_backends = [DjangoFilterBackend]
filterset_class = OrderFilter     # or: filterset_fields = ["status", "customer"]

What we're doing: Expose a small, deliberate filter surface on an orders endpoint, including one parameter the ORM cannot express declaratively.

orders/filters.py + orders/views.pypython
class OrderFilter(django_filters.FilterSet):
    status = django_filters.ChoiceFilter(choices=Order.Status.choices)
    min_total = django_filters.NumberFilter(field_name="total", lookup_expr="gte")
    placed_after = django_filters.DateFilter(field_name="created_at", lookup_expr="gt")
    has_refund = django_filters.BooleanFilter(method="filter_has_refund")

    class Meta:
        model = Order
        fields = ["status", "min_total", "placed_after", "has_refund"]

    def filter_has_refund(self, queryset, name, value):
        return queryset.filter(refunds__isnull=not value).distinct()


class OrderViewSet(viewsets.ModelViewSet):
    serializer_class = OrderSerializer
    filter_backends = [DjangoFilterBackend]
    filterset_class = OrderFilter

    def get_queryset(self):
        return Order.objects.filter(customer=self.request.user)
2
`ChoiceFilter` rejects a value outside the model's own choices with a 400 rather than passing it to the database and returning an empty list — a wrong value and a valid-but-empty result stop looking alike.
3–4
`field_name` decouples the public parameter name from the column. `min_total` and `placed_after` read as API vocabulary; `total__gte` and `created_at__gt` leak the schema.
5–12
`method=` is the escape hatch for anything declarative filters cannot say. `.distinct()` matters here — joining to a reverse relation can multiply rows.
9
`Meta.fields` is the closed list. A parameter absent from it does not exist, which is what keeps the surface auditable.
20–21
Ownership scoping stays in `get_queryset()`, above every filter. No query parameter can reach another customer's orders, whatever it asks for.

Why this works: The `FilterSet` turns "which parameters does this endpoint accept" from something you reconstruct by reading `get_queryset()` into a declaration you can read in one place — and gives type coercion, choice validation, and 400-level errors without writing any of them.

Passing a query parameter straight into `filter(**...)`

Wrong

python
def get_queryset(self):
    return Order.objects.filter(**self.request.query_params.dict())

Better

python
filter_backends = [DjangoFilterBackend]
filterset_class = OrderFilter   # the accepted parameters are a closed, declared list

What you see: `?customer__email__icontains=@rival.com` filters across a relation nobody meant to expose, and `?nonsense=1` raises `FieldError: Cannot resolve keyword 'nonsense' into field` — a 500 for what should be a 400.

Why: Kwargs built from user input make every model field, and every field reachable through every relation, part of the API surface — including ones the serializer deliberately never returns. The information leak is real: a filter that narrows on `customer__email` lets a caller confirm an email address without ever seeing it in a response body. A declared `FilterSet` inverts the default: nothing is filterable until it is named.

From a raw query string to a narrowed queryset

1 · get_queryset() sets the ceiling

Authorization decides the widest set this caller may ever see. Nothing a filter backend does can widen it.

2 · The FilterSet declares the parameters

Each filter names the model field, the lookup, and the value type. Anything not declared here does not exist as a parameter.

3 · The backend validates and applies

Values are coerced by the declared filter type. A bad date is a 400 with a field-level message, never a 500 from the database driver.

4 · Pagination runs last

The page is cut from the already-filtered queryset, so counts and next-page links describe the filtered set, not the whole table.

  1. 1 · get_queryset() sets the ceiling — Authorization decides the widest set this caller may ever see. Nothing a filter backend does can widen it.
  2. 2 · The FilterSet declares the parameters — Each filter names the model field, the lookup, and the value type. Anything not declared here does not exist as a parameter.
  3. 3 · The backend validates and applies — Values are coerced by the declared filter type. A bad date is a 400 with a field-level message, never a 500 from the database driver.
  4. 4 · Pagination runs last — The page is cut from the already-filtered queryset, so counts and next-page links describe the filtered set, not the whole table.

Three ways to declare the same filter, in increasing order of control

Three ways to declare the same filter, in increasing order of control
DeclarationGeneratesReach for it when
`get_queryset()` by handwhatever you writeone or two parameters, no coercion worth naming
`filterset_fields = ["status"]``?status=paid`exact matches on model fields
`filterset_fields = {"total": ["gte", "lte"]}``?total__gte=100&total__lte=500`ranges without writing a class
`filterset_class = OrderFilter`whatever the class declaresrenamed parameters, custom methods, cross-field logic

Together

python
class OrderViewSet(viewsets.ModelViewSet):
    filter_backends = [DjangoFilterBackend]
    filterset_fields = {"status": ["exact"], "created_at": ["gte", "lte"]}
# GET /orders/?status=paid&created_at__gte=2026-01-01

Remember: Filter backends run after `get_queryset()` and before pagination, so they can only narrow what authorization already permitted — never widen it. Use `filterset_fields` for exact matches, its dict form for ranges, and a `FilterSet` class when a parameter needs its own name, lookup, or method. Never build `filter(**query_params)`: it exposes every field and every relation, and turns a typo into a 500.

See also: search and ordering filters · query validation and complexity controls · customizing generic view behavior

Advertisement

Search, ordering, and custom backends

The two shipped backends, their costs, and writing one of your own to apply a rule everywhere.

SearchFilter, OrderingFilter, and custom backends

coreintermediate

DRF ships two more backends beyond `DjangoFilterBackend`. `SearchFilter` reads a single `?search=` parameter and matches it against the fields you list in `search_fields`, defaulting to a case-insensitive "contains" match, with prefix characters selecting a different lookup. `OrderingFilter` reads `?ordering=` and sorts by the fields listed in `ordering_fields`, with a leading `-` for descending. Both are opt-in per view. A custom backend is any class with a `filter_queryset(self, request, queryset, view)` method returning a queryset — the same contract the shipped ones implement, which is how a project-wide rule (tenant scoping, soft-delete hiding) can be applied everywhere at once.

Think of it as

`SearchFilter` is a convenience, not a search engine, and the distinction becomes expensive at scale: an unprefixed `search_fields` entry compiles to `icontains`, which is `LIKE '%term%'` — a pattern no B-tree index can serve, so every row is examined. That is fine on ten thousand rows and unusable on ten million, at which point the answer is PostgreSQL full-text search or a dedicated engine, not a bigger `search_fields`. `OrderingFilter` carries a different hazard, and it is one the DRF docs call out directly: leave `ordering_fields` unset and the filter falls back to allowing ordering on any field readable on the serializer. That is a data-leak vector rather than a performance one — ordering by a field is a slow read of its contents, one row at a time, and it works even for fields the response never displays. Naming `ordering_fields` explicitly is the fix, and it is the same instinct as a declared `FilterSet`: the accepted surface should be a list you wrote, not a default you inherited.

python
filter_backends = [SearchFilter, OrderingFilter]
search_fields = ["^sku", "name"]
ordering_fields = ["name", "price"]   # never leave this unset

What we're doing: Add project-wide tenant scoping as a custom backend, alongside per-view search and ordering.

common/filters.py + config/settings.pypython
class TenantScopeBackend(BaseFilterBackend):
    def filter_queryset(self, request, queryset, view):
        if not request.user.is_authenticated:
            return queryset.none()
        if not hasattr(queryset.model, "tenant_id"):
            return queryset
        return queryset.filter(tenant_id=request.user.tenant_id)


REST_FRAMEWORK = {
    "DEFAULT_FILTER_BACKENDS": [
        "common.filters.TenantScopeBackend",
        "django_filters.rest_framework.DjangoFilterBackend",
        "rest_framework.filters.SearchFilter",
        "rest_framework.filters.OrderingFilter",
    ],
}


class ProductViewSet(viewsets.ModelViewSet):
    search_fields = ["^sku", "name"]
    ordering_fields = ["name", "price", "created_at"]
    ordering = ["-created_at"]
2
The entire backend contract: take a queryset, return a queryset. Everything DRF ships implements exactly this method.
5–6
Models without a tenant column pass through untouched, so one backend can sit in the global default list without breaking unrelated endpoints.
12
Listing the tenant backend first means it narrows before anything else runs — every later backend operates on an already-scoped queryset.
22
Naming `ordering_fields` explicitly. Leaving it out would let a caller sort by any serializer-readable field, which the DRF docs flag as a data-leak risk.
23
`ordering` supplies the default sort. Without one, page 2 of an unordered queryset can repeat or skip rows that page 1 already returned.

Why this works: A cross-cutting rule expressed once as a backend is enforced on every list endpoint automatically, including ones added later — much harder to forget than a `get_queryset()` override each view has to remember to write.

Leaving `ordering_fields` unset on a view whose serializer exposes more than the response shows

Wrong

python
class UserViewSet(viewsets.ReadOnlyModelViewSet):
    filter_backends = [OrderingFilter]
    serializer_class = UserSerializer      # no ordering_fields declared
# GET /users/?ordering=password  — sorts by the hash, one binary-search request at a time

Better

python
class UserViewSet(viewsets.ReadOnlyModelViewSet):
    filter_backends = [OrderingFilter]
    serializer_class = UserSerializer
    ordering_fields = ["date_joined", "username"]

What you see: No error and no unusual response — just a working sort on a field the API never returns, which an attacker uses to read that field's contents by comparing orderings across requests.

Why: With `ordering_fields` unset, `OrderingFilter` allows any field readable on the serializer, which the DRF documentation calls out specifically as a way to leak data such as a password hash. Sorting is a read: it reveals relative order, and enough orderings reconstruct the value. The fix is to declare the list, and it costs one line.

Two shipped backends, and what each one costs you

SearchFilter · ?search=

search_fields = ["^sku", "name"]

the closed list of what is searched

No prefix means icontains

LIKE '%term%' — a full scan, no index can help

^ means istartswith

an index can serve this one

Past ~1M rows, switch

PostgreSQL full-text or a real search engine

OrderingFilter · ?ordering=

ordering_fields = ["price"]

the closed list of what may be sorted on

Unset = any serializer field

the documented data-leak default — always name the list

-price sorts descending

comma-separate for multiple keys

ordering = ["-created_at"]

the default when the client sends nothing — also what keeps pages stable

  • GET /products/?search=blue&ordering=-price
  • SearchFilter · ?search= — one parameter, many fields, OR-ed together
    • search_fields = ["^sku", "name"] — the closed list of what is searched
    • No prefix means icontains — LIKE '%term%' — a full scan, no index can help
    • ^ means istartswith — an index can serve this one
    • Past ~1M rows, switch — PostgreSQL full-text or a real search engine
  • OrderingFilter · ?ordering= — sorts what is left, before pagination cuts the page
    • ordering_fields = ["price"] — the closed list of what may be sorted on
    • Unset = any serializer field — the documented data-leak default — always name the list
    • -price sorts descending — comma-separate for multiple keys
    • ordering = ["-created_at"] — the default when the client sends nothing — also what keeps pages stable

`search_fields` prefixes

`search_fields` prefixes
PrefixLookupExample entryMatches
(none)`icontains``"title"`anywhere in the value — cannot use an index
`^``istartswith``"^sku"`the start of the value — an index can serve this
`=``iexact``"=email"`the whole value, case-insensitively
`@``search``"@body"`PostgreSQL full-text search only
`$``iregex``"$reference"`a regular expression — never expose to untrusted input

Together

python
class ProductViewSet(viewsets.ModelViewSet):
    filter_backends = [SearchFilter, OrderingFilter]
    search_fields = ["^sku", "name", "=barcode"]
    ordering_fields = ["name", "price", "created_at"]
    ordering = ["-created_at"]

Remember: `SearchFilter` is one `?search=` parameter across `search_fields`; no prefix means `icontains`, which cannot use an index, so it stops scaling around a million rows. `OrderingFilter` reads `?ordering=` — and always declare `ordering_fields`, because the default allows any serializer-readable field and can leak one. Set a default `ordering` too, or paginated pages will repeat and skip rows. A custom backend is one method, and in `DEFAULT_FILTER_BACKENDS` it applies project-wide.

See also: query parameters and django filter · query validation and complexity controls · indexes

Advertisement

Validation and query cost

Rejecting unknown parameters, bounding ranges and depth, and refusing the shapes that cannot be served cheaply.

Query validation, complexity controls, and expensive filters

coreadvanced

Every filter parameter you expose is a query a stranger gets to write. Three defences keep that manageable. **Validate**: reject a malformed or unknown parameter with a 400 instead of ignoring it or letting it reach the database as a 500. **Bound**: cap page size, cap how far back a date range may reach, and require the parameters that make a query cheap. **Refuse**: some filters are expensive no matter how they are written — an unbounded `icontains` across a joined table, a regex filter, an unindexed sort on a huge table — and the right answer is not to expose them, or to expose them only behind a narrower scope.

Think of it as

The threat here is not injection — the ORM parameterises for you — it is *cost*. A single request that costs the database ten seconds is a denial-of-service primitive that needs no exploit, just a wide date range and a repeat button. So think in terms of the worst query your parameter set can express, not the typical one, and ask what a caller who is actively trying to be expensive would send. That worst case is usually a combination rather than a single parameter: filtering is cheap, sorting is cheap, but filtering on an unindexed column *and then* sorting by another *and then* asking for page 900 forces the database to materialise and sort the whole table to throw almost all of it away. Two habits follow. First, the silence problem: DRF ignores unknown query parameters, so a client typo returns a full unfiltered list that looks like a successful search — validate the parameter names, and the failure becomes visible on the first request rather than in a bug report. Second, make required things required: if an endpoint is only affordable when scoped to one account or one month, that scope is not an optional filter, it is part of the contract, and the endpoint should 400 without it.

python
class StrictFilterSet(FilterSet):
    def is_valid(self):
        unknown = set(self.data) - set(self.filters) - {"page", "page_size", "ordering"}
        if unknown:
            raise ValidationError({"detail": f"Unknown parameters: {sorted(unknown)}"})
        return super().is_valid()

What we're doing: Make an events endpoint refuse the queries it cannot serve cheaply, instead of serving them slowly.

events/filters.pypython
MAX_RANGE = timedelta(days=90)


class EventFilter(django_filters.FilterSet):
    since = django_filters.IsoDateTimeFilter(
        field_name="occurred_at", lookup_expr="gte", required=True)
    until = django_filters.IsoDateTimeFilter(
        field_name="occurred_at", lookup_expr="lte", required=True)
    level = django_filters.ChoiceFilter(choices=Event.Level.choices)

    class Meta:
        model = Event
        fields = ["since", "until", "level"]

    def is_valid(self):
        known = set(self.filters) | {"page", "page_size", "ordering"}
        unknown = set(self.data) - known
        if unknown:
            raise ValidationError(
                {"detail": f"Unknown query parameters: {sorted(unknown)}"})
        return super().is_valid()

    def clean(self):
        cleaned = super().clean()
        since, until = cleaned.get("since"), cleaned.get("until")
        if since and until and until - since > MAX_RANGE:
            raise ValidationError(
                {"until": f"Range may not exceed {MAX_RANGE.days} days."})
        return cleaned
5–8
`required=True` turns the scope into part of the contract. Without both bounds the endpoint would have to scan the whole table, so it refuses rather than trying.
9
`ChoiceFilter` rejects an unknown level with a 400. A plain `CharFilter` would pass it to the database and return an empty list, which reads like "no matching events".
15–21
The check DRF does not do for you. Without it, `?sinse=…` is ignored, `since` is missing, and the request fails on `required` — but a typo in an optional parameter would silently return an unfiltered page.
23–28
The cost bound. Ninety days is a number chosen from what the index can serve; the point is that the limit is stated and enforced rather than discovered in production.

Why this works: Each of the three gates converts a class of slow request into a fast, explicit 400. The caller learns what is wrong on the first attempt, and the database never sees the query at all — which is the difference between an endpoint that degrades under load and one that stays predictable.

Capping `page_size` but leaving page depth unbounded

Wrong

python
class EventPagination(PageNumberPagination):
    page_size = 50
    max_page_size = 100
# GET /events/?page=40000 -> LIMIT 50 OFFSET 1999950

Better

python
class EventPagination(CursorPagination):
    page_size = 50
    max_page_size = 100
    ordering = "-occurred_at"    # indexed, stable — cost does not grow with depth

What you see: Page 1 answers in 8ms and page 40,000 takes 12 seconds on the same endpoint, with identical parameters otherwise. Monitoring shows a small number of very slow requests and no obvious cause.

Why: `OFFSET n` is not a seek. The database produces every one of the first n rows in order, discards them, and returns the next page — so cost grows linearly with depth while `max_page_size` caps only the width. Cursor pagination replaces the offset with a `WHERE occurred_at < <cursor>` condition, which an index on the ordering column serves in constant time regardless of how deep the client has walked.

Three gates between a query string and the database
noyesnoyesnoyesstilltoo slow

Incoming query string

?search=…&ordering=…&page=900

Gate 1 · are the parameter names known?

unknown names are ignored by default — check them yourself

Gate 2 · do the values validate?

the FilterSet coerces types and checks choices

Gate 3 · is the resulting query affordable?

range width, page depth, sort column, join fan-out

400 · unknown parameter

names the parameter, so a typo is found on the first call

400 · invalid value

per-field message from the declared filter type

400 · range too wide / depth too deep

refuse the shape, do not just run it slowly

Query runs

bounded, indexed, and paginated

statement_timeout

the database-side backstop for whatever slipped through

  • Incoming query string — ?search=…&ordering=…&page=900
    • leads to Gate 1 · are the parameter names known?
  • Gate 1 · are the parameter names known? — unknown names are ignored by default — check them yourself
    • on error, leads to 400 · unknown parameter (no)
    • leads to Gate 2 · do the values validate? (yes)
  • Gate 2 · do the values validate? — the FilterSet coerces types and checks choices
    • on error, leads to 400 · invalid value (no)
    • leads to Gate 3 · is the resulting query affordable? (yes)
  • Gate 3 · is the resulting query affordable? — range width, page depth, sort column, join fan-out
    • on error, leads to 400 · range too wide / depth too deep (no)
    • leads to Query runs (yes)
  • 400 · unknown parameter — names the parameter, so a typo is found on the first call
  • 400 · invalid value — per-field message from the declared filter type
  • 400 · range too wide / depth too deep — refuse the shape, do not just run it slowly
  • Query runs — bounded, indexed, and paginated
    • leads to statement_timeout (still too slow)
  • statement_timeout — the database-side backstop for whatever slipped through

The expensive shapes, and what to do about each

The expensive shapes, and what to do about each
ShapeWhy it is expensiveControl
`?search=` with unprefixed fields`LIKE '%x%'` — no index is usableprefix with `^`, or move to full-text search
`?page=900``OFFSET` produces and discards every earlier rowcursor pagination, or cap `max_page_size` and total depth
`?ordering=` on an unindexed columnsorts the whole filtered setrestrict `ordering_fields` to indexed columns
unbounded date rangescans years to return a pagerequire a range, and cap its width
regex filter (`$` prefix)unindexable, and can backtrack catastrophicallydo not expose it to untrusted callers
filter across a many relationrow multiplication, then `DISTINCT`use `Exists()` rather than a join, or precompute a flag

Together

python
class OrderFilter(FilterSet):
    placed_after = filters.DateFilter(field_name="created_at", lookup_expr="gte", required=True)
    placed_before = filters.DateFilter(field_name="created_at", lookup_expr="lte", required=True)

Remember: Every exposed filter is a query a stranger writes, and the threat is cost, not injection. Reject unknown parameter names — DRF ignores them, so a typo returns a full unfiltered list with a 200. Validate values through declared filter types so a bad one is a 400, not an empty result or a 500. Bound the expensive shapes: require a date range and cap its width, restrict `ordering_fields` to indexed columns, and use cursor pagination so depth does not cost anything. Keep `statement_timeout` as the backstop.

See also: search and ordering filters · query parameters and django filter · query plans

Advertisement