Skip to content

Django Models

Django models are the foundation of your application.

When model design is clear, everything else gets easier: admin panels, APIs, testing, and long-term maintenance.

This chapter focuses only on model design. You will learn how to represent real-world concepts with Django models in a clean, beginner-friendly way.

Designing a data model is more than writing classes in models.py. It means making thoughtful decisions about:

  • What entities exist in your business domain.
  • What data each entity should store.
  • How entities connect to each other.
  • What values should be allowed or blocked.
  • How your model names and structure will stay readable as the app grows.

In simple terms, model design is clarity first, code second.

Real world ideaIn Django
Entity (for example, Product)Model class
Property (for example, product name)Field
Relationship (for example, Product belongs to Category)Relation field (ForeignKey, ManyToManyField, OneToOneField)
  • User
  • Product
  • Category
  • Order
  • OrderItem
  • Payment

Normalization means avoiding repeated information.

Not ideal:

  • Save category name as plain text inside every product.

Better approach:

  • Make a separate Category model.
  • Link Product to Category.

Benefits:

  • Cleaner code
  • Fewer mistakes
  • Easier updates
flowchart LR U[User] -->|1 to many| O[Order] O -->|1 to many| OI[OrderItem] OI -->|many to 1| P[Product] C[Category] -->|1 to many| P

This structure works well because:

  • One user can place many orders.
  • One order has many items.
  • Each order item points to one product.
  • One category can contain many products.

Putting every model in one large app creates confusion as the project grows. A domain-based app structure is easier to scale and maintain.

flowchart TB A[apps] --> B[users] A --> C[products] A --> D[orders] A --> E[payments]

Why this helps

Clear ownership and easier teamwork.

Cleaner codebase

Smaller files and better separation of concerns.

Safer long-term growth

New features fit naturally without turning one app into a monolith.

Start with a small, clear model.

from django.db import models
class Product(models.Model):
name = models.CharField(max_length=255)
price = models.DecimalField(max_digits=10, decimal_places=2)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return self.name
  • class Product(models.Model): tells Django this class maps to a database table.
  • name, price, created_at: fields that describe product data.
  • __str__: a readable label in admin, shell, and logs.

Use these common field types as a practical reference.

FieldTypical use
CharFieldShort text like title, code, city
TextFieldLong text like description or notes
IntegerFieldInteger numbers
BooleanFieldTrue/False flags
DateTimeFieldDate + time values
DecimalFieldMoney values
UUIDFieldPublic-safe IDs
JSONFieldFlexible structured data
EmailFieldEmail values with basic validation
URLFieldURL values
SlugFieldURL-friendly short text

Realistic product example with mixed fields

Section titled “Realistic product example with mixed fields”
import uuid
from django.db import models
class Product(models.Model):
public_id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
name = models.CharField(max_length=255)
slug = models.SlugField(max_length=280, unique=True)
description = models.TextField(blank=True)
price = models.DecimalField(max_digits=10, decimal_places=2)
is_active = models.BooleanField(default=True)
metadata = models.JSONField(default=dict, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)

Field options define validation, storage behavior, and developer experience.

name = models.CharField(
max_length=255,
null=False,
blank=False,
unique=True,
db_index=True,
)
OptionMeaning
nullWhether empty value can be stored as NULL
blankWhether forms/validation allow empty input
uniquePrevent duplicate values
db_indexAdd index for faster lookup
defaultFallback value when not provided
editableHide or show field in admin/forms
help_textHelpful label for users/admin

Beginner-safe rule:

  • null controls database storage behavior, meaning whether a field can store NULL.
  • blank controls validation behavior, meaning whether forms and model validation allow empty input.
bio = models.TextField(blank=True, default="")

This pattern keeps behavior predictable in forms, serializers, and templates.

Choice fields limit values to a known set. They are ideal for status, role, priority, and type fields.

This style works but has drawbacks: less readable in code and admin, more prone to typos, and harder to reuse.

STATUS_CHOICES = [
("P", "Pending"),
("C", "Completed"),
("X", "Cancelled"),
]
status = models.CharField(max_length=1, choices=STATUS_CHOICES)

