django

SKILL.md

Django Development (2025)

Project Structure

project_name/
├── config/                 # Project config (rename from project_name/)
│   ├── settings/
│   │   ├── __init__.py
│   │   ├── base.py
│   │   ├── dev.py
│   │   └── prod.py
│   ├── urls.py
│   ├── wsgi.py
│   └── asgi.py             # Required for async
├── apps/
│   ├── __init__.py
│   └── core/               # Shared utilities, base models
├── templates/
├── static/
├── manage.py
├── pyproject.toml          # Modern Python packaging
└── requirements/
    ├── base.txt
    ├── dev.txt
    └── prod.txt

Environment & Settings

# config/settings/base.py
import environ

env = environ.Env(
    DEBUG=(bool, False),
)
environ.Env.read_env()

SECRET_KEY = env("SECRET_KEY")
DEBUG = env("DEBUG")
DATABASES = {"default": env.db()}
# .env
SECRET_KEY=your-secret-key
DEBUG=True
DATABASE_URL=postgres://user:pass@localhost:5432/dbname

Naming Conventions

Component Convention Example
App singular, lowercase blog, user_profile
Model singular PascalCase Article, UserProfile
View (function) noun_action article_detail
View (class) NounActionView ArticleDetailView
URL name app:noun-action blog:article-detail
Template app/noun_action.html blog/article_detail.html

Models

from django.db import models
from django.urls import reverse


class TimestampedModel(models.Model):
    """Abstract base for created/updated timestamps."""
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        abstract = True


class Article(TimestampedModel):
    class Status(models.TextChoices):
        DRAFT = "draft", "Draft"
        PUBLISHED = "published", "Published"

    title = models.CharField(max_length=200)
    slug = models.SlugField(max_length=200, unique=True)
    author = models.ForeignKey(
        "auth.User",
        on_delete=models.CASCADE,
        related_name="articles",
    )
    status = models.CharField(
        max_length=20,
        choices=Status.choices,
        default=Status.DRAFT,
        db_index=True,
    )

    class Meta:
        ordering = ["-created_at"]
        indexes = [
            models.Index(fields=["status", "created_at"]),
        ]

    def __str__(self) -> str:
        return self.title

    def get_absolute_url(self) -> str:
        return reverse("blog:article-detail", kwargs={"slug": self.slug})

Key patterns:

  • Use TextChoices / IntegerChoices for choices (not tuples)
  • Type hints on methods
  • Always set related_name, db_index on filtered fields

Views

Sync Views (Standard)

from django.views.generic import ListView, DetailView, CreateView
from django.contrib.auth.mixins import LoginRequiredMixin
from django.db.models import Q


class ArticleListView(ListView):
    model = Article
    template_name = "blog/article_list.html"
    context_object_name = "articles"
    paginate_by = 20

    def get_queryset(self):
        qs = super().get_queryset().select_related("author")
        if q := self.request.GET.get("q"):
            qs = qs.filter(Q(title__icontains=q) | Q(body__icontains=q))
        return qs

Async Views

Use async for I/O-bound operations (external APIs, file ops). Requires ASGI server.

import httpx
from django.http import JsonResponse
from asgiref.sync import sync_to_async


async def weather_view(request):
    """Async view calling external API."""
    city = request.GET.get("city", "London")
    async with httpx.AsyncClient() as client:
        response = await client.get(f"https://api.weather.com/{city}")
    return JsonResponse(response.json())


async def article_list_async(request):
    """Async view with ORM (requires sync_to_async wrapper)."""
    articles = await sync_to_async(list)(
        Article.objects.select_related("author")[:20]
    )
    return JsonResponse({"articles": [a.title for a in articles]})

When to use async views:

  • External HTTP calls → use httpx (async) not requests
  • Multiple concurrent I/O operations
  • High-concurrency endpoints

Note: Django ORM is not fully async. Wrap ORM calls with sync_to_async().

URLs

# apps/blog/urls.py
from django.urls import path
from . import views

app_name = "blog"

urlpatterns = [
    path("", views.ArticleListView.as_view(), name="article-list"),
    path("<slug:slug>/", views.ArticleDetailView.as_view(), name="article-detail"),
]

# config/urls.py
urlpatterns = [
    path("admin/", admin.site.urls),
    path("blog/", include("apps.blog.urls")),
]

Forms

from django import forms
from .models import Article


class ArticleForm(forms.ModelForm):
    class Meta:
        model = Article
        fields = ["title", "body", "status"]
        widgets = {
            "body": forms.Textarea(attrs={"rows": 10}),
        }

    def clean_title(self) -> str:
        title = self.cleaned_data["title"]
        if len(title) < 5:
            raise forms.ValidationError("Title must be at least 5 characters.")
        return title

Templates

templates/
├── base.html
├── includes/
│   ├── _pagination.html
│   └── _messages.html
└── blog/
    ├── article_list.html
    └── article_detail.html

Prefix partials with underscore. Use {% url %} not hardcoded paths:

{% extends "base.html" %}

{% block content %}
<a href="{% url 'blog:article-detail' slug=article.slug %}">
  {{ article.title }}
</a>
{% endblock %}

Type Hints

Add type hints throughout for mypy / IDE support:

from django.http import HttpRequest, HttpResponse
from django.db.models import QuerySet


def article_list(request: HttpRequest) -> HttpResponse:
    articles: QuerySet[Article] = Article.objects.filter(status="published")
    return render(request, "blog/article_list.html", {"articles": articles})
