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 typeMaps toNotes
CharFieldVARCHAR(n)Short text; max_length is required, not optional
TextFieldTEXTUnlimited-length text; no max_length needed
IntegerFieldINTEGERWhole numbers
BooleanFieldBOOLEANTrue/False; usually paired with default=
DateFieldDATECalendar date, no time component
DateTimeFieldDATETIME/TIMESTAMPDate and time together
ForeignKeyINTEGER + a foreign key constraintA "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.

Mental model: think of migrations as version control for your database schema, the same way Git is version control for your code — a linear, replayable history of every schema change.

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:

CommandWhat it does
python manage.py makemigrationsLooks at your models, compares them to the last known state, and generates a new migration file describing the difference
python manage.py migrateActually 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.

Note: This is correct, real Django output — but this website's own project doesn't run a Django server, so you won't see these commands actually execute on this page. Run them against your own TaskFlow project to see the tables appear.

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.

Next up: With real tables behind our three models, it's time to actually put data into them and read it back out. ORM CRUD Basics covers Create, Read, Update, and Delete using the Django ORM.