Skip to content

Django Models

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:

  • What “things” does your app work with? (e.g. users, products, orders)
  • What information does each “thing” need to store?
  • How do these “things” connect to each other?
  • What values should be allowed or blocked?
  • How will your code stay easy to read as the app grows?

In short: think clearly first, then write code.

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)

A typical online store would have these “things”:

  • User
  • Product
  • Category
  • Order
  • OrderItem
  • Payment

Normalization just means: don’t store the same information in multiple places.

Not ideal:

  • Save the category name as text inside every product record.

Better approach:

  • Create a separate Category model.
  • Link each Product to a Category.

Why is this better?

  • Cleaner code
  • If a category name changes, you update it in one place - not in thousands of product records
  • Fewer mistakes overall
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 makes sense because:

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

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.

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

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.name
  • class 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:

FieldTypical use
CharFieldShort text like title, code, city
TextFieldLong text like description or notes
IntegerFieldWhole numbers
BooleanFieldTrue/False flags (yes/no)
DateTimeFieldDate + time values
DecimalFieldMoney values
UUIDFieldSafe public IDs
JSONFieldFlexible structured data
EmailFieldEmail with basic format check
URLFieldWeb address values
SlugFieldURL-friendly short text
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 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,
)
OptionWhat it does
nullCan this field store NULL in the database?
blankCan this field be left empty in a form?
uniqueNo two rows can have the same value
db_indexMakes lookups on this field faster
defaultValue to use if none is provided
editableShow or hide the field in admin forms
help_textA 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.

Section titled “Modern Style with 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:

  • Much clearer to read - OrderStatus.PENDING is obvious, "P" is not.
  • Much harder to make a typo.
  • You can reuse 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 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 -> one profile.
  • If the user is deleted, the profile is also deleted automatically (CASCADE).

You can access the profile from a user like this:

user.profile

Use 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.

Many-to-Many with Extra Fields using through

Section titled “Many-to-Many with Extra Fields using through”

Sometimes 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.

Section titled “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.

OptionWhat happens
CASCADEDelete all child records too
PROTECTBlock the delete if child records exist
SET_NULLSet the link to null (field must have null=True)
SET_DEFAULTSet the link to the default value
DO_NOTHINGDo nothing - can cause database errors if misused
RESTRICTBlock delete when the record is referenced

Safe beginner rule:

  • Use PROTECT for important records you don’t want accidentally deleted.
  • Use CASCADE only when it truly makes sense to delete child records together.

When 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 cleaner

Model References, Circular Relations, and Self Relations

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

Sometimes 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",
)
flowchart TD M[Manager: Employee] --> S1[Subordinate 1] M --> S2[Subordinate 2]

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 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")

FileField and ImageField for Handling Uploads

Section titled “FileField and ImageField for Handling Uploads”

If your app lets users upload files or images, you’ll use these two fields.

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

ImageField needs the Pillow package installed:

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 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 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])

Best Practices for File Uploads in Production

Section titled “Best Practices for File Uploads in Production”
  1. Never store user uploads inside your source code folders.
  2. Use clear upload_to paths so you can find and manage files easily.
  3. Always validate file size, and validate file type when security matters.
  4. In production, use a cloud storage service (like an S3-compatible bucket) instead of your server’s local disk.
  5. Never trust an uploaded file’s name for any 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 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.

  • Keeps all database-level rules in one clear place.
  • Adds indexes that make your queries faster.
  • Makes data rules explicit so there are fewer surprises.
OptionWhat it does
orderingDefault sort order when you fetch records
db_tableCustom name for the database table
verbose_nameA human-readable name for the model (shown in admin)
verbose_name_pluralThe plural version of the human-readable name
indexesAdd fast-lookup indexes on one or more fields
constraintsAdd rules like “this combination must be unique”
get_latest_byWhich 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.

Old Style - unique_together (you’ll still see this in older projects)

Section titled “Old Style - 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")]
Section titled “Modern Style - 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.

Option 1 - Directly on a field (for single fields)

Section titled “Option 1 - Directly on a field (for single fields)”
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.

Option 2 - In Meta (for multiple fields or advanced cases)

Section titled “Option 2 - In Meta (for multiple fields or advanced cases)”
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() 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 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):
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 inside Django forms (via form.full_clean())
  • Manually when you call instance.full_clean() in your code
  • Not automatically when you call save() - you have to call self.full_clean() inside your save() override if you want that

save() is called when you write a record to the database. You can override it to:

  • Automatically set or calculate field values before saving
  • Trigger something else (like clearing a cache)
  • Enforce rules right before data hits the database
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):
# 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)
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)

Saving Only Specific Fields (More Efficient)

Section titled “Saving Only Specific Fields (More Efficient)”
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() is called when a record is removed. You can override it to:

  • Delete related files from storage
  • Log that the record was deleted (for auditing)
  • Block deletion under certain conditions

Basic Example - Delete the Uploaded File Too

Section titled “Basic Example - Delete the Uploaded File Too”
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):
# 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)

Soft Delete - Mark as Deleted Instead of Removing

Section titled “Soft Delete - Mark as Deleted Instead of Removing”

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 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):
# 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() here
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.", self)
super().delete(*args, **kwargs)

Putting It All Together - clean, save, and delete in One Model

Section titled “Putting It All Together - clean, save, and delete in One Model”
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):
# 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.

Using @property for Read-Only Calculated Fields

Section titled “Using @property for Read-Only Calculated Fields”

A @property looks and acts like a regular field, but it calculates its value every time you access it.

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 Expensive Calculations with @cached_property

Section titled “Caching Expensive Calculations with @cached_property”

If 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_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 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 stored
print(post.reading_time_minutes) # Returned from cache - no recalculation

Storing a Calculated Value in the Database

Section titled “Storing a Calculated Value in the Database”

Sometimes 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 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):
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.

ApproachWhen to use itSpeedStored in DB?
@propertySimple calculations you don’t need to query byRecalculates every timeNo
@cached_propertySlow calculations you access multiple times per requestCalculates once, then cachedNo
Stored field with save()When you need to filter/sort by it in the databaseFastest (already in the database)Yes