My first Django model felt finished when migrate printed OK. The harder lesson arrived later: field choices become long-lived database and API contracts. Whether a value can be missing, how deletion behaves, which combinations are legal, and which queries stay fast all belong in the first design conversation—not in cleanup after production data exists.
Example goal and tested baseline
The example targets Django 5.2 LTS APIs and uses backend-portable model features.
A
Categorycan own many articles.Each article has a stable unique slug and one of two workflow states.
Drafts have no publication time; published rows must have one.
A compound index supports the expected published-article listing.
Database constraints protect invariants even when data bypasses a form.
Create and activate the app
python -m django --version
python manage.py startapp blog5.2.xAn app is a reusable domain boundary
python -m django --versionconfirms which installed framework will generate code and migrations.startappcreates Python files; it does not create database tables.Run this once for a new app and review generated files before committing.
Use a virtual environment and pinned dependencies so every environment sees the same Django behavior.
INSTALLED_APPS = [
# Django and project apps...
"blog.apps.BlogConfig",
]App registration enables model discovery
The app configuration gives Django an application label and import path.
A missing app in
INSTALLED_APPSmeans its normal migrations are not part of project migration planning.Keep settings environment-specific without dynamically changing installed model apps between routine deployments.
Define fields, a relationship, constraints, and an index
from django.db import models
from django.db.models import Q
class Category(models.Model):
name = models.CharField(max_length=80)
slug = models.SlugField(max_length=90, unique=True)
class Meta:
ordering = ["name"]
verbose_name_plural = "categories"
def __str__(self) -> str:
return self.name
class Article(models.Model):
class Status(models.TextChoices):
DRAFT = "draft", "Draft"
PUBLISHED = "published", "Published"
title = models.CharField(max_length=200)
slug = models.SlugField(max_length=220, unique=True)
body = models.TextField()
category = models.ForeignKey(
Category,
on_delete=models.PROTECT,
related_name="articles",
)
status = models.CharField(
max_length=10, choices=Status, default=Status.DRAFT
)
published_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["-published_at", "-id"]
indexes = [
models.Index(
fields=["status", "-published_at"],
name="article_status_pub_idx",
),
]
constraints = [
models.CheckConstraint(
condition=(
Q(status="draft", published_at__isnull=True)
| Q(status="published", published_at__isnull=False)
),
name="article_status_time_valid",
),
]
def __str__(self) -> str:
return self.titleThe schema encodes behavior explicitly
Django creates an automatic primary key because none is declared; its type follows
DEFAULT_AUTO_FIELD.unique=Truecreates a database uniqueness guarantee and its supporting index; a separatedb_indexis unnecessary.ForeignKeystores acategory_idcolumn and normally creates an index for it.PROTECTrejects category deletion while articles reference it instead of cascading content away.TextChoicescentralizes stored values and human labels, but choices alone are application validation rather than a universal database check.The explicit check constraint connects workflow state to nullability at the database layer.
The compound index matches filtering by status and ordering by publication time; indexes should follow measured query patterns.
Understand defaults and callables
Use a callable such as
default=uuid.uuid4, notdefault=uuid.uuid4(), when every row needs a fresh value.auto_now_addsets a creation timestamp on first save;auto_nowupdates on each model save. They are not suitable for every auditable timestamp policy.Database defaults and Django field defaults are distinct mechanisms; know which writer paths must receive the value.
Avoid mutable literal defaults such as
default={}for JSON fields; usedefault=dict.Changing a default affects new model instances and may generate a migration, but it does not retroactively rewrite every existing value.
Generate and inspect the migration
python manage.py makemigrations blog --name initial_content_models
python manage.py sqlmigrate blog 0001
python manage.py check
python manage.py migrate --planMigrations for 'blog':
blog/migrations/0001_initial_content_models.py
...
Planned operations:
blog.0001_initial_content_modelsGeneration is not approval
makemigrationscompares model state with migration state and writes declarative operations.The filename sequence may differ if the app already has migrations; use the actual generated name with
sqlmigrate.sqlmigrateshows backend-specific SQL for review without applying it.checkcatches model and configuration problems but cannot prove a production migration is operationally safe.migrate --planshows pending order and dependencies.Review the generated Python file for accidental drops, renames misdetected as delete/add, expensive defaults, and backend limitations.
Apply the migration and verify state
python manage.py migrate
python manage.py showmigrations blog
python manage.py makemigrations --check --dry-runApplying blog.0001_initial_content_models... OK
blog
[X] 0001_initial_content_models
No changes detectedRisk level: caution. Review the command before running it.
Database mutation deserves rollout discipline
migratechanges the configured database and is therefore marked caution. Back up and test restoration for important environments.Django records applied migrations in
django_migrations; do not mark them applied manually without a precise recovery plan.showmigrationsverifies recorded state, while the dry-run check proves model changes are represented in migration files.Production DDL locking and transactional behavior vary by database and operation.
For large live tables, use staged expand/contract migrations rather than combining incompatible schema and application changes in one risky deploy.
Create valid rows through the ORM
python manage.py shell <<'PY'
from django.utils import timezone
from blog.models import Article, Category
category, _ = Category.objects.get_or_create(
slug="engineering", defaults={"name": "Engineering"}
)
article = Article.objects.create(
title="A migration worth reviewing",
slug="migration-worth-reviewing",
body="Models become durable contracts.",
category=category,
status=Article.Status.PUBLISHED,
published_at=timezone.now(),
)
print(article.pk, article.status, article.category.name)
PY1 published EngineeringThe ORM keeps domain names above SQL details
get_or_createis convenient only when lookup fields have suitable uniqueness and race behavior.Use
timezone.now()under Django timezone support rather than a naivedatetime.now()value.Assigning
categoryaccepts an object;category_idcan avoid fetching when a validated primary key is already known.objects.create()calls save but notfull_clean()automatically. Database constraints remain the final invariant for all writers.The returned primary key is assigned after insertion by the configured database backend.
Query without creating an N+1 problem
from blog.models import Article
def recent_articles(limit: int = 20):
return (
Article.objects
.filter(status=Article.Status.PUBLISHED)
.select_related("category")
.order_by("-published_at")[:limit]
)Query shape and index shape should agree
filteruses the stable stored choice value through the enum member.select_relatedjoins the single-valued foreign key so rendering category names does not issue one query per article.The explicit ordering matches the compound status/publication index’s intended access path.
A slice adds a database limit; validate negative or unbounded input before constructing a public API.
Use
QuerySet.explain()and database monitoring to validate performance rather than adding speculative indexes.
Validation and constraints are complementary
Forms and serializers provide friendly early validation.
Model.full_clean()can run field, model, uniqueness, and constraint validation when called.save()does not callfull_clean()automatically.Database constraints protect concurrent and non-Django writers, but surface failures as exceptions such as
IntegrityError.Catch integrity errors at a transaction boundary where the application can return a meaningful conflict response.
Business rules involving remote systems or mutable external state do not belong in a database check constraint.
Test both model validation and database enforcement
from django.core.exceptions import ValidationError
from django.db import IntegrityError, transaction
from django.test import TestCase
from blog.models import Article, Category
class ArticleModelTests(TestCase):
def setUp(self):
self.category = Category.objects.create(
name="Engineering", slug="engineering"
)
def test_published_article_requires_timestamp(self):
article = Article(
title="Invalid",
slug="invalid",
body="Missing publication time",
category=self.category,
status=Article.Status.PUBLISHED,
)
with self.assertRaises(ValidationError):
article.full_clean()
def test_database_rejects_invalid_state(self):
with self.assertRaises(IntegrityError), transaction.atomic():
Article.objects.create(
title="Invalid",
slug="invalid-db",
body="Missing publication time",
category=self.category,
status=Article.Status.PUBLISHED,
)Two tests protect two entry paths
The first test verifies application-level constraint validation via
full_clean().The second bypasses that validation and proves the database check still rejects the row.
An expected
IntegrityErroris isolated insidetransaction.atomic()so the surrounding test transaction remains usable.Use backend-aware tests for features whose enforcement differs across SQLite, PostgreSQL, MySQL, or Oracle.
Run migration tests on the same database engine used in production when backend behavior matters.
Evolving a populated model safely
Add a new field as nullable or with a safe temporary state.
Deploy code that can read old and new representations.
Backfill in bounded, restartable batches with monitoring.
Validate that no rows remain outside the intended invariant.
Add or validate the database constraint and required/index state.
Deploy code that relies on the new invariant.
Remove compatibility code in a later migration/deploy.
Common first-model mistakes
Editing a committed migration already applied elsewhere: create a new migration; historical state must remain reproducible.
Deleting and recreating the database for every change: migrations exist to evolve data without discarding it.
Using `CASCADE` by habit: choose deletion semantics from the domain and legal/audit needs.
Adding `null=True` everywhere: distinguish database absence from form optionality.
Using `unique_together` for new work: prefer explicit
UniqueConstraintfor clearer capabilities and names.Expecting choices to constrain every database writer: add an appropriate database constraint when the invariant matters.
Indexing every field: indexes consume storage and slow writes; design from real filters, joins, ordering, and plans.
Renaming by delete/add: answer the migration autodetector’s rename question carefully or write explicit state/database operations.
Production checklist
Pin a supported Django version and database driver.
Commit migrations and enforce
makemigrations --check --dry-runin CI.Review SQL and lock behavior on the production database engine.
Back up and test restore before material schema changes.
Separate schema expansion, data backfill, and constraint tightening for large tables.
Monitor migration duration, locks, errors, replica lag, and application compatibility.
Document rollback direction; many data transformations are not safely reversible.
Official Django references
Django model tutorial explains model classes, relationships, app activation, migrations, and the ORM.
Model field reference defines field options, nullability, uniqueness, relationships, and backend behavior.
Constraints reference documents check and unique constraints and validation.
Index reference documents compound, expression, conditional, and covering indexes plus backend limitations.
Migration operations describes the declarative history Django uses to evolve schema state.
Comments and corrections