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:8000mysite/
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_APPSbefore 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| Field | Maps to | Notes |
|---|---|---|
CharField(max_length=) | varchar | max_length is mandatory |
TextField | text | Unbounded text |
ForeignKey(...) | int + index + FK | on_delete is required |
ManyToManyField | Join table | Created automatically |
DecimalField | numeric | Use for money, never FloatField |
DateTimeField(auto_now_add=True) | timestamp | Set 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.Related
Views and URLs SELECT: reading data
Last refreshed 2026-09-18.