CtrlK
BlogDocsLog inGet started
Tessl Logo

tessl-labs/django-best-practices

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

1.63x
Quality

87%

Does it follow best practices?

Impact

100%

1.63x

Average score across 5 eval scenarios

SecuritybySnyk

Passed

No known issues

Overview
Quality
Evals
Security
Files

SKILL.mdskills/django-best-practices/

name:
django-best-practices
description:
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, and migrations. Use when building or reviewing Django apps, setting up a new Django project, or choosing between Django patterns.
keywords:
django, django model, django view, django url, django middleware, django settings, django rest framework, drf, django api, django orm, django migration, django admin, django patterns, django best practices, custom user model, select_related, prefetch_related, n+1, django signals, django manager, management command, get_object_or_404, app_name
license:
MIT

Django Best Practices

Project structure, models, views, URL routing, query optimization, and configuration patterns for Django.


1. Custom User Model from Day One

IMPORTANT: Always define a custom user model before running the first migration

# 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 -- using auth.User directly

# 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.


2. Project Structure

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.html

Key rules

  • One Django app per feature domain. Don't put everything in one app.
  • Always create an accounts app with a custom user model before the first migrate.
  • Split settings into base.py, dev.py, prod.py -- never use a single settings.py for all environments.

3. Settings Splitting -- base/dev/prod

# 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 -- single settings.py with DEBUG toggled by env var

# 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.


4. Models -- Meta, Choices, Indexes

# 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.name

Model rules

  • TextChoices for enum fields -- validates at model level
  • auto_now_add / auto_now for timestamps -- never set these manually
  • db_index=True on fields used in filters and ordering
  • related_name on every ForeignKey for explicit reverse queries
  • on_delete explicit on every ForeignKey -- never rely on default
  • get_absolute_url() on models that have a detail view
  • Composite indexes via class Meta: indexes for multi-column queries
  • Reference users via settings.AUTH_USER_MODEL, never auth.User

IMPORTANT: Model Meta ordering causes ORDER BY on every query

# 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

5. Avoiding N+1 Queries -- select_related and prefetch_related

IMPORTANT: Always use select_related/prefetch_related when accessing related objects

# 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',
)

When to use which

  • select_related -- ForeignKey and OneToOneField (follows the join in SQL). Use for single-object relations.
  • prefetch_related -- ManyToManyField and reverse ForeignKey (separate query, join in Python). Use for multi-object relations.
  • Chain them -- use both together for queries that traverse both types of relations.

6. Custom Managers

# 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 posts

IMPORTANT: Always keep objects = 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!

7. Signals vs Overriding save()

Prefer overriding save() for single-model logic

# 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)

Use signals only for cross-app decoupling

# 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 -- using signals for same-model logic

# 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.


8. URL Configuration with app_name

IMPORTANT: Always set app_name for URL namespacing

# 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 -- URLs without app_name

# 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'
]

9. Views -- get_object_or_404 and Class-Based vs Function-Based

Always use get_object_or_404 for detail views

# 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)

Class-based views for standard CRUD

# 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'
        )

Function-based views for custom logic

# 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:

  • CBV (ListView, DetailView, CreateView, UpdateView) -- standard CRUD operations. Less code, built-in pagination, consistent patterns.
  • FBV -- complex flows with branching logic, multi-step forms, or non-standard HTTP behavior. More explicit, easier to follow.

10. Middleware Ordering

IMPORTANT: Middleware order matters -- Django processes them top-down on request, bottom-up on response

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 -- auth before sessions

# WRONG: AuthenticationMiddleware before SessionMiddleware -- request.user won't work
MIDDLEWARE = [
    'django.contrib.auth.middleware.AuthenticationMiddleware',  # Needs sessions!
    'django.contrib.sessions.middleware.SessionMiddleware',
]

11. Migrations

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>  # Squash

Always 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.


12. Custom Management Commands

# 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.


Checklist

  • Custom user model (AbstractUser) defined before first migration
  • AUTH_USER_MODEL set in settings
  • All ForeignKey to user uses settings.AUTH_USER_MODEL, not auth.User
  • Settings split into base.py, dev.py, prod.py
  • DEBUG=False in production settings
  • One app per feature domain
  • TextChoices for enum fields
  • auto_now_add / auto_now for timestamps
  • db_index=True on filtered/sorted fields
  • related_name on every ForeignKey
  • on_delete explicit on every ForeignKey
  • select_related for ForeignKey traversal in querysets
  • prefetch_related for ManyToMany and reverse FK traversal
  • Default objects manager preserved when adding custom managers
  • save() override for same-model logic; signals for cross-app side effects
  • app_name set in every app's urls.py
  • Namespaced URL reversal ('app:name') in templates and views
  • get_object_or_404 for single-object lookups in views
  • get_absolute_url() on models with detail views
  • Middleware in correct order (SecurityMiddleware first, sessions before auth)
  • Migrations committed to git
  • Management commands use self.stdout.write, not print

References

  • Django Custom User Model documentation -- official guide, recommends doing it at project start
  • Django select_related documentation -- SQL JOIN for FK/O2O
  • Django prefetch_related documentation -- separate query for M2M/reverse FK
  • Django URL namespaces -- app_name and namespaced reversal
  • Django middleware ordering -- official ordering guide
  • Django signals vs save() -- use signals for decoupling, save() for model logic

Verifiers

  • django-structure -- App-per-feature project structure with custom user model
  • django-models -- Model design: choices, indexes, related_name, select_related
  • django-views-urls -- URL namespacing, get_object_or_404, CBV/FBV patterns
  • django-settings-middleware -- Settings splitting, middleware ordering, security
  • django-signals-managers -- Custom managers, signals vs save(), management commands

skills

django-best-practices

tile.json