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? ๐๐
