Fast estimated row counts for Django ORM queries on PostgreSQL
Project description
Django PostgreSQL Estimated Count
Fast estimated row counts for Django ORM queries on PostgreSQL. Replace expensive COUNT(*) queries with estimated_count() for near-instant results on large tables.
Inspired by the PostgreSQL wiki count estimate snippet.
Why?
QuerySet.count() runs SELECT COUNT(*) which must check row visibility for every matching row under PostgreSQL's MVCC model. On large tables this can take seconds or minutes.
This package provides estimates that are typically accurate enough for pagination, dashboards, and admin list views — without scanning the full table.
Installation
pip install django-postgresql-estimated-count
Requires Python 3.10+ and Django 4.2+ (tested against Django 5.2).
Quick start
QuerySet
Use EstimatedCountManager on your model (or call estimated_count() on any queryset via the module helper):
from django.db import models
from django_postgresql_estimated_count import EstimatedCountManager
class Article(models.Model):
title = models.CharField(max_length=200)
published = models.BooleanField(default=False)
objects = EstimatedCountManager()
# Whole table — uses pg_class.reltuples (O(1))
Article.objects.estimated_count()
# Filtered queryset — uses EXPLAIN (FORMAT JSON)
Article.objects.filter(published=True).estimated_count()
You can also use the standalone helper without changing your manager:
from django_postgresql_estimated_count import estimated_count
estimated_count(Article.objects.filter(published=True))
Django Admin
Add the mixin to your ModelAdmin to speed up changelist pagination:
from django.contrib import admin
from django_postgresql_estimated_count import EstimatedCountAdminMixin
from .models import Article
@admin.register(Article)
class ArticleAdmin(EstimatedCountAdminMixin, admin.ModelAdmin):
list_display = ("title", "published")
list_filter = ("published",)
This replaces the paginator's count with an estimate for both unfiltered and filtered admin list views, and disables the extra full-table count query (show_full_result_count = False).
Paginator only
Use the paginator directly outside admin:
from django_postgresql_estimated_count import EstimatedCountPaginator
paginator = EstimatedCountPaginator(queryset, per_page=25)
paginator.count # estimated, not exact
How it works
The package picks a strategy automatically based on the queryset:
| Query type | Strategy | Source |
|---|---|---|
| Unfiltered (whole table) | pg_class.reltuples |
PostgreSQL catalog statistics |
Filtered (.filter(), admin search/filters) |
EXPLAIN (FORMAT JSON) |
Query planner row estimate |
estimated_count()
│
├─ Not PostgreSQL? ──► fallback to exact .count()
│
├─ No WHERE clause? ──► pg_class.reltuples
│
└─ Has filters? ──────► EXPLAIN → Plan Rows
Whole-table counts (pg_class)
For querysets with no filters, the package reads reltuples from pg_class:
SELECT reltuples::bigint FROM pg_class WHERE oid = 'your_table'::regclass;
This is updated by VACUUM, ANALYZE, and autovacuum. Estimates are usually good unless the table changed significantly since the last analyze.
Filtered counts (EXPLAIN)
For querysets with a WHERE clause, the package runs:
EXPLAIN (FORMAT JSON) SELECT ... FROM your_table WHERE ...
and reads Plan Rows from the top-level plan node — the same approach described on the PostgreSQL wiki.
Accuracy
These are estimates, not exact counts. They are intended for:
- Admin pagination ("Page 1 of ~400")
- Dashboard metrics where approximate values are acceptable
- Avoiding slow
COUNT(*)on large tables
Do not use them for billing, inventory, or any case requiring exact numbers.
Limitations
Estimation only applies to plain row counts. The following queryset shapes are
detected automatically and fall back to an exact .count() (correct, but not
sped up), because their COUNT(*) semantics differ from the number of rows the
underlying SELECT returns:
DISTINCTqueries (.distinct(),.values(...).distinct())- Aggregated /
GROUP BYqueries (.values(...).annotate(...)) - Combined queries (
.union(),.intersection(),.difference()) - Sliced querysets (
qs[:10])
Empty querysets such as .none() (or .filter(pk__in=[])) return 0. Note
that for a filtered query matching no rows, PostgreSQL's planner reports a
minimum of one estimated row, so estimated_count() may return 1 where the
exact count is 0.
Plain unfiltered and filtered counts (including admin search and list filters) use the fast estimate paths.
Non-PostgreSQL fallback
On SQLite, MySQL, or other backends, estimated_count() and EstimatedCountPaginator.count silently fall back to Django's standard exact count. No configuration is required.
Development
This project uses uv for environment and dependency management, Ruff for linting and formatting, and ty for type checking.
Setup
uv sync
This creates a virtual environment and installs the project together with the
dev dependency group.
Linting, formatting & type checking
uv run ruff check . # lint
uv run ruff format . # auto-format
uv run ty check # type check
Running tests
All tests run against PostgreSQL. The easiest way to start a test database is Docker:
docker compose up -d --wait postgres
uv run pytest
Or use the helper script:
chmod +x scripts/run-tests.sh
./scripts/run-tests.sh
Configure the connection via environment variables if needed:
| Variable | Default |
|---|---|
POSTGRES_DB |
django_postgresql_estimated_count_test |
POSTGRES_USER |
postgres |
POSTGRES_PASSWORD |
postgres |
POSTGRES_HOST |
127.0.0.1 |
POSTGRES_PORT |
55432 |
The test database is created automatically by pytest-django on first run.
Version support
| Component | Supported versions |
|---|---|
| Python | 3.10, 3.11, 3.12, 3.13 |
| Django | 4.2, 5.0, 5.1, 5.2 |
| Database | PostgreSQL (required for tests) |
License
MIT — see LICENSE.
References
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file django_postgresql_estimated_count-0.1.0.tar.gz.
File metadata
- Download URL: django_postgresql_estimated_count-0.1.0.tar.gz
- Upload date:
- Size: 8.5 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.10.6 {"installer":{"name":"uv","version":"0.10.6","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"22.04","id":"jammy","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
88119af0e4d5e0a9df1d6587d88e9345acee2a9251efef55c73bb1f14d64af0e
|
|
| MD5 |
355c561797db3329ef311df787db140d
|
|
| BLAKE2b-256 |
83062844bd2f8779544563f2214e9d641e4afbce6b34a80c89167a12b5fdadb1
|
File details
Details for the file django_postgresql_estimated_count-0.1.0-py3-none-any.whl.
File metadata
- Download URL: django_postgresql_estimated_count-0.1.0-py3-none-any.whl
- Upload date:
- Size: 8.9 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.10.6 {"installer":{"name":"uv","version":"0.10.6","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"22.04","id":"jammy","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
f5fae356cd579afab4108189f77e77332c149bbc981c9a4f0f89e8d4dacf1148
|
|
| MD5 |
41bd795b78ede8f72fcf46b1386cadba
|
|
| BLAKE2b-256 |
63d64c5eb92e9f69379c4e319b127026ae0deecd28d13fee2d860c0ed22d6168
|