This style is clearer, more maintainable, and less error-prone.

from django.db import models
class OrderStatus(models.TextChoices):
PENDING = "P", "Pending"
COMPLETED = "C", "Completed"
CANCELLED = "X", "Cancelled"
class Order(models.Model):
status = models.CharField(
max_length=1,
choices=OrderStatus.choices,
default=OrderStatus.PENDING,
)

Why this style is better:

  • Better readability in code and admin.
  • Fewer typo-related bugs.
  • Easy reuse across models, forms, and business logic.

Relationships are one of the most important parts of model design. They connect models and represent real-world data relationships.

Use one-to-one when each record on one side maps to exactly one record on the other side.

Common use case: User and Profile.

from django.conf import settings
from django.db import models
class Profile(models.Model):
user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
phone = models.CharField(max_length=20, blank=True)

What this means:

  • One user has one profile.
  • If the user is deleted, profile is also deleted (CASCADE).

Reverse access:

user.profile

Use this when one parent can have many child records.

from django.db import models
class Category(models.Model):
name = models.CharField(max_length=100)
class Product(models.Model):
category = models.ForeignKey(
Category,
on_delete=models.PROTECT,
related_name="products",
)

Use many-to-many when both sides can have many related records.

class Tag(models.Model):
name = models.CharField(max_length=50)
class Product(models.Model):
tags = models.ManyToManyField(Tag, related_name="products")

Django handles the linking table automatically.

Many-to-many with extra fields using through

Section titled “Many-to-many with extra fields using through”

Use this when the relationship itself has data.

class Student(models.Model):
name = models.CharField(max_length=120)
class Course(models.Model):
title = models.CharField(max_length=200)
students = models.ManyToManyField("Student", through="Enrollment")
class Enrollment(models.Model):
student = models.ForeignKey(Student, on_delete=models.CASCADE)
course = models.ForeignKey(Course, on_delete=models.CASCADE)
enrolled_at = models.DateTimeField(auto_now_add=True)
status = models.CharField(max_length=20, default="active")

This helps maintain data integrity when related records are deleted.

OptionWhat happens
CASCADEDelete related child records
PROTECTBlock delete if children exist
SET_NULLSet relation to null (null=True required)
SET_DEFAULTSet relation to default value
DO_NOTHINGNo automatic action
RESTRICTPrevent deletion when referenced

Beginner-safe guidance:

  • Prefer PROTECT for important business records.
  • Use CASCADE only when automatic child delete is truly expected.

By default, Django creates reverse accessors like category.product_set.all(). Use related_name to make reverse queries easier to read.

category = models.ForeignKey(
Category,
on_delete=models.CASCADE,
related_name="products",
)

Reverse access with related_name:

category.products.all() # clearer than category.product_set.all()

Model References, Circular Relations, and Self Relations

Section titled “Model References, Circular Relations, and Self Relations”

Sometimes a model is declared later or in another app.

String references solve this cleanly.

b = models.ForeignKey("B", on_delete=models.CASCADE)

Cross-app version:

b = models.ForeignKey("products.Product", on_delete=models.CASCADE)

Self-reference example:

class Employee(models.Model):
manager = models.ForeignKey(
"self",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="subordinates",
)

This is useful for tree-like structures, such as manager to team members or parent category to child categories.

flowchart TD M[Manager: Employee] --> S1[Subordinate 1] M --> S2[Subordinate 2]

Generic relationships let one model point to different model types. Example: a Comment model that can attach to Product, BlogPost, or Video.

from django.db import models
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
class Comment(models.Model):
body = models.TextField()
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
object_id = models.PositiveIntegerField()
content_object = GenericForeignKey("content_type", "object_id")

Use this pattern only when you truly need cross-model flexibility.

FileField and ImageField for Media Handling

Section titled “FileField and ImageField for Media Handling”

This is one of the most practical model topics in real projects.

FieldUse
FileFieldAny file type (pdf, doc, zip, etc.)
ImageFieldImage files (jpg, png, webp, etc.)

