Django Annotations, Aggregation, and Transactions
Learn how Django's annotate() and aggregate() methods compute values in querysets, how to group and filter those results, and how to wrap multi-step updates in atomic transactions.
Django's ORM provides two complementary ways to compute values in the database instead of in Python: annotate() adds a computed field to each row in a queryset, while aggregate() returns a single dictionary of summary values. When those calculations feed into later updates, wrapping the work in a transaction keeps multi-step changes consistent. This article explains the core syntax and how to use annotations, aggregations, and transactions together.
Using annotate() to Add Computed Fields
The annotate() method appends a calculated field to each row in a queryset. It is the standard way to include per-object aggregates, such as the number of related orders or the sum of a numeric column. For example, given an Author model with a related Book model, you can annotate each author with their total book count:
from django.db.models import Count authors = Author.objects.annotate(book_count=Count('books'))
Each Author object in the resulting queryset now has a book_count attribute. You can use any aggregate function inside annotate(), including Sum, Avg, Min, and Max. You can also combine multiple annotations and use F expressions to compute values from existing fields:
from django.db.models import F, Sum products = Product.objects.annotate( total_sold=Sum('orderitem__quantity'), inventory_value=F('price') * F('quantity'), )
Here total_sold is the total quantity from related OrderItem rows, and inventory_value is calculated from the product's own price and quantity fields. F expressions keep the calculation in the database, which avoids loading rows into Python just to sum or multiply them.
Using aggregate() for Whole-Query Calculations
While annotate() adds a field to each row, aggregate() returns a single dictionary with the result of a calculation across the entire queryset. This is useful for totals, averages, or counts that span all matching records. For instance:
from django.db.models import Sum total_sales = Order.objects.aggregate(total=Sum('amount'))
This returns a dictionary like {'total': Decimal('1234.56')}. You can include multiple aggregates in one call:
from django.db.models import Avg, Count, Max stats = Order.objects.aggregate( avg_amount=Avg('amount'), max_amount=Max('amount'), order_count=Count('id'), )
The key difference between annotate() and aggregate() is the shape of the result: a queryset with extra fields versus a single dictionary. The following table summarizes the distinction:
| Method | Result shape | Use case |
|---|---|---|
annotate | Queryset with extra columns | Per-object computed values |
aggregate | Single dictionary | Whole-query summary statistics |
Grouping with values() and annotate()
To group rows by a field and compute aggregates per group, combine values() with annotate(). The values() call specifies the grouping columns, and annotate() adds the computed field. For example, to get the total sales per customer:
from django.db.models import Sum sales_by_customer = Order.objects.values('customer').annotate( total=Sum('amount') )
This produces a queryset of dictionaries, each with a customer key and a total value. The customer value is the foreign key ID, not a model instance. You can group by multiple fields and filter before grouping to restrict the input set:
from django.db.models import Sum revenue_by_region = Order.objects.filter(status='paid').values('region').annotate( total_revenue=Sum('amount') )
Order of operations matters: filter() runs before grouping, so only paid orders contribute to the totals. You can also filter on an annotation after it has been created; Django turns that into a HAVING clause for grouped queries:
Order.objects.values('region').annotate( total_revenue=Sum('amount') ).filter(total_revenue__gt=1000)
Wrapping Queryset Operations in Transactions
When a workflow involves reading aggregated data and then updating records based on that data, you need to ensure the entire sequence is atomic. Django's transaction.atomic() block guarantees that all database operations inside it either commit together or roll back together. A typical pattern is:
from django.db import transaction from django.db.models import Sum def update_inventory(product_id): with transaction.atomic(): product = Product.objects.select_for_update().get(pk=product_id) total_sold = OrderItem.objects.filter(product=product).aggregate( total=Sum('quantity') )['total'] or 0 product.stock = product.initial_stock - total_sold product.save()
The select_for_update() method locks the Product row until the transaction ends. This prevents another transaction from modifying the same product concurrently, which could otherwise lead to lost updates or inconsistent stock levels. Without the lock, two concurrent requests could both read the same total_sold, compute the same new stock, and write it back, losing one update. On databases that do not support locking reads, Django ignores the call, so verify the behavior on your target database.
Combining Aggregation and Transactions in a Real Workflow
Consider a scenario where you need to recalculate a user's account balance from all their transactions and then update the balance field. The following code uses aggregation and a transaction to do this safely:
from django.db import transaction from django.db.models import Sum def refresh_balance(user_id): with transaction.atomic(): user = User.objects.select_for_update().get(pk=user_id) balance = Transaction.objects.filter(user=user).aggregate( total=Sum('amount') )['total'] or 0 user.balance = balance user.save(update_fields=['balance'])
Here select_for_update() locks the user row, and the aggregate query runs inside the same transaction. If any part fails, the transaction rolls back, leaving the database unchanged. This pattern is especially important when the balance is read by other parts of the system, because it prevents a stale read from being used in a later update.
You can also identify rows with annotations and lock them with select_for_update() when you need to update multiple rows based on computed values. For instance, to adjust all products that have sold more than a threshold:
from django.db import transaction from django.db.models import Count with transaction.atomic(): product_ids = Product.objects.annotate( order_count=Count('orderitem') ).filter(order_count__gt=10).values('id') products = Product.objects.select_for_update().filter(id__in=product_ids) for product in products: product.is_popular = True product.save(update_fields=['is_popular'])
The annotation identifies the affected products, and the separate select_for_update() queryset locks only those product rows. This avoids putting FOR UPDATE on an aggregate query, which several databases do not allow. The transaction ensures that the is_popular flag is set consistently across all selected products.
Performance Considerations and Common Pitfalls
Aggregation queries are executed in the database, which is generally efficient, but there are pitfalls that can degrade performance. One common mistake is using annotate() without select_related() or prefetch_related() when you later access related objects. For example, if you annotate a queryset with a count and then loop over it accessing a foreign key, Django will issue a query for each row unless you prefetch the relation. Use select_related() for forward foreign keys and prefetch_related() for reverse relations.
Another pitfall is performing aggregation in Python instead of the database. If you fetch all rows and then sum them with a loop, you move the work to the application server and increase memory usage. Prefer aggregate() or annotate() with Sum, Count, and similar database functions unless you need per-row values for other reasons.
When using select_for_update(), be aware that locking behavior is database-specific. PostgreSQL locks selected rows until the transaction ends. MySQL supports locking reads with a transactional storage engine such as InnoDB, but the exact behavior depends on engine configuration. Some backends ignore the call entirely. Test your locking strategy on the actual database you deploy to.
Finally, annotations are computed attributes on the returned model instances; they are not model fields. Calling save() on an annotated object will not persist the annotation. Assign the computed value to a real model field before saving, as shown in the earlier examples, and use update_fields to limit the write to the fields you actually changed.