-
-
Notifications
You must be signed in to change notification settings - Fork 32.7k
Fixed #8408. Add ModelAdmin.estimated_count flag for optional fast row estimation in admin changelists #19624
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
…ow estimation in admin changelists Add optional estimated_count flag to ModelAdmin for faster changelist pagination This patch introduces support for ModelAdmin.estimated_count = True, an optional flag that enables faster pagination in the Django admin by replacing the default .count() calls in ChangeList.get_results() with approximate row estimates when supported by the database backend. When enabled, the admin changelist uses estimate_row_count() to query low-cost system metadata: - On PostgreSQL: pg_class.reltuples - On MySQL: SHOW TABLE STATUS If estimation is not supported or fails, the system falls back to the original .count() behavior. This change has no impact unless the flag is explicitly enabled. Included: - estimate_row_count() helper function - support in ChangeList.get_results() - fallback behavior with tests - PostgreSQL integration test for estimate accuracy - benchmark script comparing estimation vs .count() on large tables - documentation for the new ModelAdmin.estimated_count attribute This significantly reduces admin latency on large tables (e.g. 10M+ rows) without compromising UI behavior or correctness.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hello! Thank you for your contribution 💪
As it's your first contribution be sure to check out the patch review checklist.
If you're fixing a ticket from Trac make sure to set the "Has patch" flag and include a link to this PR in the ticket!
If you have any design or process questions then you can ask in the Django forum.
Welcome aboard ⛵️!
This commit includes final polishing for the `ModelAdmin.estimated_count` feature: - Adds a proper code block to the `ModelAdmin.estimated_count` section in `docs/ref/contrib/admin/index.txt` to satisfy blacken-docs. - Fixes `W292` (no newline at end of file) in several files that failed flake8. - Added to example `.. code-block:: python` to ensure compatibility with `black` formatting. - Adds a release note entry to `docs/releases/6.0.txt` documenting the new `estimated_count` flag.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This looks like it has been generated by an AI tool and there are a number of large issues with it. I also think it's the wrong approach...
The issue is that the paginators provided by Django currently only support performing a count and then using LIMIT
and OFFSET
. I think the correct approach to this is to use keyset pagination. See this article for a comparison of approaches. There are even third-party packages such as django-keyset-pagination-plus
which attempt to solve this, but probably don't work well with the admin.
To solve this issue probably requires two things:
- Provide support for a
KeysetPaginator
in Django itself - Adjust the admin to add support for it
I suggest searching the forum for prior discussions on this topic that could be resumed - or starting a new discussion if you cannot find one.
def estimate_row_count(model, connection): | ||
"""Return an estimated number of rows for ``model`` using ``connection``. | ||
|
||
Return ``None`` if estimation isn't supported or fails. | ||
""" | ||
table = model._meta.db_table | ||
try: | ||
with connection.cursor() as cursor: | ||
if connection.vendor == "postgresql": | ||
cursor.execute( | ||
"SELECT reltuples::BIGINT FROM pg_class WHERE relname = %s", | ||
[table], | ||
) | ||
row = cursor.fetchone() | ||
if row: | ||
return int(row[0]) | ||
elif connection.vendor == "mysql": | ||
cursor.execute("SHOW TABLE STATUS WHERE Name = %s", [table]) | ||
row = cursor.fetchone() | ||
if row: | ||
return int(row[4]) | ||
else: | ||
return None | ||
except Exception: | ||
return None | ||
return None |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If this were to be implemented, each database backend under django.db.backends
should provide a function for performing an estimated count rather than comparisons on connection.vendor
in the code for the admin which does not give the opportunity for third-party backends to implement support if they are able to.
if self.model_admin.estimated_count: | ||
from django.contrib.admin.utils import estimate_row_count | ||
from django.db import connections | ||
|
||
connection = connections[self.root_queryset.db] | ||
estimate = estimate_row_count(self.model, connection) | ||
if estimate is not None: | ||
paginator.count = estimate | ||
result_count = estimate | ||
else: | ||
# Get the number of objects, with admin filters applied. | ||
result_count = paginator.count |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is simply incorrect. If the backend has a way of providing an estimated count, it will always be used, regardless of any filtering, e.g. via search text or using a list filter. The estimated count only has value where the whole table is being displayed.
Further, overriding paginator.count
is likely to cause issues - if the estimate is lower than the actual count by a large enough margin, it may mean being unable to navigate to the last page (or pages).
if self.model_admin.show_full_result_count: | ||
full_result_count = self.root_queryset.count() | ||
if estimate is not None: | ||
full_result_count = estimate | ||
else: | ||
full_result_count = self.root_queryset.count() | ||
else: | ||
full_result_count = None |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't see much value for displaying an estimate over the actual count. If performing the actual count is too costly, then you have so much data that you probably don't really even care about a (likely inaccurate) estimate. In which case you can use show_full_result_count = False
to simply avoid the query.
|
||
.. code-block:: python | ||
|
||
from django.contrib.admin.views.decorators import staff_member_required | ||
|
||
|
||
@staff_member_required | ||
def my_view(request): ... | ||
def my_view(request): ... |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These changes are unrelated.
.. attribute:: ModelAdmin.estimated_count | ||
|
||
.. versionadded:: 6.0 | ||
|
||
Set ``estimated_count`` to ``True`` to display an approximate number of | ||
rows in the change list. When enabled, the count is retrieved using | ||
low-cost database specific queries (for example ``pg_class.reltuples`` on | ||
PostgreSQL or ``SHOW TABLE STATUS`` on MySQL). Unsupported databases fall | ||
back to the exact ``COUNT(*)`` query. The estimated value is used only for | ||
pagination display and may not reflect the precise row count. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No mention is made of the caveats of this approach - you need to ensure that your table is properly analysed frequently enough so that the statistics are updated to give a reasonable estimate. This varies for each database. Different workloads can also have a huge impact on how accurate the estimate is.
This also doesn't mention that it would give completely incorrect results when filtering as mentioned above.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why is this included? We don't maintain benchmark scripts for individual features like this.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Mainly was included just like a "proof of concept" of PR's body. It does not seek to be merged; It's merely illustrative.
cursor.execute("DELETE FROM bench_table") | ||
cursor.executemany( | ||
f"INSERT INTO bench_table(id) VALUES ({placeholder})", | ||
[(i,) for i in range(1000)], |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Besides, benchmarking for COUNT
against 1000 rows seems pointless. That's not a large number of records. I'd only expect this sort of estimation to be worthwhile when you have millions of rows as a minimum.
Thanks for the detailed review. I’m working on the changes |
I think before putting lots of effort into making further changes you need to discuss approaches to solving this issue on the forum as I've mentioned above:
|
I’m happy to start a discussion on the forum, @ngnpope , but my account (username rylaix) is stuck in a bugged state — I can’t create topics or reply or DM. It was mentioned on Django's discord, however, no response was received. |
Hello @rylaix, I've temporarily unsilenced your Forum account, so you can now post on Discourse. Please take the time to write your posts thoughtfully and double-check the content before submitting. Unreviewed posts can create extra work for maintainers and moderators, so we ask everyone to be mindful of that. Also, a general note: if you use AI tools to help draft content, you have to disclose that fact and please make sure you fully understand and review what you're sharing. Posts generated without careful oversight can lead to confusion and aren't generally welcomed on Discourse nor here. |
Summary
This pull request introduces an optional estimated_count flag for ModelAdmin, enabling faster and cheaper pagination in Django admin changelists by avoiding full COUNT(*) queries on large tables.
When estimated_count = True is set on a ModelAdmin, the admin changelist uses low-cost row estimation techniques based on database-specific system metadata to populate pagination and result count fields. For supported backends (PostgreSQL and MySQL), this provides significant performance improvements with minimal loss of accuracy.
Motivation
The Django admin uses .count() in ChangeList.get_results() to calculate the total number of rows and pages. While this is reliable, it's inefficient for large datasets. On tables with millions of rows, .count() can take several seconds, even minutes — degrading admin responsiveness and increasing database load.
This performance problem is well-known in the community, and many developers resort to monkey-patching or writing custom admin wrappers to avoid expensive counts. By introducing a first-class, opt-in flag, we provide a clean and official way to mitigate the issue.
How it works
A new boolean attribute estimated_count can be added to any ModelAdmin:
class MyModelAdmin(admin.ModelAdmin):
estimated_count = True
If set to True, the admin changelist uses the estimate_row_count(model, connection) helper instead of .count().
The estimate_row_count() function uses native system queries:
If estimation is unavailable or fails, .count() is used as a safe fallback.
The estimation is only used for pagination display — it does not affect queryset slicing or actual result limits.
Performance Impact
A benchmark was conducted using a synthetic table with 10 million rows on PostgreSQL (Apple Macbook Air M4 base):
.count(): ~0.1010s
Estimation: ~0.0041s
Speedup: ~25x
The script used for benchmarking is included at scripts/benchmark_estimated_count.py. It can be configured to test real-world latency differences between full count queries and metadata-based estimation.
Backward Compatibility
By default, nothing changes. ModelAdmin.estimated_count is False unless explicitly set.
If the flag is not enabled, the changelist logic continues to use .count() with no change in behavior.
All internal logic is fully contained within ChangeList.get_results() and django.contrib.admin.utils.estimate_row_count.
The fallback ensures that unsupported or misconfigured databases will still behave correctly without errors.
Tests
Documentation
Why this should be merged
This patch enables Django admin to scale better with modern datasets, without resorting to external monkey-patching or breaking internal APIs. It addresses real-world pain points in production environments and empowers developers with an officially supported solution.
Closes Trac ticket #8404
Authored by: @rylaix