ImageField requires Pillow package:

Terminal window
pip install Pillow
from django.db import models
class Product(models.Model):
name = models.CharField(max_length=255)
brochure = models.FileField(upload_to="products/brochures/", blank=True)
image = models.ImageField(upload_to="products/images/", blank=True)

upload_to defines folder structure inside your media storage.

Add in settings.py:

MEDIA_URL = "/media/"
MEDIA_ROOT = BASE_DIR / "media"

Add in project urls.py (for development):

from django.conf import settings
from django.conf.urls.static import static
from django.urls import path
urlpatterns = [
# your urls...
]
if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
{% if product.image %}
<img src="{{ product.image.url }}" alt="{{ product.name }}" />
{% endif %}
from django.core.exceptions import ValidationError
def validate_file_size(file_obj):
limit_mb = 5
if file_obj.size > limit_mb * 1024 * 1024:
raise ValidationError("File too large. Max size is 5MB.")
class Document(models.Model):
file = models.FileField(upload_to="docs/", validators=[validate_file_size])
  1. Do not store user uploads inside your app source folders.
  2. Use clear upload_to paths so files are easy to manage.
  3. Validate file size and, when needed, file type.
  4. In production, use an object storage service (for example S3-compatible storage) instead of local disk.
  5. Never trust uploaded file names directly for security decisions.
flowchart LR User[User Upload] --> Form[Django Form or API] Form --> Model[Model FileField/ImageField] Model --> Storage[Media Storage] Storage --> URL[Public Media URL]

The Meta class inside a model defines metadata and behavior for the model.

You can use it to control ordering, table naming, indexes, uniqueness rules, and other database-level behavior.

  • Keeps important model rules in one place.
  • Improves query performance with proper indexes.
  • Makes data constraints explicit and safer for production.
OptionUse case
orderingDefault ordering for query results
db_tableCustom database table name
verbose_nameHuman-friendly singular model name
verbose_name_pluralHuman-friendly plural model name
indexesAdd single or composite database indexes
constraintsAdd advanced rules like unique constraints
get_latest_byField used by .latest()
from django.db import models
class Product(models.Model):
name = models.CharField(max_length=255)
sku = models.CharField(max_length=80, unique=True)
category = models.ForeignKey("Category", on_delete=models.PROTECT)
is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = "store_products"
ordering = ["-created_at"]
verbose_name = "Product"
verbose_name_plural = "Products"
get_latest_by = "created_at"

You asked about unique_together, so here is the practical guidance:

  • unique_together is still seen in many projects.
  • For new code, prefer UniqueConstraint in constraints because it is more flexible and modern.
class Enrollment(models.Model):
student = models.ForeignKey("Student", on_delete=models.CASCADE)
course = models.ForeignKey("Course", on_delete=models.CASCADE)
class Meta:
unique_together = [("student", "course")]
class Enrollment(models.Model):
student = models.ForeignKey("Student", on_delete=models.CASCADE)
course = models.ForeignKey("Course", on_delete=models.CASCADE)
class Meta:
constraints = [
models.UniqueConstraint(
fields=["student", "course"],
name="unique_student_course_enrollment",
)
]

Indexing: field-level db_index vs Meta indexes

Section titled “Indexing: field-level db_index vs Meta indexes”

You can add indexes in two common ways.

class Product(models.Model):
slug = models.SlugField(unique=True)
is_active = models.BooleanField(default=True, db_index=True)

Use direct db_index=True when:

  • You need a simple index on one field.
  • The intent should be visible directly next to that field.
class Product(models.Model):
category = models.ForeignKey("Category", on_delete=models.PROTECT)
is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
indexes = [
models.Index(fields=["category", "is_active"]),
models.Index(fields=["-created_at"]),
]

Use Meta indexes when:

  • You need a composite index on multiple fields.
  • You need more advanced index definitions.
  • You want index strategy grouped in one place.

Model methods let you add custom behavior and logic directly to your models. Three critical methods control the instance lifecycle: save(), clean(), and delete().

