Why this helps
Each app has a clear owner, and teams can work on different apps without stepping on each other.
Models are the most important part of any Django project. Everything else - the admin panel, your API, your tests - depends on how well your models are designed.
When your models are clear and well-structured, everything else becomes easier to build and maintain.
Model design is not just about writing Python classes. It’s about making smart decisions upfront:
In short: think clearly first, then write code.
| 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) |
A typical online store would have these “things”:
Normalization just means: don’t store the same information in multiple places.
Not ideal:
Better approach:
Category model.Product to a Category.Why is this better?
This structure makes sense because:
If you put every model into one big app, things get messy fast. It’s much better to split models into separate apps by topic - one app per domain.
Why this helps
Each app has a clear owner, and teams can work on different apps without stepping on each other.
Cleaner codebase
Smaller files and each app only does one job well.
Safer long-term growth
Adding new features is easy because each app has its own space. Nothing gets crammed into one giant folder.
Here’s the simplest possible Django 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 is a database table.name, price, created_at - the columns in that table.__str__ - defines what label to show when this object appears in the admin panel, shell, or logs.These are the most common field types you’ll use day to day:
| Field | Typical use |
|---|---|
CharField | Short text like title, code, city |
TextField | Long text like description or notes |
IntegerField | Whole numbers |
BooleanField | True/False flags (yes/no) |
DateTimeField | Date + time values |
DecimalField | Money values |
UUIDField | Safe public IDs |
JSONField | Flexible structured data |
EmailField | Email with basic format check |
URLField | Web address 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 are extra settings you add to a field to control how it behaves:
name = models.CharField( max_length=255, null=False, blank=False, unique=True, db_index=True,)| Option | What it does |
|---|---|
null | Can this field store NULL in the database? |
blank | Can this field be left empty in a form? |
unique | No two rows can have the same value |
db_index | Makes lookups on this field faster |
default | Value to use if none is provided |
editable | Show or hide the field in admin forms |
help_text | A helpful hint shown next to the field in forms/admin |
Simple rule to remember:
null is about the database - can it store an empty value as NULL?blank is about forms and validation - can the user leave it empty?bio = models.TextField(blank=True, default="")This is the cleanest way to handle an optional text field. It stores an empty string instead of NULL, which avoids a lot of edge cases.
Choice fields let you restrict a field to a fixed list of values - perfect for things like status, role, or priority.
STATUS_CHOICES = [ ("P", "Pending"), ("C", "Completed"), ("X", "Cancelled"),]
status = models.CharField(max_length=1, choices=STATUS_CHOICES)This works but is easy to mistype and harder to reuse.
TextChoices (recommended)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 is better:
OrderStatus.PENDING is obvious, "P" is not.OrderStatus across models, forms, and logic.Relationships are how your models connect to each other. Getting this right is one of the most important parts of model design.
Use this when one record on each side maps to exactly one record on the other side.
Classic example: every User has one 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).You can access the profile from a user like this:
user.profileUse this when one parent record can have many child records.
Example: one category holds many products.
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 this when records on both sides can link to many records on the other side.
Example: a product can have many tags, and a tag can be on many products.
class Tag(models.Model): name = models.CharField(max_length=50)
class Product(models.Model): tags = models.ManyToManyField(Tag, related_name="products")Django automatically creates a hidden “join table” to track these connections. You don’t need to create it yourself.
throughSometimes the relationship itself has data. For example, when a student enrolls in a course, you also want to store when they enrolled and their enrollment status.
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")Here Enrollment is the “join table” but with extra fields on it.
on_delete - What Happens When a Related Record is Deleted?When you delete a parent record, Django needs to know what to do with the child records that pointed to it. That’s what on_delete controls.
| Option | What happens |
|---|---|
CASCADE | Delete all child records too |
PROTECT | Block the delete if child records exist |
SET_NULL | Set the link to null (field must have null=True) |
SET_DEFAULT | Set the link to the default value |
DO_NOTHING | Do nothing - can cause database errors if misused |
RESTRICT | Block delete when the record is referenced |
Safe beginner rule:
PROTECT for important records you don’t want accidentally deleted.CASCADE only when it truly makes sense to delete child records together.related_nameWhen you create a ForeignKey, Django automatically creates a way to go backwards - from category to all its products. By default this is called product_set, but you can rename it with related_name:
category = models.ForeignKey( Category, on_delete=models.CASCADE, related_name="products",)Now instead of writing:
category.product_set.all()You can write:
category.products.all() # much cleanerSometimes when you write a ForeignKey, the model you’re pointing to hasn’t been defined yet in the file. You can use a string instead of the class directly to solve this:
b = models.ForeignKey("B", on_delete=models.CASCADE)For a model in a different app:
b = models.ForeignKey("products.Product", on_delete=models.CASCADE)A model can also point to itself - useful for tree structures like an employee who has a manager (who is also an employee):
class Employee(models.Model): manager = models.ForeignKey( "self", null=True, blank=True, on_delete=models.SET_NULL, related_name="subordinates", )A generic relationship lets one model point to any other model - not just one specific one. For example, a Comment that can be attached to a Product, a BlogPost, or a 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")If your app lets users upload files or images, you’ll use these two fields.
FileField vs ImageField| Field | Use |
|---|---|
FileField | Any file type (pdf, doc, zip, etc.) |
ImageField | Image files (jpg, png, webp, etc.) |
ImageField needs the Pillow package installed:
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 sets the folder inside your media storage where files will be saved.
Add this to settings.py:
MEDIA_URL = "/media/"MEDIA_ROOT = BASE_DIR / "media"Add this to your project urls.py (only 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 you can find and manage files easily.The Meta class goes inside your model and holds extra settings that control how the model behaves at the database level - things like ordering, table names, and rules for uniqueness.
| Option | What it does |
|---|---|
ordering | Default sort order when you fetch records |
db_table | Custom name for the database table |
verbose_name | A human-readable name for the model (shown in admin) |
verbose_name_plural | The plural version of the human-readable name |
indexes | Add fast-lookup indexes on one or more fields |
constraints | Add rules like “this combination must be unique” |
get_latest_by | Which field to use when calling .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"] # newest first verbose_name = "Product" verbose_name_plural = "Products" get_latest_by = "created_at"Sometimes you want to make sure a combination of fields is unique - not just one field alone. For example, a student can only enroll in the same course once.
unique_together (you’ll still see this in older projects)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")]UniqueConstraint (recommended for new code)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", ) ]UniqueConstraint is more flexible and gives you more control, so use it for any new code you write.
An index is like a bookmark in a book - it helps the database find records much faster without scanning every row.
class Product(models.Model): slug = models.SlugField(unique=True) is_active = models.BooleanField(default=True, db_index=True)Use this when you only need to speed up lookups on a single 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 this when you need an index across multiple fields at once.
You can add custom behavior to your models by writing methods on them. The three most important ones are save(), clean(), and delete().
clean() Method - Validation Logicclean() is where you put rules that check if the data makes sense before it gets saved. For example: “the end date must be after the start date.”
Important: clean() only checks the data - it doesn’t save anything. It runs when you call model_instance.full_clean().
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): 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.")clean() Run?form.full_clean())instance.full_clean() in your codesave() - you have to call self.full_clean() inside your save() override if you want thatsave() Method - Persistence Logicsave() is called when you write a record to the database. You can 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): # Auto-generate slug from name if not set if not self.slug: self.slug = self.name.lower().replace(" ", "-")
# Don't allow negative prices if self.price < 0: self.price = 0
# Always call this at the end to actually save super().save(*args, **kwargs)clean() from Inside save()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() # Run validation before saving 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 these specific columns, not the whole row super().save(update_fields=['name', 'price', 'updated_at'], *args, **kwargs)This is more efficient when you only changed a couple of fields.
delete() Method - Removal Logicdelete() is called when a record is removed. You can 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): # Remove the file from disk before deleting the record if self.file: if os.path.isfile(self.file.path): os.remove(self.file.path)
super().delete(*args, **kwargs)Sometimes you don’t want to permanently delete data. Instead, you just mark it as deleted and hide it from users. This is called a “soft delete.”
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): # Don't actually remove - just mark it as deleted self.is_deleted = True self.deleted_at = timezone.now() self.save() # Notice: we do NOT call super().delete() herefrom 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.", self) super().delete(*args, **kwargs)clean, save, and delete in One Modelfrom 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): # Check that the data makes sense 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): # Normalize the email to lowercase before saving self.email = self.email.lower() self.full_clean() # Run validation super().save(*args, **kwargs)
def delete(self, *args, **kwargs): # Log the deletion before removing the record print(f"Deleting employee: {self.first_name}") super().delete(*args, **kwargs)Sometimes a field value doesn’t need to be stored in the database - it can be calculated on the fly from other data. Common examples: a person’s age from their birth date, a full name from first and last name, or a word count from a blog post’s content.
@property for Read-Only Calculated FieldsA @property looks and acts like a regular field, but it calculates its value every time you access it.
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_propertyIf a calculation is slow (like counting words in a long article), you don’t want to run it every single time you access it. @cached_property runs the calculation once and remembers the result for the lifetime of that object.
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 only runs once per object, then the result is stored return len(self.content.split())
@cached_property def reading_time_minutes(self): # Estimate 200 words per minute reading speed return max(1, self.word_count // 200)Usage:
post = BlogPost.objects.first()print(post.reading_time_minutes) # Calculated and storedprint(post.reading_time_minutes) # Returned from cache - no recalculationSometimes you need to store a calculated value so you can filter or sort by it in database queries. In that case, calculate it in save() and store it as a real field.
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): 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)Pros: You can filter like Person.objects.filter(age__gte=18) - very fast.
Cons: The stored age gets out of date over time and needs to be updated regularly.
| Approach | When to use it | Speed | Stored in DB? |
|---|---|---|---|
@property | Simple calculations you don’t need to query by | Recalculates every time | No |
@cached_property | Slow calculations you access multiple times per request | Calculates once, then cached | No |
Stored field with save() | When you need to filter/sort by it in the database | Fastest (already in the database) | Yes |