Models & Migrations
This lesson opens what's arguably the most important part of the whole track: Django's data
layer. Everything from here through several upcoming lessons builds on one idea — a
model. A model is a Python class that describes a database table. Each class
attribute you define on it becomes a column in that table, and each field type you choose
determines exactly what kind of data that column can hold. Once a model exists, Django can
create the matching table for you, and you never have to hand-write a CREATE TABLE
statement. We'll build out the three models that everything else in this track relies on:
Project, Tag, and Task.
Field Types: A Reference Table
Django ships with a field type for almost every common column type a relational database supports. Here are the ones you'll reach for constantly:
| Field type | Maps to | Notes |
|---|---|---|
CharField | VARCHAR(n) | Short text; max_length is required, not optional |
TextField | TEXT | Unlimited-length text; no max_length needed |
IntegerField | INTEGER | Whole numbers |
BooleanField | BOOLEAN | True/False; usually paired with default= |
DateField | DATE | Calendar date, no time component |
DateTimeField | DATETIME/TIMESTAMP | Date and time together |
ForeignKey | INTEGER + a foreign key constraint | A "many rows point to one row" relationship — its own deep dive in ForeignKey Relationships |
The mapping to an actual SQL column type isn't just trivia — it's what makes models useful.
When you write models.CharField(max_length=200), Django knows to generate a
VARCHAR(200) column in PostgreSQL, or the equivalent in MySQL or SQLite, without
you writing a line of SQL yourself.
Full Worked Definition: Project, Tag, and Task
Here are all three TaskFlow models defined together, exactly as they'll be used throughout the rest of this track.
# taskflow/models.py
from django.db import models
from django.contrib.auth.models import User
class Project(models.Model):
name = models.CharField(max_length=120)
description = models.TextField(blank=True)
owner = models.ForeignKey(User, on_delete=models.CASCADE)
def __str__(self):
return self.name
class Tag(models.Model):
name = models.CharField(max_length=50, unique=True)
def __str__(self):
return self.name
class Task(models.Model):
PRIORITY_CHOICES = [
(1, 'Low'),
(2, 'Medium'),
(3, 'High'),
]
title = models.CharField(max_length=200)
description = models.TextField(blank=True)
due_date = models.DateField(null=True, blank=True)
priority = models.IntegerField(choices=PRIORITY_CHOICES, default=2)
completed = models.BooleanField(default=False)
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name='tasks')
tags = models.ManyToManyField(Tag, blank=True)
def __str__(self):
return self.title
Two fields deserve a quick preview here, even though each gets its own dedicated lesson later.
Task.project is a ForeignKey — every task belongs to exactly one
project, but a project can have many tasks. Task.tags is a
ManyToManyField — a task can carry several tags (urgent,
backend, bug...) and any given tag can be attached to many different
tasks. For now, just notice that Django lets you express both kinds of relationships as plain
class attributes, no join-table SQL required on your part.
What a Migration Actually Is
Defining class Task(models.Model): in Python does not, by itself, create a table
anywhere. Your database has no idea this model exists until Django is told to synchronize the
two. That synchronization step is called a migration.
Concretely, a migration is a generated Python file that describes exactly what changed since
the last migration — "create a table named taskflow_task with these columns," or
later, "add a new column named estimated_hours to that table." Django keeps a
record, inside your database itself, of which migrations have already been applied. That's
what lets it figure out, every time you run a command, exactly which pending changes still need
to be applied — and it's also what lets every developer on a team, and every deployment
environment, arrive at the identical database schema by running the same migration files.
The Two-Step Workflow: makemigrations and migrate
Creating or changing a table is always a two-step process, and the two steps are deliberately separate commands:
| Command | What it does |
|---|---|
python manage.py makemigrations | Looks at your models, compares them to the last known state, and generates a new migration file describing the difference |
python manage.py migrate | Actually applies any pending migration files to the database, running the real schema changes |
Splitting these into two commands matters: makemigrations only ever touches files
in your project (safe to run anytime, review, and even discard), while migrate is
the step that actually changes your live database. Keeping them separate means you always get a
chance to review the generated migration file before it touches real data.
Full Worked Example: Creating the TaskFlow Tables
With the three models above saved in taskflow/models.py, and 'taskflow'
added to INSTALLED_APPS, here's the full sequence:
python manage.py makemigrations taskflow
Migrations for 'taskflow':
taskflow/migrations/0001_initial.py
- Create model Project
- Create model Tag
- Create model Task
python manage.py migrate
Operations to perform:
Apply all migrations: admin, auth, contenttypes, sessions, taskflow
Running migrations:
Applying taskflow.0001_initial... OK
After that, three real tables exist in your database — taskflow_project,
taskflow_tag, and taskflow_task — plus a hidden join table Django
generated automatically for the tags ManyToMany relationship. None of this required
writing a single CREATE TABLE statement by hand.
Changing a Model Later: Adding a New Field
Real projects evolve. Suppose a few weeks into building TaskFlow, you decide each task should track an estimate of how long it will take. You add a field to the model:
# taskflow/models.py
class Task(models.Model):
# ... existing fields ...
estimated_hours = models.IntegerField(null=True, blank=True)
Run the same two commands again — Django only generates and applies the difference, not the whole table from scratch:
python manage.py makemigrations taskflow
Migrations for 'taskflow':
taskflow/migrations/0002_task_estimated_hours.py
- Add field estimated_hours to task
python manage.py migrate
Running migrations:
Applying taskflow.0002_task_estimated_hours... OK
Notice the second migration file is numbered 0002 and named after exactly what
changed. Django's migration history for the taskflow app is now two files long,
and running migrate on any fresh database (a new developer's laptop, a staging
server) replays both of them in order to arrive at the identical, current schema.
Previewing SQL with sqlmigrate
Before trusting a migration against a real database, it's often worth seeing exactly what SQL
it's about to run. The sqlmigrate command prints that SQL without executing
anything — a genuinely useful habit before applying migrations in production.
python manage.py sqlmigrate taskflow 0001
BEGIN;
CREATE TABLE "taskflow_project" (
"id" integer NOT NULL PRIMARY KEY AUTOINCREMENT,
"name" varchar(120) NOT NULL,
"description" text NOT NULL,
"owner_id" integer NOT NULL REFERENCES "auth_user" ("id")
);
CREATE TABLE "taskflow_tag" (
"id" integer NOT NULL PRIMARY KEY AUTOINCREMENT,
"name" varchar(50) NOT NULL UNIQUE
);
CREATE TABLE "taskflow_task" (
"id" integer NOT NULL PRIMARY KEY AUTOINCREMENT,
"title" varchar(200) NOT NULL,
"description" text NOT NULL,
"due_date" date NULL,
"priority" integer NOT NULL,
"completed" bool NOT NULL,
"project_id" integer NOT NULL REFERENCES "taskflow_project" ("id")
);
COMMIT;
This is exactly what migrate would execute — no more, no less. It's a reliable way
to confirm a migration does what you expect before letting it touch a production database.