Data Modeling with Django's ORM

Food Tracker (Django)

Chapter 2 · Data Modeling with Django's ORM

Chapter 1 set up the project and the app. This chapter defines the one Model that everything else in this course builds on.

The Model

# pantry/models.py from django.db import models class Item(models.Model): STATUS_CHOICES = [ ("active", "Active"), ("used", "Used"), ] name = models.CharField(max_length=200) barcode = models.CharField(max_length=64, blank=True) category = models.CharField(max_length=100, blank=True) status = models.CharField(max_length=10, choices=STATUS_CHOICES, default="active") expiry_date = models.DateField(null=True, blank=True) added_at = models.DateTimeField(auto_now_add=True) used_at = models.DateTimeField(null=True, blank=True) def __str__(self): return self.name

null=True and blank=True Are Two Different Settings

null=True is a database-level setting — it lets the actual SQL column store NULL. blank=True is a form/admin-level setting — it lets Django's forms and the admin site accept an empty value without raising a validation error. They're independent, and both are needed on expiry_date for two separate reasons: null=True so a used item's row can genuinely have no expiry date stored, and blank=True so the admin form doesn't reject an empty expiry date as a validation failure, even though the database would happily accept it.

This app's own Firestore-based sibling course models the identical "no expiry" case by omitting the field from the document entirely — a genuine structural difference between schema-on-write (this course: an always-present column, sometimes holding NULL) and schema-on-read (that course: the field simply isn't there at all).

DateField vs. DateTimeField

expiry_date uses DateField — a use-by date has no meaningful time component. added_at and used_at use DateTimeField, since knowing roughly when within a day something happened is genuinely useful for those two fields specifically.

auto_now_add: Django's Own Trustworthy Timestamp

auto_now_add=True sets a field's value once, at creation, using the server's own clock — and makes the field non-editable through ordinary forms. This solves the identical problem the Firebase sibling course's own serverTimestamp() solves: never trust a client-supplied creation time. auto_now_add is easy to confuse with the similarly-named auto_now, which instead updates the field on every save — the wrong choice for added_at, which should only ever be set the one time the row is created.

choices= Is a Voluntary Constraint, Not a Guarantee

STATUS_CHOICES restricts what the admin site and Django Forms will offer as valid options for status — but whether that also becomes a real database-level constraint depends on the Django version in use, and shouldn't be assumed either way without checking. A raw SQL statement, or code that bypasses Django's own forms and admin validation, may still be able to write a value outside STATUS_CHOICES depending on that. choices= is genuinely useful for guiding the admin and forms layer; treating it as an unconditional database-level guarantee is the kind of assumption worth verifying rather than trusting blindly.

Migrations: Two Separate Steps, On Purpose

python manage.py makemigrations pantry python manage.py migrate

makemigrations compares the current models against the last recorded schema state and generates a migration file — a versioned, reviewable Python description of the change. migrate is the separate step that actually applies pending migrations to a real database. Keeping "describe the change" and "apply the change" as two distinct commands means a migration file can be reviewed and committed to version control before it ever touches a database, and the exact same migration can be applied identically across a developer's machine, staging, and production.

The same tradeoff, from the other side
Food Tracker (React + Firebase)'s own Chapter 2 explained schema-on-read as trading database-enforced consistency for flexibility. This chapter is the mirror image of that same tradeoff: Django's ORM enforces column types and nullability at write time, in exchange for needing an explicit migration every time the shape of the data changes at all. Neither approach is free — each simply decided which cost to accept.
Try the model out before building any views
python manage.py shell opens an interactive Python shell with Django's app registry already loaded — Item.objects.create(name="Test", status="active") works immediately, letting the model itself be exercised before any URL, view, or template exists yet.
The single most likely mistake in this exact model
Setting null=True without also setting blank=True (or the reverse) is the most common source of confusing bugs on a field like this one. A field that genuinely should be optional will still throw a "this field is required" validation error in a form or the admin if blank=True is missing — even though the database column itself would happily accept NULL. If a field behaves like it's required when it clearly shouldn't be, check blank before anything else.

Where This Course Is Headed

The Django admin as an instant CRUD tool for this exact model, barcode lookup via a Django view, camera-based scanning, the add-item flow via Django Forms, expiry alerts, item history and search, marking items used, recipe lookup, Django REST Framework, deployment, and a capstone.

Hands-On Exercises

Exercise 1

Explain the difference between null=True and blank=True, and why expiry_date needs both rather than just one.

📄 View solution
Exercise 2

Explain the difference between auto_now_add and auto_now, and why added_at specifically needs the former, not the latter.

📄 View solution
Exercise 3

Explain what makemigrations does versus what migrate does, and why Django keeps them as two separate commands rather than one combined step.

📄 View solution

Chapter 2 Quick Reference

  • null=True — database-level; allows a real SQL NULL
  • blank=True — form/admin-level; allows an empty value in forms and the admin
  • auto_now_add — set once at creation, server clock, non-editable; auto_now updates on every save instead
  • choices= — constrains forms/admin; don't assume it's an unconditional database guarantee without checking
  • makemigrations vs. migrate — describe the change, then separately apply it; keeps changes reviewable and reproducible
  • Next chapter: The Django Admin: Instant CRUD for Free