The clean() method is for validation logic that enforces business rules. It runs when you call model_instance.full_clean() (typically in forms or before saving programmatically).

Important: clean() does not save the instance; it only validates.

from django.db import models
from django.core.exceptions import ValidationError
from datetime import date
class User(models.Model):
first_name = models.CharField(max_length=100)
email = models.EmailField(unique=True)
birth_date = models.DateField()
age = models.PositiveIntegerField(blank=True, null=True)
def clean(self):
# Validate business rules
if self.birth_date > date.today():
raise ValidationError("Birth date cannot be in the future.")
if self.first_name and self.first_name[0].islower():
raise ValidationError("First name must start with capital letter.")
class Enrollment(models.Model):
start_date = models.DateField()
end_date = models.DateField()
def clean(self):
if self.end_date < self.start_date:
raise ValidationError("End date must be after start date.")
  • Automatically in Django forms via form.full_clean()
  • Manually when you call instance.full_clean() before saving
  • Not automatically by save() unless you also override save()

The save() method controls persistence logic. Override it to:

  • Modify field values before saving
  • Trigger side effects (like notifications or cache invalidation)
  • Enforce business rules before database write
  • Transform input data
from django.db import models
from datetime import date
class Product(models.Model):
name = models.CharField(max_length=255)
price = models.DecimalField(max_digits=10, decimal_places=2)
slug = models.SlugField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
def save(self, *args, **kwargs):
# Generate slug from name if not provided
if not self.slug:
self.slug = self.name.lower().replace(" ", "-")
# Enforce minimum price
if self.price < 0:
self.price = 0
# Call parent save
super().save(*args, **kwargs)
class Order(models.Model):
status = models.CharField(max_length=20)
total = models.DecimalField(max_digits=10, decimal_places=2)
def clean(self):
if self.total < 0:
raise ValidationError("Total cannot be negative.")
def save(self, *args, **kwargs):
self.full_clean() # Call clean() to validate
super().save(*args, **kwargs)
class Product(models.Model):
name = models.CharField(max_length=255)
price = models.DecimalField(max_digits=10, decimal_places=2)
updated_at = models.DateTimeField(auto_now=True)
def save(self, *args, **kwargs):
# Only update specific fields on the database
# This is useful when you change only one or two fields
super().save(update_fields=['name', 'price', 'updated_at'], *args, **kwargs)

The delete() method controls removal logic. Override it to:

  • Perform cleanup (delete related files, clear cache)
  • Log deletions for audit trails
  • Prevent deletion under certain conditions
import os
from django.db import models
class Document(models.Model):
title = models.CharField(max_length=255)
file = models.FileField(upload_to="documents/")
deleted_at = models.DateTimeField(null=True, blank=True)
def delete(self, *args, **kwargs):
# Clean up file from storage
if self.file:
if os.path.isfile(self.file.path):
os.remove(self.file.path)
# Call parent delete
super().delete(*args, **kwargs)
from django.db import models
from django.utils import timezone
class BlogPost(models.Model):
title = models.CharField(max_length=255)
content = models.TextField()
deleted_at = models.DateTimeField(null=True, blank=True)
is_deleted = models.BooleanField(default=False)
def delete(self, *args, **kwargs):
# Soft delete: mark as deleted instead of removing
self.is_deleted = True
self.deleted_at = timezone.now()
self.save()
# Don't call super().delete() for soft deletes
from django.db import models
from django.core.exceptions import ProtectedError
class SystemConfig(models.Model):
key = models.CharField(max_length=100, unique=True)
value = models.TextField()
def delete(self, *args, **kwargs):
if self.key.startswith("SYS_"):
raise ProtectedError("System configuration cannot be deleted.")
super().delete(*args, **kwargs)

Common pattern: combining clean, save, and delete

