Project layout and the ORM

How a Django project is structured, how models become database tables, and how to query them without generating an N+1 storm.

Layout and settings

pip install "django~=5.1"
django-admin startproject config .      # the trailing dot avoids a nested folder
python manage.py startapp blog

python manage.py migrate                # built-in tables (auth, sessions, admin)
python manage.py runserver              # http://127.0.0.1:8000
mysite/
  manage.py
  config/
    settings.py
    urls.py
    wsgi.py
    asgi.py
  blog/
    models.py
    views.py
    urls.py
    admin.py
    forms.py
    migrations/
  templates/
  static/
  • project (config/) holds global configuration; apps (blog/) are self-contained features.
  • A new app must be added to INSTALLED_APPS before its models, templates and admin are picked up.
  • Everything project-specific goes in the app folder — this is what makes an app reusable in another project.

Models

# blog/models.py
from django.conf import settings
from django.db import models
from django.utils import timezone

class Category(models.Model):
    name = models.CharField(max_length=60, unique=True)
    slug = models.SlugField(max_length=60, unique=True)

    class Meta:
        verbose_name_plural = "categories"
        ordering = ["name"]

    def __str__(self):
        return self.name

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=True)
    body = models.TextField(blank=True)
    status = models.CharField(max_length=10, choices=Status.choices,
                              default=Status.DRAFT)
    category = models.ForeignKey(Category, on_delete=models.PROTECT,
                                 related_name="posts", null=True, blank=True)
    author = models.ForeignKey(settings.AUTH_USER_MODEL,
                               on_delete=models.CASCADE, related_name="posts")
    published_at = models.DateTimeField(null=True, blank=True)
    created_at = models.DateTimeField(auto_now_add=True)

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

    def __str__(self):
        return self.title

    def publish(self):
        self.status = self.Status.PUBLISHED
        self.published_at = timezone.now()
        self.save(update_fields=["status", "published_at"])
python manage.py makemigrations blog   # writes a migration file, review it
python manage.py migrate              # applies it
python manage.py shell
FieldMaps toNotes
CharField(max_length=)varcharmax_length is mandatory
TextFieldtextUnbounded text
ForeignKey(...)int + index + FKon_delete is required
ManyToManyFieldJoin tableCreated automatically
DecimalFieldnumericUse for money, never FloatField
DateTimeField(auto_now_add=True)timestampSet once on insert

Querying

from django.db.models import Count, Q, Prefetch
from blog.models import Post, Category

# lazily evaluated: no SQL runs until you iterate
qs = Post.objects.filter(status=Post.Status.PUBLISHED)

Post.objects.filter(title__icontains="django")[:10]
Post.objects.filter(Q(author__username="ana") | Q(category__slug="news"))
Post.objects.exclude(status="draft").order_by("-published_at")[:20]
Post.objects.values_list("id", "title")            # tuples, no model instances

# aggregate on the database, not in Python
Category.objects.annotate(n=Count("posts")).filter(n__gt=5)

# fix N+1: one query for posts + one for their authors and categories
published = (Post.objects
             .select_related("author", "category")
             .prefetch_related(Prefetch("category__posts"))
             .filter(status="published"))

post = Post.objects.get(slug="hello")   # raises Post.DoesNotExist if absent
post = Post.objects.filter(slug="hello").first()   # returns None instead
⚠️
Accessing post.author.username inside a loop that iterates the queryset issues one extra query per row — the classic N+1. Add select_related for forward foreign keys and one-to-one, prefetch_related for many-to-many and reverse relations, and check the count with connection.queries or the debug toolbar.

FAQ

Should I edit a migration that has already been applied?
No. Generate a new migration for each change so every environment applies the same ordered set. Squash only when the migration history becomes unwieldy, and do it before a release, not during one.
What does on_delete=PROTECT do?
It blocks deleting a parent row while children reference it, raising ProtectedError. Use CASCADE for data owned by the parent and SET_NULL for optional links.

Views and URLs SELECT: reading data

Last refreshed 2026-09-18.