Why this helps
Clear ownership and easier teamwork.
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:
In simple terms, model design is clarity first, code second.
| Real world idea | In 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) |
Normalization means avoiding repeated information.
Not ideal:
Better approach:
Category model.Product to Category.Benefits:
This structure works well because:
Putting every model in one large app creates confusion as the project grows. A domain-based app structure is easier to scale and maintain.
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.nameclass 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.
| Field | Typical use |
|---|---|
CharField | Short text like title, code, city |
TextField | Long text like description or notes |
IntegerField | Integer numbers |
BooleanField | True/False flags |
DateTimeField | Date + time values |
DecimalField | Money values |
UUIDField | Public-safe IDs |
JSONField | Flexible structured data |
EmailField | Email values with basic validation |
URLField | URL values |
SlugField | URL-friendly short text |
import uuidfrom 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,)| Option | Meaning |
|---|---|
null | Whether empty value can be stored as NULL |
blank | Whether forms/validation allow empty input |
unique | Prevent duplicate values |
db_index | Add index for faster lookup |
default | Fallback value when not provided |
editable | Hide or show field in admin/forms |
help_text | Helpful 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)TextChoicesThis 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:
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 settingsfrom 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:
CASCADE).Reverse access:
user.profileUse 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.
throughUse 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")on_delete behaviorThis helps maintain data integrity when related records are deleted.
| Option | What happens |
|---|---|
CASCADE | Delete related child records |
PROTECT | Block delete if children exist |
SET_NULL | Set relation to null (null=True required) |
SET_DEFAULT | Set relation to default value |
DO_NOTHING | No automatic action |
RESTRICT | Prevent deletion when referenced |
Beginner-safe guidance:
PROTECT for important business records.CASCADE only when automatic child delete is truly expected.related_nameBy 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()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.
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 modelsfrom django.contrib.contenttypes.fields import GenericForeignKeyfrom 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.
This is one of the most practical model topics in real projects.
FileField vs ImageField| Field | Use |
|---|---|
FileField | Any file type (pdf, doc, zip, etc.) |
ImageField | Image files (jpg, png, webp, etc.) |
ImageField requires Pillow package:
pip install Pillowfrom 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 settingsfrom django.conf.urls.static import staticfrom 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])upload_to paths so files are easy to manage.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.
| Option | Use case |
|---|---|
ordering | Default ordering for query results |
db_table | Custom database table name |
verbose_name | Human-friendly singular model name |
verbose_name_plural | Human-friendly plural model name |
indexes | Add single or composite database indexes |
constraints | Add advanced rules like unique constraints |
get_latest_by | Field 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.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", ) ]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:
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:
Model methods let you add custom behavior and logic directly to your models.
Three critical methods control the instance lifecycle: save(), clean(), and delete().
clean() MethodThe 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 modelsfrom django.core.exceptions import ValidationErrorfrom 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.")form.full_clean()instance.full_clean() before savingsave() unless you also override save()save() MethodThe save() method controls persistence logic. Override it to:
from django.db import modelsfrom 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)delete() MethodThe delete() method controls removal logic. Override it to:
import osfrom 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 modelsfrom 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 deletesfrom django.db import modelsfrom 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)from django.db import modelsfrom django.core.exceptions import ValidationErrorfrom 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.
@property for read-only computed fieldsThe @property decorator creates a computed field that reads like an attribute but calculates on access.
from datetime import datefrom 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_nameUsage:
person = Person.objects.first()print(person.full_name) # "John Doe"print(person.age) # 32from 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)@cached_propertyWhen a computed field is expensive to calculate, cache it during the instance lifetime using @cached_property.
from django.utils.functional import cached_propertyfrom 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 cachedprint(post.reading_time_minutes) # Returns cached value (no recalculation)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 datefrom 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:
Disadvantages:
| Approach | Use Case | Performance | Database |
|---|---|---|---|
@property | Simple read-only calculations | Slower (calculated each time) | Not stored |
@cached_property | Expensive calculations within instance | Medium (cached per instance) | Not stored |
Stored field with save() | Critical performance or needed in queries | Fastest (stored) | Takes storage |