PYTHON / CAPSTONE PROJECTS
Project: a Django app backed by a real database
Build a Django app whose models define the schema: create the tables, query with the ORM, and keep data integrity in the database.
What you will learn
- Use makemigrations then migrate to turn model edits into real schema changes
- Declare unique and foreign-key constraints on the model so the database enforces them
- Spot N+1 query patterns and fix them with select_related or prefetch_related
- Wrap multi-row writes in transaction.atomic so a failure leaves no partial rows
Understanding Project: a Django app backed by a real database
Everything in a Django app radiates from the model class. A single line like name = CharField(max_length=100) is at once a Python attribute, a varchar(100) NOT NULL column, and something the query compiler can filter on. Migrations are the bridge: makemigrations diffs your models against the migration files already on disk and writes an ordered list of operations, and migrate replays the unapplied ones, recording each name in the django_migrations table. The consequence worth internalising is that Django trusts those files, not the live tables — it does not introspect your database to work out what changed.
A queryset is a lazily built SQL statement, not a list. Nothing reaches the database until you iterate, slice, or call something like count(), which is why chaining filter() is cheap. Each double-underscore step is a traversal: books__pages compiles into a join, whereas reading book.author on an already-loaded object fires a fresh SELECT unless you asked for select_related('author') up front. One join versus one query per row is the N+1 problem, and it is the biggest performance trap in ORM code.
Constraints belong in the database, not in your view functions. unique=True, on_delete=CASCADE and null=False are compiled into the schema, so two concurrent requests or a buggy management command still cannot create duplicates; you get an IntegrityError instead. Group related writes inside transaction.atomic() so a failure halfway through leaves the table exactly as it was. The scripts below configure Django in one file with settings.configure() and build tables with the schema editor so they run standalone; a real project keeps settings.py, migration files, and runs manage.py migrate.
import django
from django.conf import settings
settings.configure(
INSTALLED_APPS=[],
DATABASES={'default': {'ENGINE': 'django.db.backends.sqlite3', 'NAME': ':memory:'}},
USE_TZ=True,
)
django.setup()
from django.db import connection, models
from django.db.models import Avg, Count
class Author(models.Model):
name = models.CharField(max_length=100, unique=True)
class Meta:
app_label = 'catalog'
class Book(models.Model):
title = models.CharField(max_length=200)
author = models.ForeignKey(Author, on_delete=models.CASCADE, related_name='books')
year = models.PositiveIntegerField()
pages = models.PositiveIntegerField()
class Meta:
app_label = 'catalog'
ordering = ['year']
# 'manage.py migrate' does this in a real project; here we build the tables directly.
with connection.schema_editor() as schema:
schema.create_model(Author)
schema.create_model(Book)
le_guin = Author.objects.create(name='Le Guin')
lem = Author.objects.create(name='Lem')
Book.objects.bulk_create([
Book(title='A Wizard of Earthsea', author=le_guin, year=1968, pages=183),
Book(title='The Dispossessed', author=le_guin, year=1974, pages=341),
Book(title='Solaris', author=lem, year=1961, pages=204),
])
print('rows:', Book.objects.count())
for book in Book.objects.filter(pages__gt=200).select_related('author'):
print(book.year, book.title, '-', book.author.name)
for author in Author.objects.annotate(titles=Count('books'), avg=Avg('books__pages')).order_by('name'):
print(author.name, author.titles, round(author.avg))A Django model is one declaration that produces the Python class, the SQL schema (through migrations), and the queries, so every schema change begins as a code change.
Worked examples
A unique constraint plus atomic rollback
Shows that uniqueness is enforced by the database and that a failed insert inside transaction.atomic() undoes the earlier insert too.
import django
from django.conf import settings
settings.configure(
INSTALLED_APPS=[],
DATABASES={'default': {'ENGINE': 'django.db.backends.sqlite3', 'NAME': ':memory:'}},
USE_TZ=True,
)
django.setup()
from django.db import IntegrityError, connection, models, transaction
class Member(models.Model):
email = models.EmailField(unique=True)
visits = models.IntegerField(default=0)
class Meta:
app_label = 'club'
with connection.schema_editor() as schema:
schema.create_model(Member)
Member.objects.create(email='ada@example.com')
try:
with transaction.atomic():
Member.objects.create(email='grace@example.com')
Member.objects.create(email='ada@example.com')
except IntegrityError:
print('rolled back')
print(Member.objects.count())
print(list(Member.objects.values_list('email', flat=True)))Example explained
Line 1unique=True on email becomes a UNIQUE index in the CREATE TABLE, so the duplicate is rejected by SQLite, not by Python.
Line 2The second create() inside the atomic block raises IntegrityError, which the block turns into a rollback on the way out.
Line 3grace@example.com was inserted successfully but still disappears, because the whole block was one transaction.
Line 4values_list('email', flat=True) selects a single column and returns plain strings instead of model instances.
Counting the queries an access pattern costs
Compares walking a foreign key per object against select_related, using connection.queries as the measurement.
import django
from django.conf import settings
settings.configure(
DEBUG=True,
INSTALLED_APPS=[],
DATABASES={'default': {'ENGINE': 'django.db.backends.sqlite3', 'NAME': ':memory:'}},
USE_TZ=True,
)
django.setup()
from django.db import connection, models, reset_queries
class Team(models.Model):
name = models.CharField(max_length=50)
class Meta:
app_label = 'league'
class Player(models.Model):
name = models.CharField(max_length=50)
team = models.ForeignKey(Team, on_delete=models.CASCADE)
class Meta:
app_label = 'league'
with connection.schema_editor() as schema:
schema.create_model(Team)
schema.create_model(Player)
reds = Team.objects.create(name='Reds')
blues = Team.objects.create(name='Blues')
for name, team in [('Ana', reds), ('Bo', blues), ('Cy', reds)]:
Player.objects.create(name=name, team=team)
reset_queries()
names = [p.team.name for p in Player.objects.order_by('id')]
print(len(connection.queries), names)
reset_queries()
names = [p.team.name for p in Player.objects.order_by('id').select_related('team')]
print(len(connection.queries), names)Example explained
Line 1connection.queries is only populated when DEBUG is True, so the setting is part of the measurement, not decoration.
Line 2The first loop costs 1 query for the players plus 1 per player for the team: 3 players give 4 queries even though only two teams exist.
Line 3select_related('team') adds a SQL JOIN, so the team columns arrive with the player row and p.team is already cached.
Line 4order_by('id') makes the row order explicit instead of relying on whatever order the storage engine happens to return.
Important notes
':memory:' exists only for the life of the process; a real project points NAME at a file or a Postgres database and keeps its migration files in version control.
connection.schema_editor() is used here only to make a single-file script runnable — in a project it is migrate's job, and calling it yourself leaves django_migrations empty and the app unmigratable.
Common mistakes
Adding or renaming a model field and restarting the server without makemigrations plus migrate: the table still has the old shape, so the first query raises OperationalError: no such column.
Editing or deleting a migration file that has already been applied, so the names in django_migrations no longer match the files; migrate then either errors out or thinks your change is already done.
Assuming Model.save() validates: it does not call full_clean(), so a blank CharField or an out-of-range choice is written straight to the database unless a real DB constraint stops it.
Try it yourself
Change, predict, then run
Extend the main script with a Review model (ForeignKey to Book with related_name='reviews' and a PositiveSmallIntegerField rating), create its table with the schema editor, insert three reviews, then print each author's average rating using Author.objects.annotate(score=Avg('books__reviews__rating')).
Open the Python workspaceCheck your understanding
You add stock = models.PositiveIntegerField() to a model whose table already contains rows, and makemigrations stops to ask you for a one-off default. Why does it need one?
- Because SQLite cannot add a column to a table that already contains rows.
- Because the new column is NOT NULL, so the migration must write some value into the rows that already exist.
- Because the ORM cannot include a field in queries until the field has a default.
- Because the admin form would otherwise render the field as optional.
Show answer
Without null=True or default=, the column is NOT NULL and existing rows have nothing to put in it, so the migration records a one-off value used only while backfilling. Option 3 is tempting but wrong: a field with no default is perfectly usable in filters and queries — the prompt is about the rows already in the table, not about the query API.