Using Django Like a Pro: Unlocking Database-Level Power
Why ORM Alone Isn’t Enough
Django’s ORM is powerful — but it can quietly become a bottleneck.
Most developers write code that works. Fewer write code that scales. The difference often comes down to how much work you push into the database vs Python.
Relying only on ORM abstractions can lead to:
- Excess queries (N+1 problems)
- Unnecessary data transfer
- Slower computations handled in Python instead of the database
For example, looping over querysets to update values:
for product in Product.objects.all():
product.stock -= 1
product.save()This looks fine — but it triggers multiple queries.
A better approach pushes the logic into the database:
from django.db.models import F
Product.objects.update(stock=F('stock') - 1)
#Same result. One query. Atomic and safe.This is where django performance starts improving dramatically.
When to Push Logic Into the Database
A simple rule:
If the database can do it faster, let it.
Push logic into the database when:
- You’re updating many rows
- You need atomic operations
- You’re aggregating or filtering large datasets
- You want to reduce Python loops
Keep logic in Python when:
- Business rules are complex
- Readability matters more than micro-optimization
- Queries become too hard to maintain
Example: counting related objects.
Avoid:
for user in users:
user.post_count = user.posts.count()Prefer:
from django.db.models import Count
users = User.objects.annotate(post_count=Count('posts'))Now the database does the counting — faster and cleaner.
This is the real shift in django orm vs sql thinking.
Using Database Functions and Expressions
Django provides powerful tools to use database-level operations without leaving Python.
F Expressions (Atomic Updates)
from django.db.models import F
Order.objects.filter(status='pending').update(
attempts=F('attempts') + 1
)Why this matters:
- Prevents race conditions
- Avoids multiple queries
- Ensures consistency under load
Annotations (Computed Fields)
from django.db.models import Avg
products = Product.objects.annotate(
avg_rating=Avg('reviews__rating')
)Now each product includes a computed value directly from the database.
Database Functions
from django.db.models.functions import Lower
users = User.objects.annotate(
username_lower=Lower('username')
)Use cases:
- Case-insensitive comparisons
- Date transformations
- String manipulations
Instead of pulling data into Python, you let the database handle it efficiently.
Constraints, Indexes, and Performance Wins
Database-level constraints and indexes are often ignored — but they’re critical for django database optimization.
Constraints (Data Integrity)
from django.db import models
class Product(models.Model):
price = models.DecimalField(max_digits=10, decimal_places=2)
class Meta:
constraints = [
models.CheckConstraint(
check=models.Q(price__gte=0),
name='price_non_negative'
)
]This ensures invalid data never enters your database.
Indexes (Speed)
class Order(models.Model):
created_at = models.DateTimeField()
class Meta:
indexes = [
models.Index(fields=['created_at']),
]Indexes improve:
- Filtering
- Sorting
- Lookup performance
Without indexes, large datasets become slow quickly.
select_related vs Prefetch
# JOIN (single query)
orders = Order.objects.select_related('user')
# Separate queries but optimized
orders = Order.objects.prefetch_related('items')Knowing when to use each avoids performance pitfalls.
Raw SQL Without Losing Django Safety
Sometimes, ORM isn’t enough.
Get Django Wiki’s stories in your inbox
Join Medium for free to get updates from this writer.
That’s where raw SQL comes in — but it should be used carefully.
Example:
from django.db import connection
with connection.cursor() as cursor:
cursor.execute(
"SELECT id, COUNT(*) FROM orders GROUP BY id"
)
results = cursor.fetchall()When is raw SQL justified?
- Complex joins not supported cleanly by ORM
- Performance-critical queries
- Advanced database features (CTEs, window functions)
Best practice:
- Keep raw SQL isolated
- Use parameterized queries
- Document why it exists
Think of it as a precision tool — not your default approach.
Real-World Use Cases That Actually Matter
Let’s move beyond theory.
High-Traffic Inventory Updates
Instead of:
product.stock -= 1
product.save()Use:
Product.objects.filter(id=product_id).update(
stock=F('stock') - 1
)This prevents race conditions during heavy traffic.
Leaderboards
from django.db.models import Sum
leaders = User.objects.annotate(
total_score=Sum('scores__points')
).order_by('-total_score')[:10]Fast, efficient, and database-driven.
Analytics Queries
from django.db.models import Count
daily_signups = User.objects.values('date_joined__date').annotate(
count=Count('id')
)No need to process in Python — the database handles it.
These are real wins in django performance.
How to Balance ORM vs Database Power
You don’t need to abandon the ORM.
You need to understand its limits.
Use ORM for:
- Simplicity
- Readability
- Rapid development
Use database-level features for:
- Performance-critical paths
- Bulk operations
- Aggregations and analytics
Ask yourself:
- Is this query efficient?
- Am I doing work in Python that the database can do faster?
- Will this scale under load?
The best Django developers don’t just write code that works.
They write code that performs.
Ahmad, a Django full-stack developer and creator of Django.wiki — a free learning platform for Django developers.