# pyproject.toml
[tool.mypy]
plugins = ["mypy_django_plugin.main"]
django_settings_module = "config.settings.dev"

# Run
mypy apps/

Running with ASGI (for async)

# Install
pip install uvicorn

# Development
uvicorn config.asgi:application --reload

# Production
gunicorn config.asgi:application -k uvicorn.workers.UvicornWorker -w 4

Testing

import pytest
from django.test import Client
from django.urls import reverse


@pytest.mark.django_db
def test_article_list_view(client: Client):
    response = client.get(reverse("blog:article-list"))
    assert response.status_code == 200
    assert "articles" in response.context


@pytest.mark.django_db
async def test_async_view():
    """Async test for async views."""
    from django.test import AsyncClient
    client = AsyncClient()
    response = await client.get("/api/weather/?city=Paris")
    assert response.status_code == 200

Use pytest-django + factory_boy for ergonomic testing.

Common Pitfalls

  1. N+1 queries: Use select_related (FK) and prefetch_related (M2M). Check with django-debug-toolbar.

  2. Sync calls in async views: Wrap ORM with sync_to_async(). Use httpx not requests.

  3. Missing migrations: Run makemigrations after model changes. Commit migrations.

  4. Hardcoded URLs: Use {% url %} in templates, reverse() in Python.

  5. No indexes: Add db_index=True or Meta.indexes for filtered/ordered fields.

  6. Fat views: Move business logic to model methods or a service layer.

Modern Tooling

# pyproject.toml dev dependencies
django-debug-toolbar    # Query debugging
django-extensions       # shell_plus, show_urls
django-environ          # Environment variables
pytest-django           # Testing
factory-boy             # Test fixtures
mypy + django-stubs     # Type checking
ruff                    # Linting (replaces flake8/isort/black)

Management Commands

Keep commands thin — delegate logic to services.

Structure

apps/blog/
├── management/
│   └── commands/
│       └── publish_scheduled.py
└── services/
    └── publishing.py

Service Layer

# apps/blog/services/publishing.py
from dataclasses import dataclass
from django.utils import timezone
from apps.blog.models import Article


@dataclass
class PublishResult:
    published_count: int
    failed_ids: list[int]


def publish_scheduled_articles(dry_run: bool = False) -> PublishResult:
    """
    Publish all articles scheduled for now or earlier.
    
    Business logic lives here — testable without command scaffolding.
    """
    now = timezone.now()
    articles = Article.objects.filter(
        status=Article.Status.SCHEDULED,
        publish_at__lte=now,
    )
    
    if dry_run:
        return PublishResult(published_count=articles.count(), failed_ids=[])
    
    published = 0
    failed = []
    
    for article in articles:
        try:
            article.status = Article.Status.PUBLISHED
            article.save(update_fields=["status", "updated_at"])
            published += 1
        except Exception:
            failed.append(article.id)
    
    return PublishResult(published_count=published, failed_ids=failed)

Command (Thin Wrapper)

# apps/blog/management/commands/publish_scheduled.py
from django.core.management.base import BaseCommand, CommandError
from apps.blog.services.publishing import publish_scheduled_articles


class Command(BaseCommand):
    help = "Publish articles that are scheduled for now or earlier"

    def add_arguments(self, parser):
        parser.add_argument(
            "--dry-run",
            action="store_true",
            help="Show what would be published without making changes",
        )

    def handle(self, *args, **options):
        dry_run = options["dry_run"]
        
        if dry_run:
            self.stdout.write(self.style.WARNING("DRY RUN — no changes will be made"))
        
        result = publish_scheduled_articles(dry_run=dry_run)
        
        if result.failed_ids:
            self.stderr.write(
                self.style.ERROR(f"Failed to publish: {result.failed_ids}")
            )
        
        self.stdout.write(
            self.style.SUCCESS(f"Published {result.published_count} articles")
        )

Testing

# apps/blog/tests/test_services.py
import pytest
from django.utils import timezone
from apps.blog.services.publishing import publish_scheduled_articles
from apps.blog.models import Article


@pytest.mark.django_db
def test_publish_scheduled_articles(article_factory):
    # Create scheduled article in the past
    article = article_factory(
        status=Article.Status.SCHEDULED,
        publish_at=timezone.now() - timezone.timedelta(hours=1),
    )
    
    result = publish_scheduled_articles()
    
    assert result.published_count == 1
    article.refresh_from_db()
    assert article.status == Article.Status.PUBLISHED


@pytest.mark.django_db
def test_publish_dry_run_no_changes(article_factory):
    article = article_factory(
        status=Article.Status.SCHEDULED,
        publish_at=timezone.now() - timezone.timedelta(hours=1),
    )
    
    result = publish_scheduled_articles(dry_run=True)
    
    assert result.published_count == 1
    article.refresh_from_db()
    assert article.status == Article.Status.SCHEDULED  # Unchanged

Command Guidelines

Layer Responsibility
Command Parse args, call service, format output, exit codes
Service Business logic, DB operations, return typed results

Command should NOT:

  • Contain business logic
  • Query the database directly (beyond simple lookups)
  • Be the only way to run the logic

Benefits:

  • Services are reusable (views, tasks, other commands)
  • Services are testable without management command overhead
  • Commands stay focused on CLI concerns

When to Use Separate Skills

  • django-api: For REST APIs with Django Ninja or DRF
  • django-admin: For complex admin customizations
Weekly Installs
1
GitHub Stars
8
First Seen
6 days ago
Installed on
zencoder1
amp1
cline1
openclaw1
opencode1
cursor1