Django Code Smells: The 30 Patterns That Signal Trouble
Spot problems in seconds, not hours
Django Code Smells: The 30 Patterns That Signal Trouble
Spot problems in seconds, not hours
Photo by Richárd Ecsedi on Unsplash
If you’re not a Medium member, you can read this article for free via this link: Friend Link
I’ve reviewed 1,000+ Django pull requests.
Here’s what I’ve learned:
Most bugs have a smell — a pattern that signals “something’s wrong here.”
The difference between junior and senior:
Junior: “This code works, ship it!” Senior: “This code smells. Let me investigate.”
The pattern:
- Junior spends 2 hours coding, 10 hours debugging
- Senior spends 10 minutes spotting smells, prevents the bugs
The truth:
- Every bug has warning signs
- Code smells predict future problems
- Pattern recognition beats deep analysis
- Trust your nose
Today, I’ll teach you to smell trouble instantly.
30 patterns that signal problems. Each with:
- ✅ The smell (what to look for)
- ✅ Why it’s bad
- ✅ How to fix it
- ✅ When it’s acceptable
No judgment. Just recognition.
Let’s sharpen your senses.
Category 1: Database Smells
Smell #1: The Loop Query
What you see:
posts = Post.objects.all()
for post in posts:
author = post.author # Query!
print(f"{author.name}: {post.title}")
The smell: Database query inside a loop
Why it’s bad:
1 post → 2 queries
10 posts → 11 queries
100 posts → 101 queries (N+1 problem)
Impact:
- 50ms → 5 seconds
- Database CPU: 80%
- Kills performance at scale
How to fix:
posts = Post.objects.select_related('author')
for post in posts:
author = post.author # No query! Already loaded
print(f"{author.name}: {post.title}")
# 1 post → 1 query
# 100 posts → 1 query
When it’s acceptable:
- Loop runs once or twice
- Prototyping (fix before production)
Detection tip:
# In tests, check query count
from django.test.utils import override_settings
from django.db import connection
with self.assertNumQueries(1): # Expect 1 query
list(Post.objects.select_related('author'))
Smell #2: The Missing Index
What you see:
class Post(models.Model):
title = models.CharField(max_length=200)
slug = models.SlugField() # No db_index=True
status = models.CharField(max_length=20)
# Later in code:
Post.objects.filter(slug='my-post') # Slow!
Post.objects.filter(status='published') # Full table scan!
The smell: Filtering/ordering by field without index
Why it’s bad:
Without index: O(n) - full table scan
With index: O(log n) - fast lookup
1,000 rows: 10x slower
1,000,000 rows: 1000x slower
How to fix:
class Post(models.Model):
title = models.CharField(max_length=200)
slug = models.SlugField(unique=True, 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 = [
# Composite index for common query
models.Index(fields=['status', '-created_at']),
]
Rule of thumb: Index if you:
- Filter by it (
filter(field=value)) - Order by it (
order_by('field')) - Use it in joins (
select_related('field'))
When NOT to index:
- Field rarely queried
- Small table (<1000 rows)
- Field has low cardinality (2–3 values)
Smell #3: The Lazy Delete
What you see:
# Deleting without consideration
post.delete()
user.delete()
order.delete()
The smell: No cascade strategy defined
Why it’s bad:
# What happens to related objects?
# Comments? Likes? Analytics?
# Often causes:
# - Data integrity issues
# - Broken foreign keys
# - Lost historical data
How to fix:
class Comment(models.Model):
post = models.ForeignKey(
Post,
on_delete=models.CASCADE # ✓ Explicit strategy
)
author = models.ForeignKey(
User,
on_delete=models.SET_NULL, # ✓ Keep comment, remove author
null=True
)
class Order(models.Model):
user = models.ForeignKey(
User,
on_delete=models.PROTECT # ✓ Can't delete if has orders
)
Cascade strategies:
CASCADE # Delete related objects
PROTECT # Prevent deletion if related exist
SET_NULL # Set to NULL (keep record)
SET_DEFAULT # Set to default value
DO_NOTHING # Database handles it (dangerous)
SET(func) # Set to function result
When it’s acceptable:
- Prototyping only
- Test data
Smell #4: The Fat Model
What you see:
class User(models.Model):
# Fields...
def send_welcome_email(self):
# 30 lines of email logic
def charge_credit_card(self):
# 40 lines of Stripe integration
def generate_analytics_report(self):
# 50 lines of report logic
def sync_to_crm(self):
# 30 lines of API calls
# 500 lines total in model
The smell: Model has business logic, external API calls, complex operations
Why it’s bad:
- Hard to test (model tied to everything)
- Slow to load (imports everything)
- Violates single responsibility
- Can’t reuse logic outside model
How to fix:
# models.py - Keep it simple
class User(models.Model):
email = models.EmailField()
# Just data definition
# services/email.py
class EmailService:
@staticmethod
def send_welcome_email(user):
# Email logic here
# services/payment.py
class PaymentService:
@staticmethod
def charge_card(user, amount):
# Stripe logic here
# Usage
from services.email import EmailService
EmailService.send_welcome_email(user)
Rule of thumb: Models should:
- ✓ Define data structure
- ✓ Simple property methods
- ✓ Basic validation
Models should NOT:
- ✗ Call external APIs
- ✗ Send emails
- ✗ Generate reports
- ✗ Complex business logic
Smell #5: The String Query
What you see:
# Using raw SQL strings
User.objects.raw('SELECT * FROM users WHERE status = %s', ['active'])
# Or worse
from django.db import connection
cursor = connection.cursor()
cursor.execute("SELECT * FROM posts WHERE author_id = %s", [user_id])
The smell: Raw SQL when ORM would work
Why it’s bad:
- SQL injection risk
- Loses ORM benefits (caching, relations)
- Database-specific (not portable)
- Harder to test
- Breaks IDE autocomplete
How to fix:
# Use ORM
users = User.objects.filter(status='active')
# If complex query needed
from django.db.models import Q, F, Count
posts = Post.objects.filter(
Q(status='published') &
Q(views__gt=F('author__follower_count'))
).annotate(
comment_count=Count('comments')
)
When raw SQL is acceptable:
- Complex aggregations ORM can’t handle
- Performance-critical queries (after profiling)
- Database-specific features needed
- Migrations
If you must use raw SQL:
# Use parameterized queries (not string concatenation)
User.objects.raw(
'SELECT * FROM users WHERE status = %s',
['active'] # ✓ Parameterized
)
# NOT this:
User.objects.raw(
f'SELECT * FROM users WHERE status = {status}' # ✗ Injection risk!
)
Category 2: View Smells
Smell #6: The God View
What you see:
def dashboard(request):
# 200 lines of code
# 15 database queries
# 5 external API calls
# 3 email sends
# Multiple try/except blocks
# Returns one template
The smell: View doing everything
Why it’s bad:
- Hard to test
- Hard to maintain
- Hard to reuse logic
- Slow response time
- Hard to debug
How to fix:
# Break into smaller pieces
def dashboard(request):
context = {
'stats': get_user_stats(request.user),
'notifications': get_notifications(request.user),
'recent_activity': get_recent_activity(request.user),
}
return render(request, 'dashboard.html', context)
# Separate functions
def get_user_stats(user):
# Focused logic
return {...}
def get_notifications(user):
# Focused logic
return [...]
def get_recent_activity(user):
# Focused logic
return [...]
Rule of thumb: Views should:
- ✓ Handle request/response
- ✓ Call services
- ✓ Return template/JSON
- ✓ Stay under 50 lines
Views should NOT:
- ✗ Contain business logic
- ✗ Make direct external calls
- ✗ Have complex algorithms
- ✗ Be >100 lines
Smell #7: The Template Logic
What you see:
<!-- template.html -->
{% for post in posts %}
{% if post.published and post.author.is_active and not post.is_draft %}
{% if post.view_count > 100 %}
Popular: {{ post.title }}
{% else %}
{{ post.title }}
{% endif %}
{% endif %}
{% endfor %}
The smell: Complex logic in templates
Why it’s bad:
- Hard to test
- Slow template rendering
- Logic duplicated across templates
- Hard to maintain
How to fix:
# In view or model method
class Post(models.Model):
# ...
def is_visible(self):
return (
self.published and
self.author.is_active and
not self.is_draft
)
def is_popular(self):
return self.view_count > 100
# Or in view
def blog_list(request):
posts = Post.objects.filter(
published=True,
author__is_active=True,
is_draft=False
)
return render(request, 'blog.html', {'posts': posts})
<!-- template.html - Clean! -->
{% for post in posts %}
{% if post.is_popular %}
Popular: {{ post.title }}
{% else %}
{{ post.title }}
{% endif %}
{% endfor %}
Rule of thumb: Templates should:
- ✓ Display data
- ✓ Simple formatting
- ✓ Basic conditionals (show/hide)
Templates should NOT:
- ✗ Business logic
- ✗ Complex calculations
- ✗ Database queries
- ✗ Nested conditions (>2 levels)
Smell #8: The Missing CSRF
What you see:
@csrf_exempt # Danger!
def api_endpoint(request):
if request.method == 'POST':
# Process data
pass
The smell: @csrf_exempt decorator
Why it’s bad:
- CSRF vulnerability
- Easy to exploit
- Data can be manipulated
- Security audit failure
How to fix:
# Option 1: Use CSRF (default)
def api_endpoint(request):
# Django handles CSRF automatically
pass
# Option 2: If building API, use proper auth
from rest_framework.decorators import api_view
from rest_framework.permissions import IsAuthenticated
@api_view(['POST'])
@permission_classes([IsAuthenticated])
def api_endpoint(request):
# Token authentication, not CSRF
pass
When @csrf_exempt is acceptable:
- Public webhook endpoints (verify signature instead)
- API with token authentication (not cookie-based)
- Third-party callbacks
If you must exempt:
@csrf_exempt
def webhook(request):
# Verify signature instead
signature = request.META.get('HTTP_X_SIGNATURE')
if not verify_signature(request.body, signature):
return HttpResponse(status=403)
# Process webhook
Smell #9: The Silent Exception
What you see:
def process_payment(request):
try:
charge_card(request.user, amount)
send_confirmation_email(request.user)
update_inventory(product_id)
except Exception:
pass # Silent failure!
return HttpResponse("Success!") # Lies!
The smell: Empty except block
Why it’s bad:
- Errors hidden
- Bugs undetected
- False success messages
- Impossible to debug
- Data inconsistency
How to fix:
import logging
logger = logging.getLogger(__name__)
def process_payment(request):
try:
charge_card(request.user, amount)
send_confirmation_email(request.user)
update_inventory(product_id)
return HttpResponse("Success!")
except PaymentError as e:
logger.error(f"Payment failed: {e}", exc_info=True)
return HttpResponse("Payment failed", status=400)
except EmailError as e:
# Payment succeeded but email failed
logger.warning(f"Email failed: {e}", exc_info=True)
return HttpResponse("Success! (Email pending)")
except Exception as e:
# Unexpected error
logger.critical(f"Unexpected error: {e}", exc_info=True)
return HttpResponse("Error", status=500)
Rule of thumb:
- ✗ Never use bare
except: - ✗ Never use empty
exceptblock - ✓ Catch specific exceptions
- ✓ Log errors
- ✓ Return appropriate response
Smell #10: The Dirty Form
What you see:
def signup(request):
if request.method == 'POST':
username = request.POST.get('username')
password = request.POST.get('password')
email = request.POST.get('email')
# Manual validation
if not username or len(username) < 3:
return HttpResponse("Username too short")
if not password or len(password) < 8:
return HttpResponse("Password too short")
# ... 50 more lines of validation
User.objects.create_user(username, email, password)
The smell: Manual form validation instead of Django forms
Why it’s bad:
- Validation logic scattered
- Easy to miss edge cases
- No CSRF by default
- Hard to test
- Can’t reuse validation
- No error messages on fields
How to fix:
# forms.py
from django import forms
class SignupForm(forms.Form):
username = forms.CharField(min_length=3, max_length=30)
email = forms.EmailField()
password = forms.CharField(min_length=8, widget=forms.PasswordInput)
def clean_username(self):
username = self.cleaned_data['username']
if User.objects.filter(username=username).exists():
raise forms.ValidationError("Username taken")
return username
# views.py
def signup(request):
if request.method == 'POST':
form = SignupForm(request.POST)
if form.is_valid():
User.objects.create_user(
form.cleaned_data['username'],
form.cleaned_data['email'],
form.cleaned_data['password']
)
return redirect('home')
else:
form = SignupForm()
return render(request, 'signup.html', {'form': form})
Benefits:
- ✓ Validation centralized
- ✓ Reusable
- ✓ Testable
- ✓ CSRF protection
- ✓ Field-level error messages
Category 3: Architecture Smells
Smell #11: The Circular Import
What you see:
# models.py
from .utils import calculate_price # Import from utils
class Product(models.Model):
price = models.DecimalField(...)
# utils.py
from .models import Product # Import from models -> CIRCULAR IMPORT!
def calculate_price(product_id):
product = Product.objects.get(id=product_id)
return product.price * 0.9
The smell: Files importing each other
Why it’s bad:
# Import error
ImportError: cannot import name 'Product' from partially initialized module
How to fix:
# Option 1: Move function to model
class Product(models.Model):
def final_price(self):
# Logic here (no import needed)
pass
# Option 2: Late import
def calculate_price(product):
from .models import Product # Import inside function
# Use Product
pass
# Option 3: Move to separate service
# services/pricing.py (no model imports)
def calculate_price(price, discount):
return price * (1 - discount)
# models.py
from services.pricing import calculate_price
class Product(models.Model):
def final_price(self):
return calculate_price(self.price, self.discount)
Detection:
- Import errors on startup
- Tests failing mysteriously
Smell #12: The Settings Soup
What you see:
# settings.py - 800 lines
# Random order
# No comments
# Mixed concerns
SECRET_KEY = '...'
DEBUG = True
STRIPE_KEY = '...'
ALLOWED_HOSTS = []
EMAIL_HOST = '...'
CELERY_BROKER = '...'
CUSTOM_SETTING_1 = '...'
LOGGING = {...}
# ... 700 more lines
The smell: Unmaintainable settings file
Why it’s bad:
- Hard to find settings
- Hard to change
- Secrets mixed with config
- No environment separation
How to fix:
# settings/
# __init__.py
# base.py # Common settings
# development.py # Dev overrides
# production.py # Prod overrides
# test.py # Test overrides
# settings/base.py
import os
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent
# Security
SECRET_KEY = os.environ.get('SECRET_KEY')
# Application
INSTALLED_APPS = [...]
MIDDLEWARE = [...]
# Database
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': os.environ.get('DB_NAME'),
}
}
# settings/development.py
from .base import *
DEBUG = True
ALLOWED_HOSTS = ['localhost']
# settings/production.py
from .base import *
DEBUG = False
ALLOWED_HOSTS = [os.environ.get('DOMAIN')]
Benefits:
- ✓ Organized
- ✓ Environment-specific
- ✓ Secrets in environment
- ✓ Easy to maintain
Smell #13: The Magic Number
What you see:
# Scattered throughout code
if user.age > 18:
pass
if subscription_days < 30:
pass
if file_size > 5242880: # What is this?
pass
if response.status_code == 402: # Why 402?
pass
The smell: Unexplained numbers in code
Why it’s bad:
- Hard to understand
- Hard to change (scattered everywhere)
- Easy to make mistakes
- No context
How to fix:
# settings.py or constants.py
MINIMUM_AGE = 18
TRIAL_PERIOD_DAYS = 30
MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024 # 5 MB
PAYMENT_REQUIRED_STATUS = 402
# In code
if user.age > MINIMUM_AGE:
pass
if subscription_days < TRIAL_PERIOD_DAYS:
pass
if file_size > MAX_FILE_SIZE_BYTES:
pass
if response.status_code == PAYMENT_REQUIRED_STATUS:
pass
Or use Enums:
from enum import IntEnum
class SubscriptionStatus(IntEnum):
TRIAL = 1
ACTIVE = 2
CANCELLED = 3
EXPIRED = 4
# Usage
if user.subscription == SubscriptionStatus.TRIAL:
pass
Smell #14: The Global State
What you see:
# globals.py
current_user = None
current_request = None
# views.py
import globals
def view1(request):
globals.current_user = request.user
process_something()
def process_something():
# Uses global state
user = globals.current_user
The smell: Using global variables for state
Why it’s bad:
- Race conditions (multi-threading)
- Hard to test
- Implicit dependencies
- Unpredictable behavior
How to fix:
# Pass explicitly
def view1(request):
process_something(request.user)
def process_something(user):
# Explicit dependency
pass
# Or use thread-local storage (Django does this)
from threading import local
_thread_locals = local()
def set_current_user(user):
_thread_locals.user = user
def get_current_user():
return getattr(_thread_locals, 'user', None)
Rule: Avoid global state. Pass data explicitly.
Smell #15: The God Object
What you see:
class Utils:
@staticmethod
def send_email():
pass
@staticmethod
def calculate_tax():
pass
@staticmethod
def format_date():
pass
@staticmethod
def validate_phone():
pass
# 50 more unrelated methods
The smell: Class/module with unrelated functions
Why it’s bad:
- No cohesion
- Hard to find functions
- Grows indefinitely
- Tight coupling
How to fix:
# Separate by concern
# email_service.py
class EmailService:
@staticmethod
def send(to, subject, body):
pass
# tax_calculator.py
class TaxCalculator:
@staticmethod
def calculate(amount, rate):
pass
# formatters.py
class DateFormatter:
@staticmethod
def format(date, format_string):
pass
# validators.py
class PhoneValidator:
@staticmethod
def validate(phone):
pass
Rule: One class/module = One responsibility
Quick Smells (16–30)
Smell #16: The Print Debugger
print("Got here!") # ✗ Use logging
print(user) # ✗ Use debugger or logging
Smell #17: The Hardcoded Path
'/Users/john/projects/myapp/data.csv' # ✗
# Use: BASE_DIR / 'data.csv' ✓
Smell #18: The Timezone Naive
datetime.now() # ✗ Timezone naive
datetime.now(timezone.utc) # ✓ Timezone aware
Smell #19: The String Status
if order.status == "paid": # ✗ Typo risk
# Use: if order.status == Order.Status.PAID ✓
Smell #20: The Missing Migration
# Changed model but didn't run makemigrations
# ✗ Production breaks on deploy
# ✓ Always: python manage.py makemigrations
Smell #21: The Dead Code
def old_function():
# Not used anywhere
pass
# commented_out_code() # 50 lines commented
# ✗ Delete it (it's in git)
Smell #22: The Long Line
user = User.objects.filter(is_active=True, email__icontains=search, created_at__gte=start_date, created_at__lte=end_date, subscription_status='paid')
# ✗ 150+ characters
# ✓ Break into multiple lines
Smell #23: The Premature Optimization
# Before measuring
class ComplexCachingSystem:
# 200 lines of caching logic
# Used for 10 queries/day
# ✗ Overkill
Smell #24: The Secret Exposed
SECRET_KEY = 'django-insecure-hardcoded-key' # ✗
# ✓ os.environ.get('SECRET_KEY')
Smell #25: The Missing Test
# Critical payment processing
def charge_card(user, amount):
# 50 lines of logic
# No tests ✗
Smell #26: The Try-Catch All
try:
# Code
except: # ✗ Catches everything, even KeyboardInterrupt!
pass
# ✓ Be specific
except PaymentError:
pass
Smell #27: The Mutation Surprise
def add_discount(products):
for product in products:
product.price *= 0.9 # ✗ Mutates input!
return products
# ✓ Return new objects or be explicit
Smell #28: The Boolean Trap
send_email(user, True, False, True) # ✗ What do these mean?
# ✓ Named parameters
send_email(
user,
send_welcome=True,
send_notification=False,
use_template=True
)
Smell #29: The Nested Nightmare
if user:
if user.is_active:
if user.subscription:
if user.subscription.is_valid:
if user.subscription.plan == 'premium':
# 5 levels deep ✗
Fix with guards:
if not user:
return
if not user.is_active:
return
if not user.subscription or not user.subscription.is_valid:
return
if user.subscription.plan == 'premium':
# Clean! ✓
Smell #30: The Import *
from django.db.models import * # ✗
from utils import * # ✗ Where do functions come from?
# ✓ Explicit imports
from django.db.models import Model, CharField, ForeignKey
The Code Review Checklist
When reviewing code (or your own), check for:
Database (5 smells)
- Queries in loops (N+1)
- Missing indexes
- No cascade strategy
- Fat models
- Raw SQL when ORM works
Views (5 smells)
- God views (>100 lines)
- Logic in templates
- Missing CSRF
- Silent exceptions
- Manual form validation
Architecture (5 smells)
- Circular imports
- Settings chaos
- Magic numbers
- Global state
- God objects
Quick checks (15 smells)
- Print statements
- Hardcoded paths
- Timezone naive
- String statuses
- Missing migrations
- Dead code
- Long lines (>120 chars)
- Premature optimization
- Exposed secrets
- Missing tests
- Catch-all exceptions
- Mutation surprises
- Boolean traps
- Deep nesting (>3 levels)
- Import *
The Smell Severity Guide
🔴 Critical (Fix immediately)
- Missing CSRF
- Exposed secrets
- SQL injection risk
- Silent exceptions
🟠 High (Fix before merge)
- N+1 queries
- Missing indexes
- No cascade strategy
- God views/models
🟡 Medium (Fix when refactoring)
- Template logic
- Magic numbers
- Deep nesting
- Long lines
🟢 Low (Nice to have)
- Dead code
- Print statements
- Import *
Building Your Nose
Practice routine:
Week 1: Focus on 5 smells
- Pick 5 from list
- Review your code for them
- Note when you find them
Week 2: Add 5 more
- Previous 5 + 5 new
- Total: 10 smells
Week 3: Pattern recognition
- Review others’ code
- Spot smells instantly
- Explain why it’s a problem
Week 4: Automatic detection
- Smells become obvious
- Trust your intuition
- Prevent before writing
After 1 month: You’ll smell problems before they become bugs.
Conclusion
The truth: Most bugs announce themselves before they happen.
The 30 smells covered:
Database: N+1, indexes, cascade, fat models, raw SQL Views: God view, template logic, CSRF, exceptions, forms Architecture: Circular imports, settings, magic numbers, globals, god objects Quick: 15 common patterns
How to use this:
- Learn patterns — Recognize the smells
- Understand why — Know the problems
- Know fixes — Have solutions ready
- Build intuition — Practice daily
- Trust your nose — If it smells, investigate
Remember:
- Code that smells is trying to tell you something
- Listen to your instincts
- Refactor early, refactor often
- Prevention > cure
Start today:
- Review your latest code
- Find 3 smells
- Fix them
- Prevent them tomorrow
Your nose will sharpen with practice.
Thanks for reading! ❤
If this helped you, consider clapping (50 👏 s), following, or sharing it. A writer without readers is just talking to themselves — so your time means everything.
Let’s keep building better, together.
메타데이터
- post_id
- 87022c8332ec
- slug
- django-code-smells-the-30-patterns-that-signal-trouble-87022c8332ec
- url
- https://medium.com/@anas-issath/django-code-smells-the-30-patterns-that-signal-trouble-87022c8332ec
- canonical_url
- https://medium.com/@anas-issath/django-code-smells-the-30-patterns-that-signal-trouble-87022c8332ec
- author_url
- https://medium.com/@anas-issath
- status
- ok
- fetched_at
- 2026-07-29 21:15:00