Why does Django emit UnorderedObjectListWarning when paginating some QuerySets, and why is ordering by created_at alone still not enough?
answer
- no ORDER BY, no promise
- rows can skip or repeat
- the ordered attribute
- a unique tie-breaker column
basics
~20 sWithout ORDER BY, the database may return rows in any order, so LIMIT/OFFSET pages can repeat or skip rows; Paginator warns when QuerySet.ordered is False. Ties on a non-unique column cause the same problem, so add the primary key as a tie-breaker.
solid answer
~40 s`Paginator.__init__()` checks `object_list.ordered`. For a QuerySet with no `order_by()` and no `Meta.ordering`, it is `False`, and Django raises `UnorderedObjectListWarning`, a `RuntimeWarning`, naming the model. The reason: each page is a separate `LIMIT`/`OFFSET` query, and without an `ORDER BY` the database is free to return rows in a different order each time, so an entry can appear on two pages or on none. The warning checks only whether an order exists, not whether it is total. `order_by('-created_at')` silences it, but two audit entries with the same timestamp can still swap between queries. Order by `('-created_at', '-id')`. Django 6.1 adds `QuerySet.totally_ordered`, which is `True` only when the ordering includes a unique, non-nullable field or set of fields.
code
python · 13 linesfrom django.views.generic import ListView
from .models import AuditEntry
class AuditLogView(ListView):
model = AuditEntry
paginate_by = 50
ordering = ['-created_at', '-id'] # id breaks ties between equal timestamps
# In a test on Django 6.1:
# assert AuditEntry.objects.order_by('-created_at', '-id').totally_orderedgo deeper
Remember to give every paginated QuerySet an explicit order_by() or a view ordering.
Explain why unordered LIMIT/OFFSET pages can skip or repeat rows, and that the warning checks QuerySet.ordered.
Add a unique tie-breaker, make the warning an error in tests, and use 6.1's totally_ordered to catch orderings that are present but not deterministic.
Make total ordering a project rule for every list and API endpoint, enforced by a test rather than by reviewer memory.
## What triggers the warning When a `Paginator` is created, it calls an internal check on its `object_list`: - it reads `object_list.ordered`, which only QuerySets (and similar objects) have; - if that is `False`, it issues `UnorderedObjectListWarning` from `django.core.paginator`, a subclass of `RuntimeWarning`, with a message like "Pagination may yield inconsistent results with an unordered object_list" and the model name. `QuerySet.ordered` is `True` when the QuerySet has an `order_by()` clause, or when the model has a default `Meta.ordering` and the query has no `GROUP BY` (a default ordering does not apply to grouped queries, so aggregated QuerySets can warn even on ordered models). Plain lists have no `ordered` attribute and never warn. ## Why an unordered page is wrong Pagination turns each page into its own query with `LIMIT` and `OFFSET`. SQL guarantees an order only when the query says `ORDER BY`. Without it: 1. Page 1 runs `LIMIT 50 OFFSET 0` and gets some 50 rows. 2. Page 2 runs `LIMIT 50 OFFSET 50` as a separate query, and the database may produce rows in a different sequence this time. 3. An audit entry can therefore appear on both pages, or on neither, and nothing errors. The bug is intermittent and data-dependent, which is why Django warns instead of letting it pass silently. ## Why ordering by created_at is not enough The warning checks only that some order exists. It does not check that the order is **total**, meaning every row has a unique position. An audit log written in bursts has many rows with identical `created_at` values. Among ties the database may again return any order, so the same skip-or-repeat problem appears at page boundaries that fall inside a group of equal timestamps. The fix is a unique tie-breaker at the end of the ordering: - `order_by('-created_at', '-id')` on the QuerySet, or `ordering = ['-created_at', '-id']` on the `ListView`; - or `Meta.ordering` with the same list, if every listing of the model should use it. ## Django 6.1: totally_ordered Django 6.1 adds the `QuerySet.totally_ordered` property. It is `True` only when the QuerySet is ordered **and** the ordering includes a field, or set of fields, that is unique and non-nullable, such as the primary key. The paginator still warns only on `ordered`, but a test can assert `totally_ordered` for every paginated QuerySet in the project and catch the tie problem the warning misses. | QuerySet | `ordered` | `totally_ordered` (6.1) | Warning | |---|---|---|---| | `AuditEntry.objects.all()` (no `Meta.ordering`) | `False` | `False` | yes | | `.order_by('-created_at')` | `True` | `False` | no | | `.order_by('-created_at', '-id')` | `True` | `True` | no | ## Handling it in practice - Treat the warning as a bug, not noise; in tests, turn warnings into errors so it fails CI. - Put the ordering on the view (`ordering`) or in `get_queryset()`, next to the pagination, so a reviewer sees both together. - Remember that an index matching the ordering matters for speed on a large audit table; that is a database tuning topic.
- Why might an aggregated QuerySet warn even when the model has Meta.ordering?`QuerySet.ordered` ignores the model's default ordering when the query has a `GROUP BY`, because Django does not apply `Meta.ordering` to grouped queries. Add an explicit `order_by()` to the aggregated QuerySet before paginating it.
- How can a test catch non-deterministic ordering that the warning misses?On Django 6.1, assert `queryset.totally_ordered` for each paginated QuerySet; it is `True` only when the ordering includes a unique, non-nullable field. On earlier versions, check that the ordering ends with the primary key or another unique, non-null column.
Paginating an unordered list is like dealing a shuffled deck into piles of ten, but reshuffling before each pile: you will see some cards twice and never see others.
saying these in an interview costs you the question
- An order_by on created_at alone guarantees stable pages
- The database returns rows in insertion order without ORDER BY
- UnorderedObjectListWarning means the query will fail
- Paginating a plain Python list triggers the warning
- The warning is harmless because each page query is identical