Django patterns -- custom user model, project structure, models, views, URL routing, select_related/prefetch_related, signals vs save(), middleware, settings splitting, custom managers, management commands
92
87%
Does it follow best practices?
Impact
100%
1.63xAverage score across 5 eval scenarios
Passed
No known issues
Project structure, models, views, URL routing, query optimization, and configuration patterns for Django.
# accounts/models.py
from django.contrib.auth.models import AbstractUser
from django.db import models
class User(AbstractUser):
"""Custom user model -- extend later without migration pain."""
pass# config/settings.py
AUTH_USER_MODEL = 'accounts.User'# In ANY model that references a user, ALWAYS use settings.AUTH_USER_MODEL
from django.conf import settings
class Order(models.Model):
customer = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='orders',
)# WRONG: Hardcodes the user model -- breaks when you add a custom user later
from django.contrib.auth.models import User
class Order(models.Model):
customer = models.ForeignKey(User, on_delete=models.CASCADE)Swapping the user model after migrations exist requires wiping the database. Always start with a custom user model, even if it's empty. Reference it via settings.AUTH_USER_MODEL in ForeignKey fields and get_user_model() in code that operates on user instances.
project/
├── config/ # Project-level settings (renamed from default)
│ ├── __init__.py
│ ├── settings/
│ │ ├── __init__.py # Imports from base
│ │ ├── base.py # Shared settings
│ │ ├── dev.py # DEBUG=True, sqlite, django-debug-toolbar
│ │ └── prod.py # DEBUG=False, postgres, security headers
│ ├── urls.py
│ └── wsgi.py
├── accounts/ # Custom user model app -- ALWAYS create this first
│ ├── models.py # Custom User(AbstractUser)
│ ├── admin.py
│ └── ...
├── blog/ # App per feature domain
│ ├── models.py
│ ├── views.py
│ ├── urls.py
│ ├── forms.py
│ ├── admin.py
│ ├── managers.py # Custom managers
│ └── tests/
│ ├── __init__.py
│ ├── test_models.py
│ └── test_views.py
├── manage.py
├── requirements/
│ ├── base.txt
│ ├── dev.txt # -r base.txt + debug-toolbar, etc.
│ └── prod.txt # -r base.txt + gunicorn, psycopg2, etc.
└── templates/
└── base.htmlaccounts app with a custom user model before the first migrate.base.py, dev.py, prod.py -- never use a single settings.py for all environments.# config/settings/base.py
from pathlib import Path
import os
BASE_DIR = Path(__file__).resolve().parent.parent.parent
SECRET_KEY = os.environ.get('SECRET_KEY')
if not SECRET_KEY:
from django.core.exceptions import ImproperlyConfigured
raise ImproperlyConfigured('SECRET_KEY environment variable is required')
AUTH_USER_MODEL = 'accounts.User'
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
# Local apps
'accounts',
'blog',
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'db.sqlite3',
}
}# config/settings/dev.py
from .base import * # noqa: F401,F403
DEBUG = True
ALLOWED_HOSTS = ['localhost', '127.0.0.1']
INSTALLED_APPS += ['debug_toolbar']
MIDDLEWARE.insert(0, 'debug_toolbar.middleware.DebugToolbarMiddleware')
INTERNAL_IPS = ['127.0.0.1']# config/settings/prod.py
from .base import * # noqa: F401,F403
DEBUG = False
ALLOWED_HOSTS = os.environ.get('ALLOWED_HOSTS', '').split(',')
# Security headers
SECURE_BROWSER_XSS_FILTER = True
SECURE_CONTENT_TYPE_NOSNIFF = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SECURE_SSL_REDIRECT = True# WRONG: Everything in one file -- dev tools leak into production
DEBUG = os.environ.get('DEBUG', 'True').lower() == 'true'
if DEBUG:
INSTALLED_APPS += ['debug_toolbar']Use DJANGO_SETTINGS_MODULE=config.settings.dev for local development and config.settings.prod for production.
# blog/models.py
from django.conf import settings
from django.db import models
from django.urls import reverse
class Post(models.Model):
class Status(models.TextChoices):
DRAFT = 'draft', 'Draft'
PUBLISHED = 'published', 'Published'
title = models.CharField(max_length=200)
slug = models.SlugField(max_length=200, unique_for_date='published_date')
author = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='posts',
)
body = models.TextField()
status = models.CharField(
max_length=10,
choices=Status.choices,
default=Status.DRAFT,
db_index=True,
)
published_date = models.DateTimeField(null=True, blank=True, db_index=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
tags = models.ManyToManyField('Tag', blank=True, related_name='posts')
class Meta:
ordering = ['-published_date']
indexes = [
models.Index(fields=['status', 'published_date']),
]
def __str__(self):
return self.title
def get_absolute_url(self):
return reverse('blog:post_detail', kwargs={'slug': self.slug})
class Comment(models.Model):
post = models.ForeignKey(Post, on_delete=models.CASCADE, related_name='comments')
author = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='comments',
)
body = models.TextField()
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['created_at']
def __str__(self):
return f'Comment by {self.author} on {self.post}'
class Tag(models.Model):
name = models.CharField(max_length=50, unique=True)
slug = models.SlugField(max_length=50, unique=True)
def __str__(self):
return self.nameTextChoices for enum fields -- validates at model levelauto_now_add / auto_now for timestamps -- never set these manuallydb_index=True on fields used in filters and orderingrelated_name on every ForeignKey for explicit reverse querieson_delete explicit on every ForeignKey -- never rely on defaultget_absolute_url() on models that have a detail viewclass Meta: indexes for multi-column queriessettings.AUTH_USER_MODEL, never auth.User# WRONG: ordering in Meta applies to ALL queries, even when you don't need it
class Meta:
ordering = ['-created_at']
# This adds ORDER BY to every query, including count() and exists()
Post.objects.filter(status='published').count() # Unnecessary ORDER BY# RIGHT: Use ordering in Meta only if you genuinely want it everywhere.
# For views that need specific ordering, use .order_by() explicitly:
Post.objects.filter(status='published').order_by('-published_date')
# To remove Meta ordering on a specific query:
Post.objects.filter(status='published').order_by() # No ORDER BY# WRONG: N+1 queries -- each post.author triggers a separate query
posts = Post.objects.filter(status='published')
for post in posts:
print(post.author.username) # N additional queries
print(post.comments.count()) # N more queries# RIGHT: select_related for ForeignKey/OneToOne (SQL JOIN)
posts = Post.objects.filter(status='published').select_related('author')
for post in posts:
print(post.author.username) # No additional query -- joined
# RIGHT: prefetch_related for ManyToMany and reverse ForeignKey (separate query + Python join)
posts = Post.objects.filter(status='published').prefetch_related('comments', 'tags')
for post in posts:
print(post.comments.all()) # No additional query -- prefetched
# RIGHT: Combine both for deep relations
posts = Post.objects.filter(
status='published'
).select_related(
'author'
).prefetch_related(
'comments__author', # Prefetch comments AND their authors
'tags',
)# blog/managers.py
from django.db import models
class PublishedManager(models.Manager):
def get_queryset(self):
return super().get_queryset().filter(status='published')
# blog/models.py
class Post(models.Model):
# ... fields ...
objects = models.Manager() # Default manager -- keep it
published = PublishedManager() # Custom manager for published posts
# Usage:
Post.published.all() # Only published posts
Post.objects.all() # All postsobjects = models.Manager() when adding custom managers# WRONG: Replacing the default manager breaks admin, migrations, and shell
class Post(models.Model):
objects = PublishedManager() # Now Post.objects.all() only returns published!# RIGHT: Override save() for logic that belongs to the model itself
class Post(models.Model):
title = models.CharField(max_length=200)
slug = models.SlugField(max_length=200)
def save(self, *args, **kwargs):
if not self.slug:
from django.utils.text import slugify
self.slug = slugify(self.title)
super().save(*args, **kwargs)# RIGHT: Signal for cross-app side effects -- blog app shouldn't import notifications
# notifications/signals.py
from django.db.models.signals import post_save
from django.dispatch import receiver
from blog.models import Post
@receiver(post_save, sender=Post)
def notify_subscribers_on_publish(sender, instance, created, **kwargs):
if instance.status == 'published':
# Send notifications -- this logic belongs to the notifications app
pass# WRONG: Signal for logic that belongs in the model -- hard to trace, easy to miss
@receiver(pre_save, sender=Post)
def auto_slug(sender, instance, **kwargs):
if not instance.slug:
instance.slug = slugify(instance.title)Rule of thumb: If the logic is about the model itself (auto-slug, computed fields, validation), override save(). If the logic is a side effect in a different app (send email, update cache, log activity), use a signal.
# blog/urls.py
from django.urls import path
from . import views
app_name = 'blog' # REQUIRED for namespaced URL reversal
urlpatterns = [
path('', views.PostListView.as_view(), name='post_list'),
path('<slug:slug>/', views.PostDetailView.as_view(), name='post_detail'),
path('<slug:slug>/comment/', views.add_comment, name='add_comment'),
]# config/urls.py
from django.contrib import admin
from django.urls import path, include
urlpatterns = [
path('admin/', admin.site.urls),
path('blog/', include('blog.urls')), # namespace comes from app_name
path('accounts/', include('accounts.urls')),
]# In templates:
# {% url 'blog:post_detail' slug=post.slug %}
# In views:
from django.urls import reverse
url = reverse('blog:post_detail', kwargs={'slug': post.slug})
# In models:
def get_absolute_url(self):
return reverse('blog:post_detail', kwargs={'slug': self.slug})# WRONG: No app_name -- name collisions between apps, can't use namespaced reversal
urlpatterns = [
path('', views.post_list, name='post_list'), # Conflicts if another app has 'post_list'
]# WRONG: Manual try/except returns 500 on missing objects if you forget the try
post = Post.objects.get(slug=slug)
# RIGHT: Returns 404 automatically
from django.shortcuts import get_object_or_404
post = get_object_or_404(Post, slug=slug, status=Post.Status.PUBLISHED)# blog/views.py
from django.views.generic import ListView, DetailView, CreateView
from django.contrib.auth.mixins import LoginRequiredMixin
class PostListView(ListView):
model = Post
template_name = 'blog/post_list.html'
context_object_name = 'posts'
paginate_by = 10
def get_queryset(self):
return Post.published.select_related('author').prefetch_related('tags')
class PostDetailView(DetailView):
model = Post
template_name = 'blog/post_detail.html'
context_object_name = 'post'
def get_queryset(self):
return Post.published.select_related('author').prefetch_related(
'comments__author', 'tags'
)# RIGHT: FBV for non-standard flows where CBV would require overriding many methods
from django.shortcuts import render, redirect, get_object_or_404
from django.contrib.auth.decorators import login_required
@login_required
def add_comment(request, slug):
post = get_object_or_404(Post, slug=slug, status=Post.Status.PUBLISHED)
if request.method == 'POST':
form = CommentForm(request.POST)
if form.is_valid():
comment = form.save(commit=False)
comment.post = post
comment.author = request.user
comment.save()
return redirect(post.get_absolute_url())
else:
form = CommentForm()
return render(request, 'blog/add_comment.html', {'post': post, 'form': form})When to use which:
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware', # 1. Security headers first
'django.contrib.sessions.middleware.SessionMiddleware', # 2. Sessions before auth
'django.middleware.common.CommonMiddleware', # 3. URL normalization
'django.middleware.csrf.CsrfViewMiddleware', # 4. CSRF before auth
'django.contrib.auth.middleware.AuthenticationMiddleware', # 5. Auth requires sessions
'django.contrib.messages.middleware.MessageMiddleware', # 6. Messages require sessions
'django.middleware.clickjacking.XFrameOptionsMiddleware', # 7. Security headers
]# WRONG: AuthenticationMiddleware before SessionMiddleware -- request.user won't work
MIDDLEWARE = [
'django.contrib.auth.middleware.AuthenticationMiddleware', # Needs sessions!
'django.contrib.sessions.middleware.SessionMiddleware',
]python manage.py makemigrations # Generate from model changes
python manage.py migrate # Apply
python manage.py showmigrations # Check status
python manage.py squashmigrations <app_label> <start_migration> <end_migration> # SquashAlways commit migrations to git. Never manually edit migration files unless you know exactly what you're doing.
Squash migrations when an app has accumulated many small migrations. This reduces migration count without changing the database schema.
# blog/management/commands/publish_scheduled_posts.py
from django.core.management.base import BaseCommand
from django.utils import timezone
from blog.models import Post
class Command(BaseCommand):
help = 'Publish posts that have a scheduled publish date in the past'
def add_arguments(self, parser):
parser.add_argument('--dry-run', action='store_true', help='Show what would be published')
def handle(self, *args, **options):
now = timezone.now()
posts = Post.objects.filter(
status=Post.Status.DRAFT,
published_date__lte=now,
)
count = posts.count()
if options['dry_run']:
self.stdout.write(f'Would publish {count} posts')
return
posts.update(status=Post.Status.PUBLISHED)
self.stdout.write(self.style.SUCCESS(f'Published {count} posts'))Usage: python manage.py publish_scheduled_posts --dry-run
Place commands in <app>/management/commands/<command_name>.py. Always include help text and use self.stdout.write (not print) for output.
AbstractUser) defined before first migrationAUTH_USER_MODEL set in settingssettings.AUTH_USER_MODEL, not auth.Userbase.py, dev.py, prod.pyDEBUG=False in production settingsTextChoices for enum fieldsauto_now_add / auto_now for timestampsdb_index=True on filtered/sorted fieldsrelated_name on every ForeignKeyon_delete explicit on every ForeignKeyselect_related for ForeignKey traversal in querysetsprefetch_related for ManyToMany and reverse FK traversalobjects manager preserved when adding custom managerssave() override for same-model logic; signals for cross-app side effectsapp_name set in every app's urls.py'app:name') in templates and viewsget_object_or_404 for single-object lookups in viewsget_absolute_url() on models with detail viewsself.stdout.write, not print