---
url: "https://vueda.dev/v3/core-concepts/filtering-and-ordering-semantics.md"
title: "Filtering and Ordering Semantics"
---

# Filtering and Ordering Semantics

Filtering and ordering pass through four places. The [Canonical Viewset](/v3/reference/glossary.md#canonical-viewset) declares the filters and ordering fields. [Model Info](/v3/reference/glossary.md#model-info) publishes those declarations. `list` and `retrieve` endpoints reject query parameters outside them. The client builds its filter and sort controls from the published metadata.

This page describes what the server accepts, rejects, and publishes at each step, and how the client uses it. [Configuration Surface and Defaults](/v3/core-concepts/configuration-surface-and-defaults.md#wire-query-parameter-namespace) lists the [Wire Query Parameters](/v3/reference/glossary.md#wire-query-parameters), and [Declare List Ordering](/v3/guides/declare-list-ordering.md) gives the steps for setting up a list's ordering.

## Contract Boundary and Authority

Model info reads `filterset_class`, `ordering`, and `ordering_fields` from the canonical viewset. A model registered with a serializer only ([Serializer-Only Registration](/v3/reference/glossary.md#serializer-only-registration)) has an empty `model_filtering` section, because it has no filterset. Its `model_ordering` section still reports the model's own [`Meta.ordering`](https://docs.djangoproject.com/en/6.1/ref/models/options/#django.db.models.Options.ordering).

A serializer field is filterable only when the viewset's filterset declares a filter for it. Sorting follows [DRF: `OrderingFilter`](https://www.django-rest-framework.org/api-guide/filtering/#orderingfilter). When the viewset declares no `ordering_fields`, every readable serializer field is sortable under its `source`. A field renamed with `source="name"` sorts as `name`. A field sourced from a Python model property is not sortable, because the database has no column for it. When the viewset declares `ordering_fields`, only the fields that it names, plus the default ordering's fields, are sortable.

## Metadata Projection for Ordering and Filtering

### Ordering metadata

[`model_ordering`](/v3/reference/api/rest/schemas/ModelInfoOrdering.md) has two keys:

- `default` lists the names of the fields or queryset annotations that the rows are sorted by when a request sends no `o`. A path that ends in a transform is listed under its dotted name, such as `created.year`. It comes from the viewset's `ordering` when that is declared, and from the model's `Meta.ordering` otherwise. The two are never merged.
- `fields` lists every field that a request may name in `o`, each with a `name` and a semantic `type`. A field that the default ordering names also carries `ascending`.

`fields` follows the viewset's `ordering_fields`. An explicit list reports its entries, and `"__all__"` reports the model's fields and the annotations that `get_queryset` adds. With no `ordering_fields`, `fields` reports the serializer's readable fields by `source`. Every field that the default ordering reads also appears in `fields`. [`VuedaOrderingFilter`](/v3/reference/api/py/vueda.core.filters/VuedaOrderingFilter.md) accepts an explicit `o` for each of them.

Every name in `model_ordering` is the [public name](/v3/reference/glossary.md#public-name) that a client sends back in `o`. Django's `"pk"` alias is reported as the field behind it: `id`, or each field of a [Composite Primary Key](/v3/reference/glossary.md#composite-primary-key). The ordering backend accepts both `o=id` and `o=pk`.

[`get_model_ordering`](/v3/reference/api/py/vueda.info.serializers/ModelInfoSerializer.md#get_model_ordering) documents each rule in full. [core.ordering](/v3/reference/api/py/vueda.core.ordering.md) documents how an ordering term is read.

A default ordering is reported whole or not at all. `default` is empty when any of its terms cannot be reported under one name. Two kinds of term cause that:

- A term that resolves to no field and no queryset annotation, such as a field renamed without updating `ordering`. Every `list` request that falls back to it fails with Django's `FieldError`.
- A term that reads more than one column, or none, such as `Concat("first_name", "last_name")` or `"?"`. The rows are sorted, and nothing describes the sort.

A viewset `ordering` that fails to resolve does not fall back to the model's `Meta.ordering`. DRF passes the viewset's `ordering` to [`order_by()`](https://docs.djangoproject.com/en/6.1/ref/models/querysets/#django.db.models.query.QuerySet.order_by) as written, so the request fails.

The `vueda_info.E006` system check reports a viewset `ordering` or `ordering_fields` entry that names no field. Django's `models.E015` reports the same drift in `Meta.ordering`. E006 follows the same resolution as the metadata, so a `"pk"` alias, a `formatted_name` reached through a lookup expression, a path that ends in a transform such as `created__year`, and an annotation that `get_queryset` adds are all accepted. For a path that doesn't resolve, E006 quotes Django's own `FieldError` message. E006 builds the viewset's queryset to learn its annotations. When `get_queryset` raises without a request, for example because it reads `self.request`, E006 skips that viewset.

An `ordering_fields` entry that names no field is left out of `fields`, and the other entries stay. The ordering backend still accepts `o` for that entry, because it validates against the declared names. A request that names it fails in `order_by()` with a server error.

### Queryset Annotations

An annotation that the viewset's `get_queryset` adds is orderable. It appears in `fields` when `ordering_fields` names it or is `"__all__"`, or when the default ordering names it. Its `type` comes from the annotation's `output_field`. The `type` is `alpha` when Django cannot resolve one.

A default ordering that names an annotation is reported under the annotation's name, in `default` and in `fields`. A transform on an annotation, such as `launched__year`, is reported the same way, under its dotted name `launched.year`, and typed from the transform's output. The metadata builds the viewset's queryset without a request, so a `get_queryset` that reads `self.request` fails the model-info request with a server error. A multi-column function becomes reportable once it is annotated and the ordering names the annotation, or once a [Django: GeneratedField](https://docs.djangoproject.com/en/6.1/ref/models/fields/#django.db.models.GeneratedField) or a database view column holds it. [Declare List Ordering](/v3/guides/declare-list-ordering.md#make-a-function-default-visible) shows each option.

### Database Functions in a Default Ordering

A default ordering term can be any expression that `order_by()` accepts. That includes scalar functions such as [Django: Lower](https://docs.djangoproject.com/en/6.1/ref/models/database-functions/#django.db.models.functions.Lower) and [Django: Coalesce](https://docs.djangoproject.com/en/6.1/ref/models/database-functions/#django.db.models.functions.Coalesce). VUEDA reads the columns that a term uses from Django's expression tree, so any [Django: Func](https://docs.djangoproject.com/en/6.1/ref/models/expressions/#django.db.models.Func) subclass works. Window functions need a [Django: Window](https://docs.djangoproject.com/en/6.1/ref/models/expressions/#django.db.models.expressions.Window), as Django requires.

A term that reads one column is reported under that column. Its `type` describes the column, and `ascending` follows the term's direction. `Lower("name").desc()` is reported as `name`, type `alpha`, with `ascending: false`.

A term that reads more than one column leaves `default` empty. Each column still appears in `fields` without `ascending`, so a client can offer each as a sort. A term that reads no column, such as `"?"` or [Django: Now](https://docs.djangoproject.com/en/6.1/ref/models/database-functions/#django.db.models.functions.Now), leaves `default` empty. It adds nothing to `fields`. A viewset can list `"?"` in `ordering_fields` to accept `o=?`, and `fields` does not report it.

An explicit `o` sorts by the column itself. `o=name` against a `Lower("name")` default is a case-sensitive sort. The VUEDA client does not send the default ordering back, so a list that the reader has not sorted keeps the function.

### `formatted_name` as an ordering and filter target

A model's [Formatted Name](/v3/reference/glossary.md#formatted-name) is orderable when the database can sort it: a `formatted_name` column, or a path named by `formatted_name_lookup_expression`. Metadata reports either as `formatted_name`, typed from the column that the path reaches. [Create a CRUD Surface](/v3/guides/create-crud-surface.md#the-formatted-name-contract) describes how a model configures its formatted name.

A `formatted_name` computed by `get_formatted_name()` has no column. It is left out of `fields`, and a default ordering that names it is not reported. The `vueda_info.E005` system check reports ordering declared on a computed `formatted_name`.

A model's `Meta.ordering` may name its own lookup-expression `formatted_name` when its default manager is a [FormattedNameManager](/v3/reference/api/py/vueda.core.models/FormattedNameManager.md). Django's `models.E015` skips that one term. A related path such as `customer__formatted_name` in `Meta.ordering` still fails `models.E015`.

A viewset's ordering and filters can name a related model's `formatted_name` at any depth, such as `customer__formatted_name`. A client sends the dotted form, `o=customer.formatted_name`. On the server, `VuedaOrderingFilter` and the filterset rewrite the path to the column behind it, such as `customer__data__formatted_name`. Metadata and the accepted query names use the declared path. A request that names the rewritten path is rejected.

Two related shapes are left unrewritten, as [core.formatted\_name.resolve\_formatted\_name\_path](/v3/reference/api/py/vueda.core.formatted_name.md#resolve_formatted_name_path) describes. One is a related `formatted_name` computed by `get_formatted_name()`. The other is a path through a reverse foreign key or many-to-many, which would multiply rows. In ordering, E006 reports both, and metadata leaves them out.

A related `formatted_name` that is a real column is an ordinary field path. Through a many-valued relation it is neither rewritten nor reported, and it multiplies rows like any other such path.

### The Filterset Half of the Rewrite

A filter builds its query from its `field_name`, so the rewrite happens on the filterset. [FormattedNamePathFilterSetMixin](/v3/reference/api/py/vueda.core.filters/FormattedNamePathFilterSetMixin.md) rewrites `field_name` on each filterset instance's copy of its filters. The class declaration keeps the declared path, and the generated filter label comes from it.

Both VUEDA filterset bases include the mixin: [VuedaFilterSet](/v3/reference/api/py/vueda.core.filters/VuedaFilterSet.md) and [VuedaCompositePrimaryKeyFilterSet](/v3/reference/api/py/vueda.core.filters/VuedaCompositePrimaryKeyFilterSet.md). A filterset built on [django-filter: `FilterSet`](https://django-filter.readthedocs.io/en/stable/ref/filterset.html#django_filters.filterset.FilterSet) directly gets no rewrite. A filter on `customer__formatted_name` there raises `FieldError`, a server error, on each request that sends it. [Declare List Ordering](/v3/guides/declare-list-ordering.md#order-and-filter-by-a-related-formatted-name) shows how to add the mixin.

No system check reads a filter's `field_name`. A filter on one of the two unrewritten shapes stays in `model_filtering`, and a request that sends it raises `FieldError`.

`VuedaCompositePrimaryKeyFilterSet` declares no default filters, because a composite primary key model has no `id` field. [Set Up CRUD for a Composite Primary Key Model](/v3/guides/composite-primary-keys.md) covers its filters.

A transform may follow `formatted_name` in a filter's `field_name`. The rewrite keeps it: `customer__formatted_name__unaccent` queries `customer__data__formatted_name__unaccent`, and `formatted_name__unaccent` on the model's own annotation is left for Django to apply. See [Transforms in an Ordering Path](/v3/core-concepts/filtering-and-ordering-semantics.md#transforms-in-an-ordering-path) for which transforms Django accepts.

```python
class CartFilterSet(VuedaFilterSet):
    customer_name = rest_framework.CharFilter(
        field_name="customer__formatted_name__unaccent",
        lookup_expr="icontains",
    )
```

Whether a transform also applies to the value that a client sends depends on the transform:

| `field_name`                         | Lookup      | Applied to                        | Client sends | Matches "José Smith" |
| ------------------------------------ | ----------- | --------------------------------- | ------------ | -------------------- |
| `customer__formatted_name__unaccent` | `icontains` | the column and the client's value | `jose`       | Yes                  |
| `customer__formatted_name__lower`    | `exact`     | the column only                   | `josé smith` | Yes                  |
| `customer__formatted_name__lower`    | `exact`     | the column only                   | `José Smith` | No                   |
| `customer__formatted_name`           | `iexact`    | the column and the client's value | `JOSÉ SMITH` | Yes                  |

Django marks `unaccent` as bilateral, which is what makes it apply to both sides. It needs `django.contrib.postgres` in `INSTALLED_APPS` and the `unaccent` database extension. `lower` and `upper` are database functions that a project registers as transforms itself, with `CharField.register_lookup(Lower)`, and they apply to the column alone. For a case-insensitive match, use the `iexact` or `icontains` lookup on `customer__formatted_name` instead of a `lower` transform.

django-filter can't build a label from such a path, because `formatted_name` on a model that reaches it through a lookup expression is not a model field. The label falls back to the filter's own name and lookup, so `customer_name` above is labelled "Customer Name Contains". Declare a `label` when that doesn't read well to the person using the list (see [Filter labels](/v3/core-concepts/filtering-and-ordering-semantics.md#filter-labels)).

### Filtering metadata

[`model_filtering`](/v3/reference/api/rest/schemas/ModelInfoFilter.md) maps each filter's public name to an entry. Every entry carries `hidden`, `label`, `lookup_exprs`, `required`, `type_db`, `type_model`, `type_filter`, `choices`, and `input_type`. Optional keys, such as `suffixes`, appear when they apply.

`lookup_exprs` is a list with one element. A django-filter filter has one lookup expression. A `Meta.fields` entry with several lookups becomes one filter per lookup.

A filter whose form field is disabled is left out. A negated filter (`exclude=True`) is reported like any other filter.

`choices` takes one of three forms:

- A list of `{label, value}` entries for a static choice set. Values are strings, and the blank placeholder option is left out.
- `true`, with `app_label`, `model`, and `filterset_name`, for a filter backed by a related model's queryset. The client loads these choices from the filter-choices endpoint.
- `true`, with the same keys, for a value-derived filter such as [django-filter: `AllValuesFilter`](https://django-filter.readthedocs.io/en/stable/ref/filters.html#django_filters.filters.AllValuesFilter). Its options are the values that the column holds now, so it is reported this way even when the column is empty.

### Public Filter Names

[PublicFilterAliasMixin](/v3/reference/api/py/vueda.core.filters/PublicFilterAliasMixin.md) renames every filter to a public name by turning each `__` into `.`. Both VUEDA filterset bases include it. A `Meta.fields` entry of `{"customer__formatted_name": ["exact", "icontains"]}` produces `customer.formatted_name` and `customer.formatted_name.icontains`. A filter declared under an attribute name with no `__`, such as `customer_name`, keeps that name.

Only the public name is accepted. A request that sends the declared name, such as `customer__formatted_name=`, gets a `400`.

A dotted public name does not say whether its last segment is a lookup or a relation. `customer.formatted_name.icontains` is the `icontains` lookup on `customer.formatted_name`. Clients read the exact names from `model_filtering`.

A multi-widget filter adds its suffix after an underscore. [django-filter: `RangeFilter`](https://django-filter.readthedocs.io/en/stable/ref/filters.html#django_filters.filters.RangeFilter) declared as `distributor__id` accepts `distributor.id_min` and `distributor.id_max`. The client finds the suffix by splitting the key on the last underscore, which works because paths use `.`.

The `vueda_info.E012` system check reports two filters that accept the same query parameter. Both filters below accept `distributor.id_min`:

```python
class ProductFilterSet(VuedaFilterSet):
    distributor__id = filters.RangeFilter(field_name="distributor__id")
    distributor__id_min = filters.NumberFilter(field_name="distributor__id", lookup_expr="gte")
```

The check's hint says to rename or remove one of them. It also covers names generated from `Meta.fields`.

### Filter labels

`model_filtering` reports a label for every filter, and the client shows it in the filter menu and on the filter's chip. The label is decided in this order:

1. **A declared `label`** is used as written: `rest_framework.CharFilter(field_name="customer__formatted_name", label="Customer")` is labelled "Customer".
2. **Otherwise, django-filter builds one from the declared path.** It joins the verbose names of the model fields that the path names, adds the lookup expression in words, and VUEDA title-cases the result. `field_name="customer__user__email", lookup_expr="icontains"` is labelled "Customer User Email Address Contains", because the `email` field's verbose name is "email address".
3. **When django-filter can't follow the path, the filter's own name is used instead.** Django labels a form field that declares no label the same way: underscores become spaces, and VUEDA title-cases the result. A dot in a public filter name reads as a space too. The words django-filter would add stay: "Exclude" in front of a negated filter, and the lookup expression in words after the name. A lookup that the name already ends with is taken off the name first, so it isn't repeated.

The third step applies to a path that names something other than a model field. That covers `formatted_name` on a model that reaches it through `formatted_name_lookup_expression`, and a path that ends in a transform, such as `customer__formatted_name__lower`. django-filter's own label for such a path would be `[invalid name]`.

`model_filtering` reports django-filter's label unless it is `[invalid name]`, and the Django fallback in that case. Customer reaches its `formatted_name` through a lookup expression in these examples, and both columns are title-cased the way `model_filtering` reports them:

| Filter name                         | `field_name`                      | Lookup                      | django-filter               | Django fallback                  |
| ----------------------------------- | --------------------------------- | --------------------------- | --------------------------- | -------------------------------- |
| `customer_email`                    | `customer__user__email`           | `exact`                     | Customer User Email Address | Not used                         |
| `customer_formatted_name`           | `customer__formatted_name`        | `exact`                     | `[Invalid Name]`            | Customer Formatted Name          |
| `customer_name_lower`               | `customer__formatted_name__lower` | `exact`                     | `[Invalid Name]`            | Customer Name Lower              |
| `customer.formatted_name`           | `customer__formatted_name`        | `exact`                     | `[Invalid Name]`            | Customer Formatted Name          |
| `customer_formatted_name_icontains` | `customer__formatted_name`        | `icontains`                 | `[Invalid Name]`            | Customer Formatted Name Contains |
| `customer_name`                     | `customer__formatted_name`        | `icontains`, `exclude=True` | `[Invalid Name]`            | Exclude Customer Name Contains   |

A declared `label` replaces both columns.

The Django fallback applies to `model_filtering`, which the VUEDA client reads. django-filter's own label on the filter is still `[invalid name]`, and DRF's browsable API shows that one. Declare a `label` on such a filter to fix both.

A generated label describes the path, not what the reader is looking for. A filter named `formatted_name` on an invoice list is labelled "Formatted Name", which says nothing about invoices to someone filtering the list. Name a filter after what it means to the reader, such as `customer_name`, or declare a `label`.

## Queryset Ordering

An `order_by()` on the viewset's `queryset` attribute, inside `get_queryset`, or in the model's default manager sorts rows that no declaration describes. `OrderingFilter` applies only the viewset's `ordering` or the request's `o`. With neither, it leaves the queryset's order in place. The rows arrive in the queryset's order, while `default` reports the model's `Meta.ordering` or nothing.

When the viewset declares `ordering`, that ordering replaces the queryset's own on every `list` request. The metadata is then accurate, and the queryset's `order_by()` has no effect.

A model's `Meta.ordering` is applied by Django, and the ordering backend applies nothing when the viewset declares no `ordering`. This matters for nulls placement, described in the next section.

### Three Rules for Queryset Ordering

The `vueda_info.E010` and `vueda_info.E015` system checks hold an ordering in a queryset to three rules. E010 checks each viewset's list queryset. E015 checks each registered model's default manager.

**Rule 1: the metadata must not lie** (E010, per viewset). Each list arrives in one order, and `model_ordering.default` must not claim a different one:

| Viewset `ordering` declared?             | The rows arrive in                                            | `default` reports                                 |
| ---------------------------------------- | ------------------------------------------------------------- | ------------------------------------------------- |
| Yes                                      | The viewset's `ordering`, because DRF applies it              | The viewset's `ordering`, so the two always agree |
| No, and the queryset carries an ordering | The queryset's ordering, from `get_queryset()` or the manager | `Meta.ordering`, or nothing                       |
| No, and the queryset carries none        | `Meta.ordering`                                               | `Meta.ordering`, so the two always agree          |

So the metadata can lie in the middle row, when `Meta.ordering` names a different order. An empty `default` makes no claim, so it can't lie. Fix it by declaring the order as the viewset's `ordering`. For an ordering that names an annotation, removing `Meta.ordering` also works.

The third row has one exception. Django leaves `Meta.ordering` out of a query that has a `GROUP BY`, such as a queryset that annotates an aggregate like `Count("items")`. With no viewset `ordering`, the rows of such a list arrive unordered while `default` reports `Meta.ordering`, so E010 reports it too. Declare the order as the viewset's `ordering`. DRF applies it with `order_by()`, and Django keeps an explicit ordering on a grouped query. Removing `Meta.ordering` also clears the error, because an empty `default` makes no claim.

**Rule 2: declare what can be declared** (E015 for a manager, E010 for `get_queryset()` and the `queryset` attribute). An ordering made only of fields belongs in a declaration: `Meta.ordering` for a manager, and the viewset's `ordering` for `get_queryset()` or the `queryset` attribute. An ordering that names an annotation is exempt, because a declaration can't always hold it. `Meta.ordering` can't name an annotation, and an aggregate such as `Count("items")` has to be annotated before anything can sort by it. So a manager or `get_queryset()` may sort by an annotation, however complex it is. A manager ordering that sorts the same way as `Meta.ordering` passes, because `default` already describes it.

**Rule 3: no dead `order_by()`** (E010). When the viewset declares `ordering`, DRF replaces the queryset's ordering on every list request, so an `order_by()` in `get_queryset()` or on the `queryset` attribute does nothing. Remove it, or make it match. An ordering from the default manager is exempt, because it still sorts every other query of the model.

The rules give these results:

- An annotation ordering with no `Meta.ordering` passes, in a manager or in `get_queryset()`, as long as the viewset declares no `ordering` that it contradicts (rule 3).
- An annotation ordering with a different `Meta.ordering` and no viewset `ordering` breaks rule 1. E010 reports each viewset that starts from it.
- A fields-only ordering in a manager, in `get_queryset()`, or on the `queryset` attribute breaks rule 2, unless it matches the declared default: `Meta.ordering` for a manager, and the default that `model_ordering.default` reports for a viewset. E015 reports a manager once for the model, so E010 doesn't repeat it for each viewset.
- Any ordering in `get_queryset()` that differs from a declared viewset `ordering` breaks rule 3.

### A `.reverse()` in a Sortable List

One case falls outside the three rules, because it breaks the sorts that a client asks for, not only the default. Django keeps a `.reverse()` on the query through a later `order_by()`. DRF's ordering backend calls `order_by()` with the viewset's `ordering` or the request's `o`, so on a reversed list queryset every sort comes back in the opposite order. A client that asks for `name` ascending gets it descending. E010 reports a `.reverse()` in a viewset's list queryset, from `get_queryset()`, the `queryset` attribute, or the default manager, whenever the viewset has an order to get backwards: it declares `ordering`, or a list request can sort by at least one name. Without `ordering_fields`, DRF lets `o` sort by any readable serializer field, so a viewset can't be sorted only when it sets `ordering_fields = []` and no default ordering applies. A `.reverse()` on such a viewset gives a list in one fixed order, and the three rules judge it like any other ordering.

### How the Checks Read an Ordering

E010 builds the viewset without a request and calls `get_queryset()`. When `get_queryset()` raises without a request, the check reads the `queryset` attribute instead. E015 builds a queryset from the default manager. Both read the terms on the query, not the call that put them there. So an `order_by()` in `get_queryset()`, an `order_by()` on the `queryset` attribute, and a custom QuerySet method that calls `order_by()` are all read the same way. On a viewset that can't be sorted, a `.reverse()` is read as part of the ordering: each term is flipped, and a `.reverse()` with no `order_by()` reads as the reversed `Meta.ordering`, because that is the order that Django runs.

A query that Django rejects is reported with Django's own `FieldError` message, because every query of it would raise the same error. That covers a misspelled field, `F()` name, or transform. It also covers an aggregate written straight into `order_by()`, which Django rejects with a message to annotate it first. E015 compiles a manager's query only when the manager orders, because the check is about ordering. E010 doesn't repeat a rejected default manager query for each viewset.

Two orderings agree when they sort by the same terms, in the same order. Direction counts: `ordering = ["queued"]` disagrees with a queryset ordered by `-queued`. Nulls placement counts too. A queryset ordered by `F("due_date").asc(nulls_first=True)` disagrees with `ordering = ["due_date"]`, because PostgreSQL puts nulls last in an ascending sort. The check applies the viewset's `nulls_ordering` to its `ordering` first, the same way a list request does, so `nulls_ordering = {"due_date": "first"}` makes the two agree. A function over a column counts as its own term, so `Lower("name")` disagrees with `"name"`. Two function terms agree when Django compiles them to the same SQL, so `Collate("name", "C")` disagrees with `Collate("name", "de")`, although a message quotes both as `Collate(F(name))`. A `"pk"` alias and the field behind it count as the same term. A `formatted_name` and the column that its `formatted_name_lookup_expression` names also count as the same term.

A message about an ordering, a `.reverse()`, or a grouping names where it was written: the `queryset` attribute, `get_queryset()`, or the default manager. A message about a query that Django rejects while it compiles names the viewset's list queryset as a whole.

### Manager Ordering

A model's default manager sorts every query that names no other manager, such as the admin, related lookups, and the expanded children that a `VuedaViewSet` loads. That makes its ordering a model-wide default, and `Meta.ordering` is where Django, DRF, and `model_ordering.default` read the model-wide default. The default manager is the one that `Meta.default_manager_name` names. Without that setting, it is the first manager declared on the model or its bases.

A manager other than the default orders only the code that names it, such as `Order.unfulfilled.all()`. E015 does not check it. A viewset whose queryset starts from that manager carries its ordering, and E010 applies the three rules to it like any other.

A `Meta.ordering` that differs from a default manager's annotation ordering is still correct for the model. It applies wherever code goes around the default manager, such as a second manager or the base manager that Django uses internally. Rule 1 judges only the viewsets that report it as their default.

### What the Checks Cannot See

E010 and E015 do not see these cases:

- An ordering that varies by request. When `get_queryset()` needs the request, E010 reads only the `queryset` attribute.
- An ordering that depends on `self.action`, such as an `order_by()` that `get_queryset()` applies only when `self.action == "list"`. E010 calls `get_queryset()` with no action set, so it never sees that ordering. Setting the action first does not help, because `VuedaViewSet.get_queryset` then builds the list serializer, and that serializer needs the request.
- A viewset whose `filter_backends` leaves out the ordering backend. Nothing applies its `ordering` or a client's `o`, so E010 applies none of the ordering rules to it. It still reports a query that Django rejects.
- A bare `order_by()`, which clears the model's default ordering. [`queryset_explicit_ordering`](/v3/reference/api/py/vueda.core.ordering.md#queryset_explicit_ordering) describes this gap.
- An ordering that names no single column, such as `"?"` or `Concat("first_name", "last_name")`. The checks have nothing to compare it with.

The checks show only that the ordering that a viewset's queryset carries, when it is built without a request, follows the three rules. They do not show the order that any request returns.

## Nulls Placement for Client-Requested Ordering

Stock DRF passes an explicit `o` to the database as a plain field name. The database's default nulls placement then applies, even when the default ordering placed nulls first.

[`nulls_ordering`](/v3/reference/api/py/vueda.core.viewsets/VuedaViewSet.md#nulls_ordering) maps a field to `"first"` or `"last"`. `VuedaOrderingFilter` applies that placement in both directions. A field also listed in [`nulls_ordering_flip`](/v3/reference/api/py/vueda.core.viewsets/VuedaViewSet.md#nulls_ordering_flip) swaps the placement when sorted descending.

The placement applies wherever the field is sorted by name: an explicit `o`, and a viewset `ordering` written as strings. A term written as an expression, such as `F("due_date").asc(nulls_first=True)`, keeps its own placement. A model's `Meta.ordering` gets no placement when the viewset declares no `ordering`, because the backend applies nothing then.

A `nulls_ordering` key and a `nulls_ordering_flip` entry are `__`-joined paths, as in `ordering`, even though `o` is dotted. The backend translates `o=customer.user.email` to `customer__user__email` before it looks up a placement. So `{"customer__user__email": "first"}` applies, and `{"customer.user.email": "first"}` matches nothing.

A key must also name a field that a list request can sort by. That is a field that `ordering_fields` offers or that the default ordering names. A placement on any other field is never applied.

A key that ends in the `"pk"` alias and a key that names the field behind it are the same key. `{"customer__pk": "first"}` also applies to `o=customer.id`, which is the spelling that `model_ordering` advertises. This holds for a primary key of one column.

A composite primary key needs a placement on each of its columns. `model_ordering` advertises the key one column at a time, so a client sorts by one column, and a key on the `pk` applies only when the list is sorted by the whole key. Declare the placement on each column as well. Django allows a relation to reach a composite primary key model only from the reverse side. For example, an order list whose `order_items` reverse relation leads to a model with `pk = models.CompositePrimaryKey("order_id", "product_id")` declares `{"order_items__pk": "first", "order_items__order": "first", "order_items__product": "first"}`. An order with no items has nulls in those columns, so the placement decides where it sorts.

A value other than `"first"` or `"last"` is ignored at request time, and the rows use the database's default placement. The `vueda_info.E007` system check reports such a value. It also reports a `nulls_ordering` that is not a dict, and a `nulls_ordering_flip` entry that has no placement to flip. It reports a key or entry that names no field a list request can sort by, and its hint gives the `__`-joined spelling of a dotted key. A key that resolves as written but contains a `.` or a `,`, such as a `JSONField` key `specs__v1.2`, has no `o` name, so the hint says to annotate the value under a plain name instead. It reports a composite `pk` key when the same attribute leaves out any of the key's columns. It reports every problem in both attributes in one run.

To read the names a list request can sort by, the check builds the viewset without a request. When the viewset's `get_queryset`, or the serializer that DRF reads default ordering fields from, needs the request, the check skips the keys that no request can sort by.

`model_ordering` does not report nulls placement.

## Transforms in an Ordering Path

A declared ordering path may end in a transform. A transform is a lookup that turns a field's value into another value, such as `__year` on a date or `__len` on an array. Django accepts these paths in `order_by()` and in `Meta.ordering`, and VUEDA accepts them in a viewset's `ordering`, `ordering_fields`, and `nulls_ordering`.

```python
class InvoiceViewSet(VuedaViewSet):
    queryset = Invoice.objects.all()
    serializer_class = InvoiceSerializer

    ordering = ["created__year", "-created__month"]
    ordering_fields = ["created__year", "created__date__year", "details__priority"]
    # model_ordering.default == ["created.year", "created.month"]
    # model_ordering.fields includes {"name": "created.year", "type": "numeric", "ascending": true}
    # `?o=created.year` and `?o=-details.priority` are accepted.
```

VUEDA looks each transform up in Django's own lookup registry, the same way a query does. So any transform that Django resolves for the field works:

- a built-in transform, such as `year`, `month`, or `date`;
- a chain of transforms, such as `created__date__year`;
- a transform that a project registers on a field class itself;
- a key on a `JSONField`, such as `details__priority`.

A misspelled transform, such as `created__yaer`, names nothing. `vueda_info.E006` reports it the same way it reports a misspelled field.

A key on a `JSONField` can contain any character, including a `.` or a `,`. That is a limitation, described under [Names that contain a dot or a comma](/v3/core-concepts/filtering-and-ordering-semantics.md#names-that-contain-a-dot-or-a-comma).

`model_ordering` reports the path under its dotted name, which is the name that a client sends back in `?o=`. The `type` comes from the transform's output, not from the column. `created.year` is `numeric`, because the database sorts the year as a number. A key on a `JSONField` is `alpha`, the same as the `JSONField` itself.

This differs from a database function. `ExtractYear("created")` sorts by the same value as `created__year`, but it is an expression around the column. So it is reported under the column's own name, `created`, and typed from the column (see [Database Functions in a Default Ordering](/v3/core-concepts/filtering-and-ordering-semantics.md#database-functions-in-a-default-ordering)). Write the path form when a client should be able to name the transformed value in `?o=`.

A path does not have to start with a model field. Two other starting points accept transforms too:

- **A queryset annotation.** `ordering_fields = ["launched__year"]` works when `get_queryset` adds a `launched` annotation. Django applies the transform to the annotation, and `model_ordering.fields` reports `launched.year`, typed from the transform's output. The annotation may come from `.annotate()` or `.alias()`. A plain string in Django's `order_by()` can't apply a transform to an `.alias()` name, so `VuedaOrderingFilter` orders by such a path through an `F()` expression, which can. A default ordering that names such a path is reported in `model_ordering.default` as well (see [Queryset annotations](/v3/core-concepts/filtering-and-ordering-semantics.md#queryset-annotations)).
- **`formatted_name`.** `formatted_name__unaccent` and `customer__formatted_name__unaccent` both work, including on a model that reaches `formatted_name` through `formatted_name_lookup_expression`. `VuedaOrderingFilter` rewrites the part up to `formatted_name` to the lookup expression behind it and keeps the transforms, so the database applies them to the column that the lookup expression lands on. The model's own `formatted_name` is left on its annotation, and Django applies the transforms there.

Some transforms are not built in. `Lower` and `Ceil`, for example, are database functions. A path such as `name__lower` works only after a project registers the function as a transform, with `CharField.register_lookup(Lower)`. Until then, `vueda_info.E006` reports the path as naming nothing, because Django would refuse it too.

A `column_totals` path may end in a transform as well. See [Total Over a Transform](/v3/guides/list-column-totals.md#total-over-a-transform).

### Names that contain a dot or a comma

A client can't sort by a name that contains a `.` or a `,`. This is a known limitation of the `?o=` parameter, and no setting lifts it.

`?o=` takes dotted names, and the client sends every ordering term that way. VUEDA turns each `.` in a term back into `__` to find the ORM path. So a `.` that belongs to the name itself is read as a separator. DRF splits a `?o=` value on commas, so a `,` splits the name into two terms.

Django model fields can't contain either character. Two kinds of name can:

- **A key on a `JSONField`.** `ordering_fields = ["specs__v1.2"]` names the key `v1.2`. Its dotted name, `specs.v1.2`, reads back as `specs__v1__2`, a different key.
- **An annotation name built from a dictionary.** `.annotate(**{"v1.2": F("name")})` is valid Django. Its dotted name, `v1.2`, reads back as `v1__2`.

The query itself sorts by such a name without trouble. A viewset's `ordering` that names one still orders every list. What fails is naming it from a client:

- `vueda_info.E006` reports the declaration, so `manage.py check` fails until it changes.
- `model_ordering` leaves the name out of `fields`. A default ordering that names one is not reported in `default` either.
- `?o=` rejects the dotted name with a `400`, rather than sorting by the different path that it reads back as.

To make such a value sortable, annotate it under a name without a dot or a comma in the viewset's own `get_queryset`, and order by that name:

```python
from django.db.models.fields.json import KeyTextTransform


class ReleaseViewSet(VuedaViewSet):
    ordering_fields = ["spec_v1_2"]

    def get_queryset(self):
        return super().get_queryset().annotate(spec_v1_2=KeyTextTransform("v1.2", "specs"))
```

## Query Namespace and Validation Boundary

[NoExtraFieldsForViewSetMixin](/v3/reference/api/py/vueda.core.viewsets/NoExtraFieldsForViewSetMixin.md) rejects any query parameter that the endpoint does not recognize. This is [Query Parameter Validation](/v3/reference/glossary.md#query-parameter-validation).

A `list` request accepts:

- Each filter's public name, and for a multi-widget filter, each suffixed name such as `distributor.id_min`.
- `p`, `ps`, `e`, `f`, `om`, `s`, and `o`.
- `ct`, the column totals parameter, when the viewset includes [ListRowLevelViewSetMixin](/v3/reference/api/py/vueda.core.viewsets/ListRowLevelViewSetMixin.md).

A viewset with no `filterset_class` accepts only the second and third groups. A `retrieve` request accepts only `e`, `f`, and `om`.

An unrecognized key gets a `400` with one entry per unknown key. The message starts with `Invalid query parameter.` and then names the accepted filters. When the viewset has no filterset, it names the accepted parameters. The client's list adapters classify this response as a [ListFilterError](/v3/reference/api/js/utils/errors/members/ListFilterError.md), as [CRUD Adapter Layer](/v3/core-concepts/crud-adapter-layer.md) describes. [Field and Expand Semantics](/v3/core-concepts/field-and-expand-semantics.md) describes how `f` and `e` values are checked.

DRF ignores unknown query parameters by default. [DRF Ecosystem Compatibility Boundaries](/v3/core-concepts/drf-ecosystem-deviations.md) describes this departure.

[`remove_invalid_fields`](/v3/reference/api/py/vueda.core.filters/VuedaOrderingFilter.md#remove_invalid_fields) checks every comma-separated `o` term before applying any. If one term is invalid, the request gets a `400` keyed `o`. The message names every invalid term and lists the valid ones. Empty terms, as in `o=name,`, are dropped.

A term is valid when it is the public name of a field in [`get_valid_fields`](/v3/reference/api/py/vueda.core.filters/VuedaOrderingFilter.md#get_valid_fields). That set holds every declared `ordering_fields` name, each field that the default ordering reads, and the fields behind `"pk"`.

## Search Contract Surface

[VuedaSearchFilterBackend](/v3/reference/api/py/vueda.core.filters/VuedaSearchFilterBackend.md) handles `s`. It extends [DRF: `SearchFilter`](https://www.django-rest-framework.org/api-guide/filtering/#searchfilter) with three `search_fields` prefixes:

- `#` for trigram similarity.
- `~` for trigram word similarity.
- `V:` for ranked search.

DRF's own prefixes, `^`, `=`, `@`, and `$`, still work. With no `#`, `~`, or `V:` field, the backend behaves as `SearchFilter`. The exception is a field that reaches through a many-valued relation, which [Search Through a Many-Valued Relation](/v3/core-concepts/filtering-and-ordering-semantics.md#search-through-a-many-valued-relation) describes.

Plain, `#`, and `~` fields filter rows, and each group must match. `#` and `~` fields join the search terms into one term. `V:` fields rank the rows that remain.

A search is ranked when it has a `V:` field, or a plain field alongside `#` or `~` fields. The rank, `combined_rank`, adds full-text rank, trigram similarity, a whole-word match score, and 10 per plain match. Rows below [`search_threshold`](/v3/reference/api/py/vueda.core.filters/VuedaSearchFilterBackend.md#search_threshold) (0.2) are dropped. A search that uses only `#` or `~` fields is filtered and not ranked.

A ranked search is sorted by `combined_rank`, best first, unless the request sends `o`. Any nonempty `o` replaces the rank order. An `o` with an invalid term gets the `400` described above.

### Search Through a Many-Valued Relation

A search field can reach through a reverse foreign key or a many-to-many, such as `V:cart_items__product_option__name` on a `Cart`. A join on that relation returns one row per matching related row. The backend keeps those joins inside an `Exists` subquery and narrows the viewset's queryset to the objects that match. The list returns each object once, whatever the prefix.

In a ranked search, an object matches when at least one of its related rows reaches `search_threshold`. The object ranks by its best matching row, and the primary key breaks ties. When the request sends `o`, the list keeps the order that it has without a search, and the primary key breaks ties.

The subquery reads the viewset's search queryset, which [`get_search_queryset`](/v3/reference/api/py/vueda.core.filters/VuedaSearchFilterBackend.md#get_search_queryset) returns. It is the result of the viewset's own `get_search_queryset()` when the viewset defines one. Otherwise, it is the model's default manager.

The viewset's queryset applies its ordering, annotations, aggregates, and filters outside the subquery, so they see every related row. A viewset that lists only carts with at least three items keeps such a cart when two of its items match the search:

```python
class CartViewSet(VuedaViewSet):
    search_fields = ["V:cart_items__product_option__name"]

    def get_queryset(self):
        return super().get_queryset().annotate(item_count=Count("cart_items")).filter(item_count__gte=3)
```

A filter through the searched relation and the search are separate conditions, as two `filter()` calls on a many-valued relation are in Django. Take a viewset queryset filtered by `cart_items__quantity__gte=24`. It keeps a cart with one item of quantity 24 and another item that matches the search.

A search whose fields follow only foreign keys joins at most one row per object. So does a search whose fields are all annotations of the viewset's queryset. Such a search matches against the viewset's queryset directly and never reads the search queryset.

#### When a Viewset Needs `get_search_queryset()`

A viewset defines `get_search_queryset()` in two cases. In the first, its `search_fields` name an annotation that only its `get_queryset()` adds, alongside a field through a many-valued relation. The default manager lacks the annotation, so every search request fails with `FieldError: Cannot resolve keyword 'customer_name'`, a server error:

```python
class CartViewSet(VuedaViewSet):
    search_fields = ["V:customer_name", "V:cart_items__product_option__name"]

    def get_queryset(self):
        return super().get_queryset().annotate(customer_name=F("customer__user__name"))

    def get_search_queryset(self):
        return Cart.objects.annotate(customer_name=F("customer__user__name"))
```

An annotation that the default manager already adds needs no `get_search_queryset()`. One example is the `formatted_name` that a [FormattedNameManager](/v3/reference/api/py/vueda.core.models/FormattedNameManager.md) adds.

In the second case, the model's default manager hides rows that the viewset lists. In this example, the default manager leaves out archived widgets, and the viewset lists them through a second manager:

```python
class WidgetManager(FormattedNameManager):
    def get_queryset(self):
        return super().get_queryset().filter(archived=False)


class Widget(VuedaModel):
    objects = WidgetManager()
    all_objects = FormattedNameManager()


class WidgetViewSet(VuedaViewSet):
    queryset = Widget.all_objects.all()
    search_fields = ["V:parts__name"]

    def get_search_queryset(self):
        return Widget.all_objects.all()
```

Without `get_search_queryset()`, the subquery reads `Widget.objects`, so every search leaves out the archived widgets. The request returns a `200`, and no system check reports it.

A search queryset follows four rules:

- It carries every annotation that `search_fields` name.
- It includes every row that the viewset lists.
- It applies no filters. Inside the subquery, a filter on an aggregate or a window function sees only the matching related rows. A filter through the searched relation limits a ranked search to the related rows that pass it.
- It is a new queryset. Returning `self.get_queryset()` brings the viewset's filters into the subquery.

The `vueda_info.E014` system check reads the search queryset of each registered viewset whose search reaches through a many-valued relation. It reports a `search_fields` entry that names neither a model field nor an annotation of the search queryset. It also reports a `get_search_queryset()` that filters on an aggregate or a window function. It skips a `get_search_queryset()` that cannot run outside a request, for example because it reads `self.request`.

## Filter Choices and Permission Surfaces

Choices for a queryset-backed or value-derived filter change with the data, so model info does not include them. The client loads them from [List filterset field choices](/v3/reference/api/rest/vueda.info/vueda.info_model_info_filter_choices_list.md). It requests a page size of 200 and loads only the first page ([#381](https://github.com/arrai-innovations/vueda/issues/381)). [`ModelInfoFilterSetChoicesViewSet`](/v3/reference/api/py/vueda.info.viewsets/ModelInfoFilterSetChoicesViewSet.md) describes that endpoint.

The endpoint returns:

- `404` for a filter name that the filterset does not declare. The message lists the valid names.
- `404` for a model with no viewset, or a viewset with no filterset.
- `403` when the user lacks the mapped `read` permission on the model. A queryset-backed filter also needs the mapped `list` permission on the related model. Without it, the list works and that filter's dropdown stays empty.
- `500` with `FieldError` when the related model has no label path. That model has no `formatted_name` column, no `formatted_name_lookup_expression`, and no `get_formatted_name()`.

The `500` case gets no startup report for a model outside `FormattedNameBaseModel`, such as the user model. The `vueda_info.E001` system check covers only a `FormattedNameBaseModel` that sets `formatted_name = None` with neither alternative.

Choices come only from the rows that the registered viewset's `list` view reads. The endpoint starts from the viewset's `get_queryset()`, called as a `list` request with empty URL keyword arguments. [`filter_rows_for_user`](/v3/reference/api/py/vueda.core.permissions.md#filter_rows_for_user) then keeps the rows that pass the same row-level and workflow state rules that the `list` view applies. The request's other filters then narrow those rows. A value-derived filter offers only the values that those rows hold. A queryset-backed filter offers only related rows that the user may also list, by the same rules on the related model. A related row that the user may not list is left out, even when a visible row references it. Static choices read no rows, so these rules do not change them.

The endpoint does not call the viewset's `get_queryset`. A dropdown can therefore offer a value that appears only on rows that `get_queryset` excludes from the list ([#459](https://github.com/arrai-innovations/vueda/issues/459)). [Row-Level Permission Filtering](/v3/core-concepts/row-level-permission-filtering.md) describes the row-level rules.

## Client Normalization and Cache Semantics

[storeModelInfo](/v3/reference/api/js/stores/storeModelInfo/functions/storeModelInfo.md) fetches model info once per `app.model` and camelCases its keys. [storeModelConfig](/v3/reference/api/js/stores/storeModelConfig/functions/storeModelConfig.md) derives from it:

- [`sortables`](/v3/reference/api/js/stores/storeModelConfig/types/ModelConfig.md#sortables), the names in `ordering.fields`.
- [`sorted`](/v3/reference/api/js/stores/storeModelConfig/types/ModelConfig.md#sorted), the `ordering.default` names with their `ascending` directions.
- [`filterables`](/v3/reference/api/js/stores/storeModelConfig/types/ModelConfig.md#filterables) and [`filterableDetails`](/v3/reference/api/js/stores/storeModelConfig/types/ModelConfig.md#filterableDetails), from `filtering`.

[useFilterables](/v3/reference/api/js/use/useFilterables/functions/useFilterables.md) merges the config's filterables with a view's overrides. [use/useViewList](/v3/reference/api/js/use/useViewList.md) resolves that list once for [ViewList](/v3/reference/api/vue/ViewList.md) and passes it to [FilterGroup](/v3/reference/api/vue/FilterGroup.md). [useFilter](/v3/reference/api/js/use/useFilter/functions/useFilter.md) picks each filter's component and widget. [use/useFilterForm](/v3/reference/api/js/use/useFilterForm.md) converts a filter value to and from its URL form, using the filter's one lookup expression. `useViewList` writes the reader's sort to `o`. [storeModelChoices](/v3/reference/api/js/stores/storeModelChoices/functions/storeModelChoices.md) and [useModelChoices](/v3/reference/api/js/use/useModelChoices/functions/useModelChoices.md) load filter choices.

The add-filter menu, [FilterMenu](/v3/reference/api/vue/FilterMenu.md), offers only filters that the client can render an input for. `useViewList`'s [`validFilterables`](/v3/reference/api/js/use/useViewList/types/ViewListFilterGroup.md) keeps a visible filter when its type has value handling and components to mount. The components come from the filter field mapping, or from the view config's `fieldComponents` and `widgetComponents`. A range filter needs both boundary components. A widget that resolves to [WidgetUnmapped](/v3/reference/api/vue/WidgetUnmapped.md) does not count, because it shows a diagnostic. A filter that fails this check is left out of the menu, and its URL value is not restored. A console warning names the app, model, and filter once per list visit.

[mergeFilterFieldMapping](/v3/reference/api/js/utils/fieldMappings/functions/mergeFilterFieldMapping.md) registers a custom type's components and value handling together. [Filter Input Types](/v3/core-concepts/filtering-and-ordering-semantics.md#filter-input-types) lists the built-in types.

A filter marked `hidden`, such as the `id` filter that [IdInFilterSet](/v3/reference/api/py/vueda.core.filters/IdInFilterSet.md) declares, has no input, whatever its type. Its type needs no mapping. Its URL value still reaches the `list` request and shows as a [List Scope](/v3/reference/glossary.md#list-scope), a chip with a clear control. [Open a Scoped List](/v3/guides/scope-a-list.md) describes scopes, the `scopes` prop, and clearing.

`storeModelInfo` caches a failed fetch. Later requests for that model reject with the cached error, so its filter and sort controls stay unavailable until the cache clears. [Reactive Data Flow](/v3/core-concepts/reactive-data-flow.md) describes when that happens.

## Filter Input Types

Each `model_filtering` entry reports a `type_filter`, which the client reads as `typeFilter`. For most filters, it names the Django form field class that the filter builds. `AllValuesFilter` and [django-filter: `AllValuesMultipleFilter`](https://django-filter.readthedocs.io/en/stable/ref/filters.html#django_filters.filters.AllValuesMultipleFilter) report `AllValuesChoiceField` and `AllValuesMultipleChoiceField`. Their choices are the values that a column holds, and the client loads them from the filter-choices endpoint.

The client picks a filter's input and value shape from its type. Two tables in [utils/fieldMappings](/v3/reference/api/js/utils/fieldMappings.md) describe each type:

- [`FilterFieldMappings`](/v3/reference/api/js/utils/fieldMappings.md#FilterFieldMappings) holds value handling. `initialValue` is the empty value that the filter form starts from. `array` marks a list value. `repeatedKey` marks a list that the `list` request sends as one key per value. `range` marks a range.
- [`filterFieldMapping`](/v3/reference/api/js/utils/fieldMappings.md#filterFieldMapping) holds the components: the field component and widget. For a range, it holds the field set and each boundary's component and widget.

### Built-in Types

| `typeFilter`                                                                   | Input                                                       | URL value                            |
| ------------------------------------------------------------------------------ | ----------------------------------------------------------- | ------------------------------------ |
| `CharField`                                                                    | `WidgetTextInput`                                           | The text                             |
| `BooleanField`                                                                 | `WidgetToggle`                                              | `true` or `false`                    |
| `NullBooleanField`                                                             | `WidgetSelectDropdown` with the metadata's choices          | The chosen value                     |
| `ChoiceField`, `TypedChoiceField`                                              | `WidgetSelectDropdown` with the metadata's choices          | The chosen value                     |
| `ModelChoiceField`                                                             | `WidgetModel`, single select                                | The chosen primary key               |
| `MultipleChoiceField`                                                          | `WidgetCombobox`, multi-select, with the metadata's choices | One value per choice                 |
| `ModelChoiceInField`, `ModelMultipleChoiceInField`, `ModelMultipleChoiceField` | `WidgetModel`, multi-select                                 | One primary key per choice           |
| `AllValuesChoiceField`                                                         | `WidgetModel`, single select                                | The chosen stored value              |
| `AllValuesMultipleChoiceField`                                                 | `WidgetModel`, multi-select                                 | One stored value per choice          |
| `DateField`                                                                    | `WidgetDateField`                                           | `YYYY-MM-DD`                         |
| `DateTimeField`, `IsoDateTimeField`                                            | `WidgetDateField` with minute granularity                   | `YYYY-MM-DDTHH:MM:SS`                |
| `TimeField`                                                                    | `WidgetTimeField`                                           | `HH:MM:SS`                           |
| `DurationField`                                                                | `WidgetDuration` with days, hours, and minutes              | `[D ]HH:MM:SS`, such as `1 02:30:00` |
| `DecimalField`, `PositiveDecimalField`, `FloatField`                           | `WidgetNumberInput`                                         | The number                           |
| `DecimalInField`                                                               | `WidgetTagsInput`, numbers only                             | One number per entry                 |
| `RangeField`                                                                   | Two `WidgetNumberInput` boundaries                          | One key per suffix                   |
| `DateRangeField`                                                               | Two `WidgetDateField` boundaries                            | One key per suffix                   |
| `DateTimeRangeField`                                                           | Two `WidgetDateField` boundaries with minute granularity    | One key per suffix                   |

In the page URL, a list value repeats its key once per value, such as `?condition=new&condition=used`. A range writes one `<filter>_<suffix>` key per boundary, with the two suffixes from its metadata.

The `list` request sends a list value in the form that the filter's Django widget reads:

- `MultipleChoiceField`, `ModelMultipleChoiceField`, and `AllValuesMultipleChoiceField` set `repeatedKey`. The request repeats the key once per value, such as `?condition=new&condition=used`. Their `SelectMultiple` widget reads each key as one value, so a stored value with a comma, such as `Acme, Inc.`, arrives intact.
- `ModelChoiceInField`, `ModelMultipleChoiceInField`, and `DecimalInField` send one comma-separated value, such as `?price.in=1,2.5`. Their django-filter CSV widget reads one key and splits its value.

`ChoiceField`, `TypedChoiceField`, and `MultipleChoiceField` filters show the choices that their metadata lists. The `ModelChoice*` and `AllValues*` types report `choices: true`. [WidgetModel](/v3/reference/api/vue/WidgetModel.md) loads their choices from the filter-choices endpoint when the input gets focus or already holds a value.

The add-filter menu leaves out a visible filter whose type is missing from either table, such as a `UUIDField` filter. [Client Normalization and Cache Semantics](/v3/core-concepts/filtering-and-ordering-semantics.md#client-normalization-and-cache-semantics) describes that check.

### Custom Filter Types

`mergeFilterFieldMapping` registers a custom type or changes a built-in one. An entry's `initialValue`, `array`, `repeatedKey`, and `range` keys go to `FilterFieldMappings`. Its other keys go to `filterFieldMapping`.

```js
import WidgetColor from "./WidgetColor.vue";
import { mergeFilterFieldMapping } from "@vueda/utils/fieldMappings.js";

mergeFilterFieldMapping({
    ColorField: {
        component: "FormField",
        fieldProps: { hidden: true },
        widget: WidgetColor,
        initialValue: null,
    },
});
```

A type needs entries in both tables. Without value handling, the filter form cannot build an empty or URL-restored value. Without components, it has nothing to render.

- The widget's value is what the request parameter carries: a string, or an array of strings for a type with `array`.
- A list type sets `array: true` and `initialValue: []`. A single URL value then restores as a list with one entry. A list type whose Django widget reads repeated keys, such as `SelectMultiple`, also sets `repeatedKey: true`. Without it, the `list` request sends the values as one comma-separated value.
- A range type sets `range: true` and an `initialValue` object. It provides `component: "FieldSetRange"`, `boundaryComponent`, and `boundaryWidget`. The filter's metadata supplies the two suffixes.
- `fieldProps: { hidden: true }` makes `FormField` render the widget without its own label row, because the filter form already shows the filter's label. It does not hide the filter.

## Observable Failure Modes

**A field sorts under its source name.** With no `ordering_fields`, a serializer field that renames `Product.name` to `title` sorts as `name`. A request for `o=title` gets a `400`, as does `o` on a property-backed field. Both columns look sortable in the UI, and sorting one returns the `400`.

**The sort indicator disagrees with the rows.** A queryset `order_by()` sorts the rows while `default` reports another order, or none. The response is a `200`. The client's sort indicator is wrong, and its reset restores an order the server never applied. `vueda_info.E010` reports it, whether the `order_by()` is on the `queryset` attribute or in `get_queryset()`. An ordering that depends on the request goes unreported.

**A sorted list shows no sort.** A default ordering on a multi-column function or a column-less term sorts the rows without a `default` entry. No check reports it, and the client shows no sort indicator.

**A search leaves out rows that the default manager hides.** A viewset lists rows that its model's default manager filters out, such as archived rows listed through a second manager. Every search through a many-valued relation leaves those rows out, with a `200`. No check reports it. [When a Viewset Needs `get_search_queryset()`](/v3/core-concepts/filtering-and-ordering-semantics.md#when-a-viewset-needs-get-search-queryset) gives the fix.

**A search on an annotation raises `FieldError`.** A viewset's `search_fields` name an annotation that only its `get_queryset()` adds, alongside a field through a many-valued relation. Every search request fails with a server error. `vueda_info.E014` reports the viewset at startup.

**A filter dropdown stays empty.** The filter-choices endpoint answers `403` or `500` while the rest of the list works. The request that fails is the choices request, and the `list` request succeeds.
