Introduction
Django common mistakes are not always obvious when you're building fast, but they become painfully clear once your app hits production. A few wrong decisions early—around query patterns, signal handling, configuration, and database design—can turn a smooth deployment into a debugging nightmare. In this guide, we'll walk through the pitfalls that hurt Django apps in production and show you how to avoid them.
Whether you're running a transaction-heavy SaaS platform, an e-commerce backend, or a real-time API, these mistakes cost money and credibility. We've seen teams burn through database connections, spike their infrastructure costs, and wake up to 5xx errors because of issues that were preventable from day one. Let's cover the practical fixes.
N+1 Queries in Templates and Serializers
The N+1 query problem is the classic Django trap. Your view loads one object, the template iterates over related objects, and suddenly you're running dozens or hundreds of extra queries. In production, this tanks your database connection pool and response time.
The problem in templates:
# views.py
def order_list(request):
orders = Order.objects.all()
return render(request, 'orders.html', {'orders': orders})
# orders.html template
{% for order in orders %}
<div>
Order #{{ order.id }} - Customer: {{ order.customer.name }}
{% for item in order.items.all %}
<p>{{ item.product.name }} x{{ item.quantity }}</p>
{% endfor %}
</div>
{% endfor %}
If you have 50 orders with 10 items each, this fires 1 query for orders, 50 for customers, 50 for items, and 500 for products. That's 601 queries for one page.
The fix: Use select_related() for foreign keys and prefetch_related() for reverse relationships.
# views.py - fixed
from django.db.models import Prefetch
def order_list(request):
orders = Order.objects.select_related('customer').prefetch_related(
Prefetch('items', queryset=OrderItem.objects.select_related('product'))
)
return render(request, 'orders.html', {'orders': orders})
Now you're down to 4 queries total: one for orders, one for customers, one for items, one for products.
The problem in serializers: DRF serializers hide the same problem. A nested serializer on a many-to-many or reverse FK will fire a query per parent object.
# serializers.py - problematic
class OrderSerializer(serializers.ModelSerializer):
items = OrderItemSerializer(many=True, read_only=True)
class Meta:
model = Order
fields = ['id', 'customer', 'items']
# This will N+1 when you call many=True
serializer = OrderSerializer(orders, many=True)
The fix: Override the serializer's to_representation() or use a custom manager on your queryset before serialization.
# serializers.py - fixed
class OrderSerializer(serializers.ModelSerializer):
items = serializers.SerializerMethodField()
class Meta:
model = Order
fields = ['id', 'customer', 'items']
def get_items(self, obj):
# Assume items are already prefetched
items = obj.items.all()
return OrderItemSerializer(items, many=True).data
# In your view
orders = Order.objects.prefetch_related('items__product').all()
serializer = OrderSerializer(orders, many=True)
A better pattern: use queryset.only() or defer() to specify exactly which fields you need, reducing payload size and parse time.
Logic in Signals
Django signals are convenient, but they're a footgun for complex logic. Signal handlers run synchronously in the same transaction, can't be easily retried, and are invisible to anyone reading the main code path. You'll end up debugging side effects instead of main features.
Common mistake:
# signals.py
from django.db.models.signals import post_save
from django.dispatch import receiver
@receiver(post_save, sender=Order)
def send_order_confirmation(sender, instance, created, **kwargs):
if created:
# Attempt to send email synchronously
send_email_to_customer(instance)
# Update inventory
update_inventory(instance)
# Call external API
notify_warehouse_api(instance)
If send_email_to_customer times out or fails, the entire request fails and the user sees an error, even though the order was saved. If the warehouse API is slow, your response time crawls. Testing becomes a maze—you never know what side effects a model save will trigger.
The fix: Move side effects to task queues. Use Celery, RQ, or Django-Q to defer work.
# models.py or admin.py
from django.db.models.signals import post_save
from django.dispatch import receiver
from .tasks import send_order_confirmation_task
@receiver(post_save, sender=Order)
def trigger_order_tasks(sender, instance, created, **kwargs):
if created:
# Just enqueue the task; don't block the request
send_order_confirmation_task.delay(instance.id)
# tasks.py
from celery import shared_task
@shared_task
def send_order_confirmation_task(order_id):
order = Order.objects.get(id=order_id)
send_email_to_customer(order)
update_inventory(order)
notify_warehouse_api(order)
If you must use signals, keep them thin: validate data or set a flag, then dispatch a task. Never do I/O or call external APIs directly inside a signal handler.
DEBUG=True in Production
Leaving DEBUG=True in production is a security and performance disaster that's easy to overlook in rushed deployments.
Why it's dangerous:
- The Django error page exposes your entire codebase, settings, and environment variables (including database credentials and API keys) to anyone who triggers an error.
- Django stores every SQL query in memory when
DEBUG=True. On a high-traffic site, this leaks gigabytes of RAM within hours. - Static files are served through Django's development server, which is orders of magnitude slower than a reverse proxy like nginx.
The fix: Always set DEBUG=False in production. Use environment variables.
# settings.py
import os
from pathlib import Path
DEBUG = os.getenv('DEBUG', 'False') == 'True'
SECRET_KEY = os.getenv('SECRET_KEY')
ALLOWED_HOSTS = os.getenv('ALLOWED_HOSTS', 'localhost').split(',')
# Enable Sentry or similar for error tracking in production
if not DEBUG:
import sentry_sdk
sentry_sdk.init(dsn=os.getenv('SENTRY_DSN'))
In your deployment config (Docker, systemd, or cloud platform), set DEBUG=False explicitly. Add a pre-deploy check that fails if DEBUG=True.
# manage.py or a deployment script
import sys
import os
if os.getenv('DEBUG') == 'True' and os.getenv('ENVIRONMENT') == 'production':
print("ERROR: DEBUG=True in production environment")
sys.exit(1)
Missing Database Indexes on Filter Fields
Every query with a filter() or get() on an unindexed field becomes a full table scan. As your data grows, these queries degrade from milliseconds to seconds. You won't notice until you're at scale.
Common mistake:
# models.py
class Order(models.Model):
customer_id = models.IntegerField() # No index
status = models.CharField(max_length=20) # No index
created_at = models.DateTimeField(auto_now_add=True) # No index
# views.py
# This scan the entire orders table
orders = Order.objects.filter(status='pending')
With 1 million orders, this query without an index could scan every row. Add db_index=True to fields you filter or sort on frequently.
The fix:
# models.py
class Order(models.Model):
customer_id = models.IntegerField(db_index=True)
status = models.CharField(max_length=20, db_index=True)
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
class Meta:
indexes = [
models.Index(fields=['status', 'created_at']), # Composite index
]
Create a migration and apply it:
# Generated migration
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('orders', '0001_initial'),
]
operations = [
migrations.AddIndex(
model_name='order',
index=models.Index(fields=['status', 'created_at'], name='orders_status_created_idx'),
),
]
Use django-extensions or raw SQL to audit your queries: python manage.py sqlqueries or check your database's slow query log. If a query without an index runs in production, add the index immediately.
Common Mistakes Summary
- N+1 in templates and serializers: Use
select_related(),prefetch_related(), and query prefetching. Check your database logs for repeated queries. - Signal overload: Keep signals thin; defer heavy work to task queues. Avoid I/O and external API calls in signal handlers.
- DEBUG=True in production: Always set
DEBUG=Falsein production. Use environment variables and pre-deploy checks. - Unindexed filter fields: Add indexes to any field you filter, sort, or join on. Use composite indexes for common query patterns.
Key Takeaways
- Profile your queries in development using Django Debug Toolbar or raw query logging; don't assume your code is efficient.
- Design your data models and migrations with production scale in mind. Indexes and query patterns matter from day one.
- Separate side effects from the main request path using task queues. Keep signals and request handlers simple and fast.
- Use environment variables for all configuration. Test your production settings locally before deployment.
- Monitor your database connection count, query time, and memory usage in production. Set up alerts for query slowdowns and connection pool exhaustion.






Comments
Loading comments…