Section titled “Common pattern: combining clean, save, and delete”
from django.db import models
from django.core.exceptions import ValidationError
from datetime import date
class Employee(models.Model):
first_name = models.CharField(max_length=100)
email = models.EmailField(unique=True)
hire_date = models.DateField()
salary = models.DecimalField(max_digits=10, decimal_places=2)
def clean(self):
# Validate inputs
if self.hire_date > date.today():
raise ValidationError("Hire date cannot be in the future.")
if self.salary < 0:
raise ValidationError("Salary must be positive.")
def save(self, *args, **kwargs):
# Apply business logic before save
self.email = self.email.lower() # Normalize email
self.full_clean() # Run validation
super().save(*args, **kwargs)
def delete(self, *args, **kwargs):
# Cleanup before delete (e.g., cleanup files, notify systems)
print(f"Deleting employee: {self.first_name}")
super().delete(*args, **kwargs)

Sometimes you need fields that are calculated on-the-fly based on other data, rather than storing them in the database. Common examples: age from birth date, full name from first and last name, or status computed from related data.

Using @property for read-only computed fields

Section titled “Using @property for read-only computed fields”

The @property decorator creates a computed field that reads like an attribute but calculates on access.

from datetime import date
from django.db import models
class Person(models.Model):
first_name = models.CharField(max_length=100)
last_name = models.CharField(max_length=100)
birth_date = models.DateField()
@property
def age(self):
today = date.today()
return today.year - self.birth_date.year - (
(today.month, today.day) < (self.birth_date.month, self.birth_date.day)
)
@property
def full_name(self):
return f"{self.first_name} {self.last_name}"
def __str__(self):
return self.full_name

Usage:

person = Person.objects.first()
print(person.full_name) # "John Doe"
print(person.age) # 32
from django.db import models
class Order(models.Model):
created_at = models.DateTimeField(auto_now_add=True)
@property
def total_price(self):
return sum(item.quantity * item.price for item in self.items.all())
@property
def item_count(self):
return self.items.aggregate(total=models.Sum('quantity'))['total'] or 0
class OrderItem(models.Model):
order = models.ForeignKey(Order, on_delete=models.CASCADE, related_name="items")
product = models.ForeignKey("Product", on_delete=models.PROTECT)
quantity = models.PositiveIntegerField()
price = models.DecimalField(max_digits=10, decimal_places=2)

Caching computed values with @cached_property

Section titled “Caching computed values with @cached_property”

When a computed field is expensive to calculate, cache it during the instance lifetime using @cached_property.

from django.utils.functional import cached_property
from django.db import models
class BlogPost(models.Model):
title = models.CharField(max_length=255)
content = models.TextField()
@cached_property
def word_count(self):
# This calculation runs once per instance
return len(self.content.split())
@cached_property
def reading_time_minutes(self):
# Estimate 200 words per minute
return max(1, self.word_count // 200)

Usage:

post = BlogPost.objects.first()
print(post.reading_time_minutes) # Calculated and cached
print(post.reading_time_minutes) # Returns cached value (no recalculation)

Store computed fields in database with save()

Section titled “Store computed fields in database with save()”

Sometimes you need to store computed values in the database for performance (especially if the calculation is expensive or used in queries).

from datetime import date
from django.db import models
class Person(models.Model):
first_name = models.CharField(max_length=100)
birth_date = models.DateField()
age = models.PositiveIntegerField(blank=True, null=True)
def save(self, *args, **kwargs):
# Calculate and store age before saving
today = date.today()
self.age = today.year - self.birth_date.year - (
(today.month, today.day) < (self.birth_date.month, self.birth_date.day)
)
super().save(*args, **kwargs)

Advantages:

  • Age can be used in database queries and filtering
  • Better performance for frequently accessed field

Disadvantages:

  • Age becomes stale over time (need periodic updates)
  • Requires extra storage space

Comparison: property vs stored field vs cached_property

Section titled “Comparison: property vs stored field vs cached_property”
ApproachUse CasePerformanceDatabase
@propertySimple read-only calculationsSlower (calculated each time)Not stored
@cached_propertyExpensive calculations within instanceMedium (cached per instance)Not stored
Stored field with save()Critical performance or needed in queriesFastest (stored)Takes storage