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 Category can 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

Django project root containing manage.pybash
python -m django --version
python manage.py startapp blog
5.2.x

An app is a reusable domain boundary

  • python -m django --version confirms which installed framework will generate code and migrations.

  • startapp creates 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.

project/settings.pypython
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_APPS means 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

blog/models.pypython
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.title

The schema encodes behavior explicitly

  • Django creates an automatic primary key because none is declared; its type follows DEFAULT_AUTO_FIELD.

  • unique=True creates a database uniqueness guarantee and its supporting index; a separate db_index is unnecessary.

  • ForeignKey stores a category_id column and normally creates an index for it.

  • PROTECT rejects category deletion while articles reference it instead of cascading content away.

  • TextChoices centralizes 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, not default=uuid.uuid4(), when every row needs a fresh value.

  • auto_now_add sets a creation timestamp on first save; auto_now updates 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; use default=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

Django project rootbash
python manage.py makemigrations blog --name initial_content_models
python manage.py sqlmigrate blog 0001
python manage.py check
python manage.py migrate --plan
Migrations for 'blog':
  blog/migrations/0001_initial_content_models.py
...
Planned operations:
blog.0001_initial_content_models

Generation is not approval

  • makemigrations compares 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.

  • sqlmigrate shows backend-specific SQL for review without applying it.

  • check catches model and configuration problems but cannot prove a production migration is operationally safe.

  • migrate --plan shows 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

Django project rootbash
python manage.py migrate
python manage.py showmigrations blog
python manage.py makemigrations --check --dry-run
Applying blog.0001_initial_content_models... OK
blog
 [X] 0001_initial_content_models
No changes detected

Risk level: caution. Review the command before running it.

Database mutation deserves rollout discipline

  • migrate changes 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.

  • showmigrations verifies 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

Django project rootbash
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)
PY
1 published Engineering

The ORM keeps domain names above SQL details

  • get_or_create is convenient only when lookup fields have suitable uniqueness and race behavior.

  • Use timezone.now() under Django timezone support rather than a naive datetime.now() value.

  • Assigning category accepts an object; category_id can avoid fetching when a validated primary key is already known.

  • objects.create() calls save but not full_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

blog/services.pypython
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

  • filter uses the stable stored choice value through the enum member.

  • select_related joins 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 call full_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

blog/tests/test_models.pypython
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 IntegrityError is isolated inside transaction.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

  1. Add a new field as nullable or with a safe temporary state.

  2. Deploy code that can read old and new representations.

  3. Backfill in bounded, restartable batches with monitoring.

  4. Validate that no rows remain outside the intended invariant.

  5. Add or validate the database constraint and required/index state.

  6. Deploy code that relies on the new invariant.

  7. 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 UniqueConstraint for 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-run in 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