---
url: "https://vueda.dev/v3/guides/list-column-totals.md"
title: "Expose Aggregates in `list` Responses"
---

# Expose Aggregates in `list` Responses

This guide adds [Column Totals](/v3/reference/glossary.md#column-totals) to a model's `list` endpoint and shows them in the footer row of `ViewList`'s table. You declare the totals on the viewset. The client discovers them from [Model Info](/v3/reference/glossary.md#model-info) and requests the ones its visible columns can show.

## Before You Begin

- The viewset includes [ListRowLevelViewSetMixin](/v3/reference/api/py/vueda.core.viewsets/ListRowLevelViewSetMixin.md). [VuedaViewSet](/v3/reference/api/py/vueda.core.viewsets/VuedaViewSet.md) and [VuedaReadOnlyViewSet](/v3/reference/api/py/vueda.core.viewsets/VuedaReadOnlyViewSet.md) both include it.
- The `list` endpoint paginates with [VUEDAPageNumberPagination](/v3/reference/api/py/vueda.core.pagination/VUEDAPageNumberPagination.md), or a subclass that keeps its `get_paginated_response`. That method adds the `columnTotals` key, and another pagination class drops the totals. A viewset with `pagination_class = None` returns a bare array and computes no totals.
- The viewset is served at its [Model API Path](/v3/reference/glossary.md#model-api-path). The examples use an `Invoice` model in a `billing` app, served at `/routes/billing/invoice/`.

## Declare the Totals

Set [`column_totals`](/v3/reference/api/py/vueda.core.viewsets/ListRowLevelViewSetMixin.md#column_totals) to a mapping of total name to the ORM path it sums:

```python
class InvoiceViewSet(VuedaViewSet):
    queryset = Invoice.objects.all()
    serializer_class = InvoiceSerializer
    column_totals = {
        "subtotal": "subtotal",
        "tax": "tax",
        "product_price": "product_option__price",
    }
```

Name each total after the display column that renders it. The name is what a client requests, and it is the key that the response returns the total under; the path stays on the server. A serializer field `price` with `source="product_option.price"` renders in a column named `price`, so its total is `{"price": "product_option__price"}`.

Each path must follow two rules:

- It ends on a column that the database can `SUM`: a numeric field or a `DurationField`.
- It reaches only through forward foreign keys and one-to-one relations. A reverse foreign key, a many-to-many, or a `GenericRelation` adds a row per related object, which inflates every total in the same query.

The `ListRowLevelViewSetMixin` reference explains both rules. A total is a plain `SUM` of the stored values, so it adds values in different units or currencies as they are. [#336](https://github.com/arrai-innovations/vueda/issues/336) tracks refusing mixed-currency aggregates.

### Total a Queryset Annotation

A path can also name an annotation that the viewset's own `get_queryset` adds:

```python
class InvoiceLineViewSet(VuedaViewSet):
    queryset = InvoiceLine.objects.all()
    serializer_class = InvoiceLineSerializer
    column_totals = {"line_total": "line_total"}

    def get_queryset(self):
        return super().get_queryset().annotate(line_total=F("quantity") * F("unit_price"))
```

The system check accepts the name without applying the two path rules, so the annotation must have one value per row. An annotation that reads the multi-valued side of a join has a value per related row, and its total is wrong. [`get_column_info`](/v3/reference/api/py/vueda.core.viewsets/ListRowLevelViewSetMixin.md#get_column_info) describes how each kind of total counts a matched row once.

When the value could be a column, store it in a [Django: GeneratedField](https://docs.djangoproject.com/en/6.1/ref/models/fields/#django.db.models.GeneratedField) or a database view instead, so the check validates the path. [Declare List Ordering](/v3/guides/declare-list-ordering.md#make-a-function-default-visible) shows both, and [Queryset Annotations](/v3/core-concepts/filtering-and-ordering-semantics.md#queryset-annotations) gives the same advice for ordering.

### Total Over a Transform

A path can end in a transform. A transform is a lookup that turns each row's value into another value before the sum, such as `__len` on an array. Most numeric transforms are database functions that a project registers itself. To bill per started hour, round each entry up to a whole hour before adding them:

```python
from django.db.models import DecimalField
from django.db.models.functions import Ceil

DecimalField.register_lookup(Ceil)


class TimesheetEntryViewSet(VuedaViewSet):
    queryset = TimesheetEntry.objects.all()
    serializer_class = TimesheetEntrySerializer
    column_totals = {"billable_hours": "hours__ceil"}
```

Entries of 1.25 and 0.5 hours total 3 billable hours. Summing first and rounding after would give 2.

Register the transform once at startup, such as in an app's `AppConfig.ready()`. A transform that isn't registered names nothing, so the check reports the path, and Django would refuse it too.

The first path rule applies to the transform's output rather than to the column. `hours__ceil` passes because `Ceil` returns a decimal. A key on a `JSONField` fails, because its output is still JSON. The check judges only the type, so a path such as `created__year` passes because a year is an integer. Whether the sum means something is yours to decide.

A transform can also follow an annotation that `get_queryset` adds with `.annotate()` or `.alias()`, such as `launched__year` on a `launched` annotation. VUEDA works out the transformed value once per matched row and sums those values, so a row that a join matched more than once still counts once. The check judges the transform's output type, and names a transform that it can't apply, such as a misspelled `launched__yaer`.

## Run the System Check

Run `python manage.py check`. The `vueda_info.E013` error names the viewset, the total, and the problem. It reports:

- a `column_totals` that is not a mapping;
- a name that is empty, contains a comma, is a wildcard value (`*` or `~all`), or is one that Django refuses as a column alias;
- a path that names no model field and no annotation of the viewset's queryset;
- a path that applies something to such an annotation that is not a transform Django can apply, or that applies a transform to an annotation whose own type Django can't determine;
- a path through a relation that can match more than one row;
- a path that ends on a relation, or on a column or transform output that `SUM` cannot add.

When `get_queryset` reads `self.request`, the check cannot build the queryset. It then checks the names only and skips the path rules.

The `vueda_info.W002` warning reports a name that contains a percent sign. Django accepts one in a column alias today and refuses it from Django 7.0, so rename the total before you upgrade.

[info.checks.check\_column\_totals\_configuration](/v3/reference/api/py/vueda.info.checks.md#check_column_totals_configuration) checks registered viewsets only. A viewset routed without [Canonical Registration](/v3/reference/glossary.md#canonical-registration) is not checked, and a path through a multi-valued relation on it inflates totals without any error.

::: warning
Django does not run system checks when a WSGI application starts. Run `manage.py check` in your build or deploy pipeline.
:::

## Request Totals

A `list` request names the totals it wants in the `ct` query parameter:

```text
GET /routes/billing/invoice/?ct=subtotal,product_price
```

- Repeating the parameter (`?ct=subtotal&ct=product_price`) means the same as a comma-separated value.
- A wildcard value, `*` or `~all`, requests every declared total.
- Empty values and duplicates are dropped, so `?ct=` alone requests nothing.
- A name that the viewset does not declare returns a `400` that lists the valid totals, even when a wildcard is also sent.
- `retrieve` rejects `ct`, and a total name is never a valid [Sparse Fields](/v3/reference/glossary.md#sparse-fields) `f` value.

A request that names no totals gets `columnTotals: {}` and runs no aggregation query. Each requested total adds one `SUM`.

Totals cover the rows that the filter backends and [Row-Level Permissions](/v3/reference/glossary.md#row-level-permissions) leave, across all pages, so every page reports the same totals. [Row-Level Permission Filtering](/v3/core-concepts/row-level-permission-filtering.md#list-response-contract) describes that order. [#317](https://github.com/arrai-innovations/vueda/issues/317) tracks permission-aware aggregates.

The paginated response carries the requested totals beside the rows:

```json
{
    "results": [...],
    "columnTotals": {
        "subtotal": 12345.67,
        "product_price": 13580.24
    },
    "perPage": 10,
    "totalPages": 5,
    "totalRecords": 47
}
```

Every requested name is present, and every value is a number. A filter that matches no rows totals `0`. A `DurationField` total is a number of seconds, as [VuedaJSONRenderer](/v3/reference/api/py/vueda.core.renderers/VuedaJSONRenderer.md) encodes it. VUEDA's default settings make it the default renderer.

The server computes totals in the request that returns them and never caches them. When a client builds its own requests, it names every total that it wants on each request and replaces its totals with each response.

Declaring `column_totals` also documents `ct` on the viewset's `list` operation in the generated OpenAPI schema, with that viewset's total names. [`get_override_parameters`](/v3/reference/api/py/vueda.core.open_api/VuedaBaseAutoSchema.md#get_override_parameters) describes the parameter.

## Discover Totals from Model Info

The [`model_column_totals`](/v3/reference/api/py/vueda.info.serializers/ModelInfoSerializer.md#get_model_column_totals) section of model info lists the declared total names, in declaration order:

```text
GET /routes/vueda.info/model_info/billing/invoice/?e=model_column_totals
```

```json
{
    "model_column_totals": {
        "fields": ["subtotal", "tax", "product_price"]
    }
}
```

The section does not name the query parameter. The server reads it from the `COLUMN_TOTALS_PARAM` setting, and VUEDA's client sends the [`COLUMN_TOTALS_PARAM`](/v3/reference/api/js/utils/constants.md#COLUMN_TOTALS_PARAM) constant. If you rename the setting, change the client constant to match, as [Wire Query Parameter Namespace](/v3/core-concepts/configuration-surface-and-defaults.md#wire-query-parameter-namespace) describes. [#301](https://github.com/arrai-innovations/vueda/issues/301) tracks discovering the name in the client.

## Render Totals in `ViewList`

[ViewList](/v3/reference/api/vue/ViewList.md) renders declared totals with no client configuration. [useViewList](/v3/reference/api/js/use/useViewList/functions/useViewList.md) requests the advertised totals whose names match a visible display column. Hiding the last totalled column stops the request for totals.

The footer is a table row, so card layout requests no totals. [ObjectsGrid](/v3/reference/components/objectsgrid.md) describes when the grid switches to table layout at its `tableBreakpoint`. A list shell that calls `useViewList` directly passes it the same [`tableBreakpoint`](/v3/reference/api/js/use/useViewList/types/ViewListOptions.md#tableBreakpoint) that it gives `ObjectsGrid`.

In all-pages mode, [allPagePaginatedListCrudAdaptor](/v3/reference/api/js/utils/listCrud/functions/allPagePaginatedListCrudAdaptor.md) requests totals on the first page only, since every page carries the same totals.

### Format One Column's Total

The default footer shows each total as its raw number. Replace one cell with the [`field(<name>)totals`](/v3/reference/api/vue/ViewList/slots.md) slot, which receives the total as `value`. This list shows a `DurationField` total with [DurationDisplay](/v3/reference/api/vue/DurationDisplay.md):

```vue
<script setup>
import DurationDisplay from "@vueda/display/duration-display/DurationDisplay.vue";
import ViewList from "@vueda/views/ViewList.vue";
</script>

<template>
    <view-list app="billing" model="timesheet">
        <template #[`field(hours)totals`]="{ value }">
            <duration-display :value="value" inline />
        </template>
    </view-list>
</template>
```

### Replace the Footer Row

The [`row-after-objects`](/v3/reference/api/vue/ViewList/slots.md#slot-row-after-objects) slot replaces the whole footer. It receives `columnTotals` and `class`, the grid's row classes. `columnTotals` is empty in card layout, so render the row only when it has totals:

```vue
<view-list app="billing" model="invoice">
    <template #row-after-objects="{ class: rowClass, columnTotals }">
        <div v-if="Object.keys(columnTotals).length" :class="rowClass" role="row">
            <div role="cell">Totals</div>
            <div role="cell">{{ columnTotals.subtotal }}</div>
            <div role="cell">{{ columnTotals.product_price }}</div>
        </div>
    </template>
</view-list>
```

The slot receives only the totals that `useViewList` requested. `useViewList` rewrites the `ct` parameter whenever the visible columns or the layout change. To show the total of a column that the list does not display, add that column to `displayFields`, or fetch the total with a request of your own.

## Check the Result

- `manage.py check` reports no `vueda_info.E013` for the viewset.
- A `list` request without `ct` returns `columnTotals: {}`.
- A request naming one declared total returns only that key, under its declared name.
- A wildcard returns every declared total.
- Totals change when you apply a filter, and when a user with row-level restrictions views the list.
- Totals are the same on every page.
- A filter that matches no rows returns `0` totals.
- The table footer shows each total under its column.

## Troubleshooting

**`columnTotals` is missing from the response.** The viewset uses a pagination class other than `VUEDAPageNumberPagination` or a subclass that keeps its `get_paginated_response`. A bare array means pagination is off, and no totals are computed.

**`columnTotals` is `{}`.** The request named no totals. In `ViewList`, check that the totalled column is visible, the list is in table layout, and `model_column_totals` lists the total.

**A declared total never appears.** Its name matches no display column, so the client never requests it. `useViewList` logs a `console.error` naming the total. Rename the total to match its column, or add the column to `displayFields`.

**A `400` lists the valid totals.** The request named a total that the viewset does not declare. Total names are the keys of `column_totals`; the ORM paths are not valid names.

**Every list request is a `400`, or totals are never computed, after renaming `COLUMN_TOTALS_PARAM`.** The client still sends `ct`. A viewset that rejects unknown query parameters answers `400`, and one that ignores them computes no totals. Change the client constant to match the setting.

**A totals request raises `NotImplementedError`.** The list queryset picks one row per group with `distinct(...)` on a queryset annotation. `get_column_info` describes which `DISTINCT ON` querysets it can total.

**A total changes when another total is requested with it.** A path reaches through a relation that matches more than one row. Run `manage.py check`, and register the viewset if the check does not report it.

**Totals do not match the listed rows.** A customized `list` computes totals before `apply_row_level_filter`, or over the page. Call `get_column_info` on the filtered queryset, as the default `list` does.

**A duration total arrives as a string, such as `"3600.0"`.** DRF's `JSONRenderer` rendered the response. A project that sets `DEFAULT_RENDERER_CLASSES` itself, or sets `renderer_classes` on a viewset, keeps DRF's encoding. Use `VuedaJSONRenderer` there.

**A total is `null`.** An overridden `get_column_info` calls `aggregate()` with a `Sum` that has no `default`. The default method sums with a zero `default`.
