Module 3: Models.py — Database Design & Django ORM
🎯 What You Will Learn in This Module
By the end of this tutorial, you will:
- Understand how Django Models translate Python code into database tables
- Master every essential field type (CharField, TextField, IntegerField, DecimalField, etc.)
- Build database relationships: One-to-Many, Many-to-Many, and One-to-One
- Use Meta options to control table behavior, ordering, and indexing
- Create abstract base models to avoid repeating code
- Add custom properties and methods to your models
- Run migrations to sync your models with the database
- Design a real-world blog schema from scratch
📖 1. What is a Django Model?
A Model is a Python class that defines the structure of your database table. Instead of writing raw SQL like this:
1CREATE TABLE post ( 2 id SERIAL PRIMARY KEY, 3 title VARCHAR(200) NOT NULL, 4 content TEXT, 5 created_at TIMESTAMP DEFAULT NOW() 6);
You write Python:
1class Post(models.Model): 2 title = models.CharField(max_length=200) 3 content = models.TextField() 4 created_at = models.DateTimeField(auto_now_add=True)
Django's ORM (Object-Relational Mapper) automatically:
- Creates the correct SQL for your database (PostgreSQL, MySQL, SQLite, etc.)
- Handles data type conversions
- Generates migration files to track schema changes
- Provides a Python API to create, read, update, and delete records
💡 Think of it this way: The model is the blueprint, the database table is the building, and the ORM is the construction crew that builds it for you.
🏗️ 2. Creating Your First Model
Open blog/models.py. If it's empty, replace it with this foundation:
1from django.db import models 2from django.contrib.auth import get_user_model 3 4# Get the active user model (best practice instead of importing User directly) 5User = get_user_model()
Abstract Base Model: Don't Repeat Yourself
Most models need created_at and updated_at timestamps. Instead of adding these to every model, create an abstract base model:
1class TimestampModel(models.Model): 2 """ 3 Abstract base model that provides created_at and updated_at 4 to all child models automatically. 5 """ 6 created_at = models.DateTimeField(auto_now_add=True) 7 updated_at = models.DateTimeField(auto_now=True) 8 9 class Meta: 10 abstract = True # This model won't create its own table
What is abstract = True?
It tells Django: "Use this as a template, but don't create a database table for it." Other models will inherit from TimestampModel and automatically get these fields.
📝 3. Field Types Deep Dive
Django provides a rich set of field types. Each maps to a specific database column type.
Text Fields
1class Category(TimestampModel): 2 # CharField: For short strings. max_length is REQUIRED. 3 name = models.CharField(max_length=100, unique=True) 4 5 # SlugField: For URL-friendly strings (e.g., "my-first-post") 6 slug = models.SlugField(max_length=100, unique=True) 7 8 # TextField: For long content. No max_length required. 9 description = models.TextField(blank=True) 10 11 # BooleanField: True/False flags 12 is_active = models.BooleanField(default=True)
| Field | Best For | Required Args | Database Type |
|---|---|---|---|
CharField | Titles, names, short labels | max_length | VARCHAR |
TextField | Descriptions, articles, HTML | None | TEXT |
SlugField | URL segments | max_length | VARCHAR |
EmailField | Email addresses | max_length (default 254) | VARCHAR |
URLField | Web links | max_length (default 200) | VARCHAR |
💡 Tip: Use
blank=Trueto make a field optional in forms. Usenull=Trueto allow database NULL values. For text fields, usually justblank=Trueis enough.
Numeric Fields
1class Product(TimestampModel): 2 # DecimalField: For money, prices (NEVER use FloatField for money!) 3 price = models.DecimalField(max_digits=10, decimal_places=2) 4 5 # PositiveIntegerField: For counts, stock, quantities (0 and above) 6 stock_quantity = models.PositiveIntegerField(default=0) 7 8 # IntegerField: For whole numbers (can be negative) 9 discount_percent = models.IntegerField(default=0)
| Field | Best For | Key Arguments |
|---|---|---|
IntegerField | Any whole number | default=0 |
PositiveIntegerField | Counts, ages, quantities | default=0 |
PositiveSmallIntegerField | Ratings (1-5), percentages | default=0 |
DecimalField | Money, precise decimals | max_digits, decimal_places |
FloatField | Scientific calculations | Avoid for money (rounding errors) |
🚨 Critical: Always use
DecimalFieldfor prices and money.FloatFieldcauses rounding errors (e.g.,0.1 + 0.2 ≠ 0.3in floating-point math).
Date & Time Fields
1class Event(TimestampModel): 2 # auto_now_add: Set once when created 3 created_at = models.DateTimeField(auto_now_add=True) 4 5 # auto_now: Updated every time the record is saved 6 updated_at = models.DateTimeField(auto_now=True) 7 8 # Manual date fields (user-provided or calculated) 9 event_date = models.DateField() 10 start_time = models.DateTimeField() 11 12 # Optional date 13 ended_at = models.DateTimeField(null=True, blank=True)
| Field | Behavior | Use Case |
|---|---|---|
DateField | Stores date only | Birthdays, deadlines |
DateTimeField | Stores date + time | Post published, event start |
TimeField | Stores time only | Opening hours |
DurationField | Stores time span | Event length, task duration |
File & Media Fields
1class Post(TimestampModel): 2 # ImageField: Requires Pillow (pip install Pillow) 3 featured_image = models.ImageField( 4 upload_to='posts/%Y/%m/', # Organizes by year/month 5 blank=True, 6 null=True 7 ) 8 9 # FileField: For PDFs, documents, downloads 10 attachment = models.FileField( 11 upload_to='attachments/', 12 blank=True 13 )
| Field | Stores | Requirements |
|---|---|---|
FileField | Any file | None |
ImageField | Images only | Pillow library |
JSONField | JSON data | Modern databases (PostgreSQL) |
🔗 4. Database Relationships
Real-world data is connected. Django provides three relationship types.
One-to-Many: ForeignKey
A post belongs to one category, but a category has many posts.
1class Post(TimestampModel): 2 title = models.CharField(max_length=200) 3 slug = models.SlugField(max_length=200, unique=True) 4 5 # The author (User) can write MANY posts 6 # Each post has ONE author 7 author = models.ForeignKey( 8 User, 9 on_delete=models.CASCADE, 10 related_name='blog_posts' # user.blog_posts.all() 11 ) 12 13 # The category can contain MANY posts 14 # Each post belongs to ONE category 15 category = models.ForeignKey( 16 'Category', # String reference avoids import order issues 17 on_delete=models.SET_NULL, 18 null=True, # Allow posts without a category 19 related_name='posts' # category.posts.all() 20 )
on_delete behaviors explained:
| Behavior | What Happens | Use Case |
|---|---|---|
CASCADE | Delete the child object | Delete comments when post is deleted |
PROTECT | Prevent parent deletion | Can't delete category if posts exist |
SET_NULL | Set FK to NULL | Post survives if category is deleted |
SET_DEFAULT | Set FK to default value | Assign "Uncategorized" automatically |
DO_NOTHING | Take no action | Manual cleanup (advanced/risky) |
Many-to-Many
A post can have many tags, and a tag can belong to many posts.
1class Post(TimestampModel): 2 # ... other fields ... 3 4 tags = models.ManyToManyField( 5 'Tag', 6 related_name='posts', # tag.posts.all() 7 blank=True # Optional: posts don't need tags 8 )
Behind the scenes: Django creates a junction table (a third table with two foreign keys) to manage this relationship.
One-to-One
A user has exactly one profile, and a profile belongs to exactly one user.
1class Profile(TimestampModel): 2 user = models.OneToOneField( 3 User, 4 on_delete=models.CASCADE, 5 related_name='profile' # user.profile 6 ) 7 bio = models.TextField(max_length=500, blank=True) 8 birth_date = models.DateField(null=True, blank=True) 9 phone = models.CharField(max_length=15, blank=True) 10 avatar = models.ImageField(upload_to='avatars/', blank=True)
🏛️ 5. The Complete Blog Model
Now let's build the complete blog schema. Add this to blog/models.py:
1from django.db import models 2from django.contrib.auth import get_user_model 3 4User = get_user_model() 5 6 7class TimestampModel(models.Model): 8 """Abstract base model for created/updated timestamps.""" 9 created_at = models.DateTimeField(auto_now_add=True) 10 updated_at = models.DateTimeField(auto_now=True) 11 12 class Meta: 13 abstract = True 14 15 16class Category(TimestampModel): 17 name = models.CharField(max_length=100, unique=True) 18 slug = models.SlugField(max_length=100, unique=True) 19 description = models.TextField(blank=True) 20 is_active = models.BooleanField(default=True) 21 22 class Meta: 23 verbose_name_plural = 'Categories' # Fix admin pluralization 24 ordering = ['name'] # Default sort order 25 26 def __str__(self): 27 return self.name 28 29 30class Tag(TimestampModel): 31 name = models.CharField(max_length=50, unique=True) 32 slug = models.SlugField(max_length=50, unique=True) 33 34 def __str__(self): 35 return self.name 36 37 38class Post(TimestampModel): 39 STATUS_CHOICES = [ 40 ('draft', 'Draft'), 41 ('published', 'Published'), 42 ('archived', 'Archived'), 43 ] 44 45 title = models.CharField(max_length=200) 46 slug = models.SlugField(max_length=200, unique=True) 47 48 author = models.ForeignKey( 49 User, 50 on_delete=models.CASCADE, 51 related_name='blog_posts' 52 ) 53 54 category = models.ForeignKey( 55 Category, 56 on_delete=models.SET_NULL, 57 null=True, 58 related_name='posts' 59 ) 60 61 tags = models.ManyToManyField( 62 Tag, 63 related_name='posts', 64 blank=True 65 ) 66 67 content = models.TextField() 68 excerpt = models.TextField(max_length=500, blank=True) 69 70 featured_image = models.ImageField( 71 upload_to='posts/%Y/%m/', 72 blank=True, 73 null=True 74 ) 75 76 status = models.CharField( 77 max_length=10, 78 choices=STATUS_CHOICES, 79 default='draft' 80 ) 81 82 published_at = models.DateTimeField(null=True, blank=True) 83 view_count = models.PositiveIntegerField(default=0) 84 is_featured = models.BooleanField(default=False) 85 86 class Meta: 87 ordering = ['-created_at'] # Newest first 88 indexes = [ 89 models.Index(fields=['-created_at']), 90 models.Index(fields=['status', '-published_at']), 91 ] 92 93 def __str__(self): 94 return self.title 95 96 @property 97 def reading_time(self): 98 """Calculate estimated reading time in minutes.""" 99 words = len(self.content.split()) 100 return max(1, round(words / 200)) # 200 words per minute average
⚙️ 6. Meta Options Explained
The class Meta inside a model controls database-level behavior:
1class Meta: 2 db_table = 'custom_table_name' # Override default table name 3 ordering = ['-created_at'] # Default sort order 4 verbose_name = 'Blog Post' # Human-readable name (singular) 5 verbose_name_plural = 'Blog Posts' # Human-readable name (plural) 6 indexes = [ 7 models.Index(fields=['status']), # Speed up queries filtering by status 8 ] 9 unique_together = ['author', 'slug'] # Prevent duplicate slugs per author 10 constraints = [ 11 models.CheckConstraint( 12 check=models.Q(view_count__gte=0), 13 name='view_count_non_negative' 14 ) 15 ]
| Option | Purpose |
|---|---|
ordering | Default sort when querying (Post.objects.all()) |
indexes | Add database indexes for faster filtering |
verbose_name / verbose_name_plural | Names shown in Django Admin |
db_table | Custom database table name |
unique_together | Composite unique constraints |
abstract | Makes model a template (no table created) |
💡 Why indexes matter: Without an index, the database scans every row to find matches. With an index, it finds matches instantly — like the difference between searching a book page-by-page vs. using the table of contents.
🧮 7. Model Methods & Properties
Models can have methods just like any Python class:
1class Post(TimestampModel): 2 # ... fields ... 3 4 def __str__(self): 5 """String representation (used in admin, shell, etc.).""" 6 return self.title 7 8 @property 9 def reading_time(self): 10 """Computed property (acts like a field, but not stored in DB).""" 11 words = len(self.content.split()) 12 return max(1, round(words / 200)) 13 14 def publish(self): 15 """Custom method to publish the post.""" 16 self.status = 'published' 17 from django.utils import timezone 18 self.published_at = timezone.now() 19 self.save() 20 21 def increment_views(self): 22 """Efficiently increment view count without full save.""" 23 from django.db.models import F 24 Post.objects.filter(pk=self.pk).update(view_count=F('view_count') + 1) 25 self.refresh_from_db()
@property vs. regular method:
@property: Acts like a field (call it aspost.reading_time, no parentheses)- Regular method: Requires parentheses (
post.publish())
🔄 8. Migrations: Syncing Models to Database
Models are just Python code until you create migrations — files that tell Django how to update the database schema.
The Migration Workflow
1# Step 1: Create migration files based on model changes 2python manage.py makemigrations 3 4# Step 2: Apply migrations to the database 5python manage.py migrate 6 7# Step 3: (Optional) View the SQL Django will execute 8python manage.py sqlmigrate blog 0001
What happens when you run makemigrations?
- Django compares your current
models.pywith the last migration - It generates a new file in
blog/migrations/0002_....py - This file contains Python instructions to alter the database
What happens when you run migrate?
- Django reads all unapplied migration files
- Executes the corresponding SQL commands on your database
- Updates the
django_migrationstable to track what was applied
Viewing Migration Status
1# Show all migrations and whether they're applied 2python manage.py showmigrations 3 4# Output: 5# blog 6# [X] 0001_initial ← [X] means applied 7# [ ] 0002_add_tags ← [ ] means pending
🧪 9. Practice Task — Build a Product Model
Apply what you learned by creating a complete e-commerce style model.
Task Instructions
Create a Product model in blog/models.py (or a new shop/models.py app) with these requirements:
| Field | Type | Constraints |
|---|---|---|
name | CharField | max_length=200, unique=True |
slug | SlugField | max_length=200, unique=True |
description | TextField | blank=True |
price | DecimalField | max_digits=10, decimal_places=2 |
stock | PositiveIntegerField | default=0 |
category | ForeignKey | To Category, on_delete=models.CASCADE |
is_available | BooleanField |
Bonus:
- Add a
Metaclass withordering = ['-created_at'] - Add a
@propertycalledis_in_stockthat returnsTrueifstock > 0 - Add a
__str__method returning the product name
Solution
1class Product(TimestampModel): 2 name = models.CharField(max_length=200, unique=True) 3 slug = models.SlugField(max_length=200, unique=True) 4 description = models.TextField(blank=True) 5 price = models.DecimalField(max_digits=10, decimal_places=2) 6 stock = models.PositiveIntegerField(default=0) 7 category = models.ForeignKey( 8 Category, 9 on_delete=models.CASCADE, 10 related_name='products' 11 ) 12 is_available = models.BooleanField(default=True) 13 14 class Meta: 15 ordering = ['-created_at'] 16 17 def __str__(self): 18 return self.name 19 20 @property 21 def is_in_stock(self): 22 return self.stock > 0
After creating the model, run:
1python manage.py makemigrations 2python manage.py migrate
❌ Common Mistakes & How to Fix Them
| Mistake | Error / Symptom | Solution |
|---|---|---|
Forgot on_delete on ForeignKey | TypeError: __init__() missing required argument: 'on_delete' | Always add on_delete=models.CASCADE (or another option) |
CharField without max_length | TypeError: CharField() missing required argument: 'max_length' | max_length is required for CharField and SlugField |
ImageField without Pillow | ImproperlyConfigured: Pillow is required | Run pip install Pillow |
| Changed model, didn't migrate | OperationalError: no such column | Run makemigrations then migrate |
unique=True with duplicate data | IntegrityError: UNIQUE constraint failed | Remove duplicates before adding unique constraint, or use a data migration |
| Circular imports | ImportError: cannot import name | Use string references like 'Category' instead of importing the class |
| with mutable objects |
✅ Module 3 Summary
| Concept | Key Takeaway |
|---|---|
| Model | Python class = Database table |
| Field | Model attribute = Database column |
CharField | Short text, requires max_length |
TextField | Long text, no length limit |
DecimalField | Use for money (max_digits, decimal_places) |
ForeignKey | One-to-Many relationship |
ManyToManyField | Many-to-Many relationship |
OneToOneField | One-to-One relationship |
on_delete | Defines behavior when related object is deleted |
related_name | How to access reverse relationships |
class Meta | Controls table behavior, ordering, indexes |
@property | Computed attributes that act like fields |
🚪 What's Next?
In Module 4, we will dive deeper into Database Relationships — building a complete Library system with advanced ForeignKey patterns, intermediate Many-to-Many models (with extra fields), and understanding on_delete behaviors with real-world examples. You'll also learn about reverse queries and the power of related_name.
Before proceeding, make sure:
- Your
blog/models.pycontains theCategory,Tag, andPostmodels - You've run
makemigrationsandmigratesuccessfully - You understand the difference between
ForeignKey,ManyToManyField, andOneToOneField - You completed the Product model practice task
- You can explain what
related_namedoes
Your database schema is taking shape. Ready to master relationships? 🔗🚀
