Django models and migrations: what developers should know
Schema management is one of the first hurdles teams face when scaling a Django application beyond a prototype. Getting it wrong leads to downtime, data loss, or deployment failures—all preventable with a clear workflow. Django models and migrations give us a Python-first approach to defining your database schema, tracking changes as versions, and rolling them out safely.
Why Django models matter
A Django model is your single source of truth for a database table. Instead of writing raw SQL CREATE TABLE statements, you define fields in Python classes. Django then handles the translation to your specific database dialect—PostgreSQL, MySQL, SQLite, or others.
This matters because:
- Your schema lives in version control alongside your application code.
- You avoid manual SQL drift—the schema file and actual database stay in sync through migrations.
- New team members understand your data structure by reading Python, not SQL documentation.
- Django's ORM uses the model definitions to validate queries at development time.
Models are not optional infrastructure; they're the foundation of how Django applications interact with data.
A model—fields map to columns
Let's define a concrete example: a Product model for an e-commerce backend. Each field in the model class becomes a column in the database table.
from django.db import models
class Product(models.Model):
name = models.CharField(max_length=120)
description = models.TextField(blank=True, null=True)
price = models.DecimalField(max_digits=10, decimal_places=2)
cost = models.DecimalField(max_digits=10, decimal_places=2, null=True)
sku = models.CharField(max_length=50, unique=True)
stock = models.IntegerField(default=0)
is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'products'
indexes = [
models.Index(fields=['sku']),
models.Index(fields=['is_active', 'created_at']),
]
def __str__(self):
return self.nameNotice the field types: CharField for strings, DecimalField for money (not Float—crucial for financial data), IntegerField for counts, BooleanField for flags, DateTimeField for timestamps. Each maps to a native database type.
The auto_now_add=True on created_at tells Django to set the value once when the record is inserted. The auto_now=True on updated_at updates the timestamp every time the row changes. This removes the burden of manually managing these fields in your application logic.
The Meta class defines table metadata: the actual table name and database indexes. Adding indexes upfront prevents N+1 query problems and slow reports later. Here we've indexed SKU (used for lookups) and a compound index on active status and creation date (common for filtering).
Field options like null=True, blank=True, unique=True, and default translate directly to database constraints and application validation. Setting null=True creates a nullable column; unique=True adds a unique constraint; defaults save you from null checks in views.
Create migrations—python manage.py makemigrations creates versioned files
Once you've defined or changed a model, you generate a migration file. This is a Python script that documents exactly what changed and how to apply it to the database.
python manage.py makemigrationsDjango compares your current models against the last migration and creates a new migration file in your app's migrations/ directory. If you added the Product model and no migration exists yet, you might see:
# products/migrations/0001_initial.py
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = []
operations = [
migrations.CreateModel(
name='Product',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=120)),
('description', models.TextField(blank=True, null=True)),
('price', models.DecimalField(decimal_places=2, max_digits=10)),
('cost', models.DecimalField(decimal_places=2, max_digits=10, null=True)),
('sku', models.CharField(max_length=50, unique=True)),
('stock', models.IntegerField(default=0)),
('is_active', models.BooleanField(default=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'db_table': 'products',
},
),
migrations.AddIndex(
model_name='product',
index=models.Index(fields=['sku'], name='products_sku_idx'),
),
migrations.AddIndex(
model_name='product',
index=models.Index(fields=['is_active', 'created_at'], name='products_is_active_idx'),
),
]Django auto-generates an id primary key unless you specify otherwise. The migration is now a versioned artifact: numbered, sequenced, and repeatable. If you later add a discount field, Django generates a new file like 0002_product_discount.py with only the changes.
This versioning is critical in teams. Every migration file is reviewed in code review, committed to Git, and becomes part of your deployment pipeline. You can see the exact evolution of your schema over months or years.
Apply them—python manage.py migrate—run in CI and on deploy
Migrations are inert until you run them against a database.
python manage.py migrateThis command applies all unapplied migrations in order. Django tracks which migrations have been run in a special table called django_migrations. If you run migrate twice, the second run is a no-op—only new migrations execute.
In practice, your deployment workflow looks like:
- Pull new code (which may include new migration files).
- Run
python manage.py migratebefore starting the application server. - Start Django.
On staging, this is safe and repeatable. On production, it's your moment of truth. If a migration fails—due to a data integrity issue, a long-running ALTER TABLE on a huge table, or a constraint violation—your deployment stops. That's by design. Better to catch it before users hit errors.
In CI pipelines, you typically:
- Run migrations against a test database to validate they execute cleanly.
- Run your test suite (which uses the migrated schema).
- Only deploy if all tests pass.
This catches migration issues early. A common practice is to run migrations in a separate step before restarting the application server, giving you a window to roll back if needed.
Review generated SQL—sqlmigrate shows what will run against production
Before deploying to production, always inspect the actual SQL your migration will execute:
python manage.py sqlmigrate products 0001This outputs the raw SQL for migration 0001 in the products app (without executing it):
BEGIN;
--
-- Create model Product
--
CREATE TABLE "products" (
"id" bigint NOT NULL PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY,
"name" varchar(120) NOT NULL,
"description" text NULL,
"price" numeric(10, 2) NOT NULL,
"cost" numeric(10, 2) NULL,
"sku" varchar(50) NOT NULL UNIQUE,
"stock" integer NOT NULL DEFAULT 0,
"is_active" boolean NOT NULL DEFAULT true,
"created_at" timestamp with time zone NOT NULL,
"updated_at" timestamp with time zone NOT NULL
);
CREATE INDEX "products_sku_idx" ON "products" ("sku");
CREATE INDEX "products_is_active_idx" ON "products" ("is_active", "created_at");
COMMIT;This is PostgreSQL output (your database dialect may vary). Use this to catch issues:
- Index bloat: Are you indexing columns that rarely filter queries?
- Lock risk: Will an ALTER TABLE lock the table for seconds on a table with millions of rows?
- Data loss: Will a column drop accidentally remove data?
- Constraint conflicts: Will adding a unique constraint fail due to existing duplicates?
For large tables, production DBAs often prefer to add indexes separately using CONCURRENTLY syntax or schedule long-running migrations during low-traffic windows. Reviewing SQL upfront lets you plan for this.
Common mistakes
Editing migration files by hand. Never manually edit a migration file after creating it (except to fix a critical bug). If you realize you made a mistake, delete the migration file and run makemigrations again. Editing migrations breaks the linearity that makes them reliable.
Using null=True for strings. CharField and TextField should almost never have null=True. Use blank=True instead. String fields default to empty string, not NULL, which is cleaner in queries. Reserve null=True for numeric and date fields where NULL is semantically meaningful (e.g., cost might be null if unknown).
Forgetting to commit migration files to version control. A migration without version control is not a migration—it's a one-off script. Always commit migration files. Your team and your future self will thank you when you need to audit or revert a change.
Running makemigrations in production. Never run makemigrations on a production server. Generate migrations locally or in CI, test them, and deploy them. Production should only run migrate, never make.
Large data transformation without data checks. If a migration includes data manipulation (e.g., populating a new column from an old one), write a separate data migration and test it thoroughly on a copy of production data first. A mistake here can corrupt production data silently.
Key takeaways
- Define your database schema in Python models, not raw SQL. This keeps your schema in version control and makes it portable across databases.
- Use makemigrations to generate migration files; each is a versioned, reproducible change set.
- Run migrate during deployment to apply migrations in order. Django tracks what's been applied, so re-running is safe.
- Always review the SQL output with sqlmigrate before deploying to production. Catch lock risks, index bloat, and data loss early.
- Commit all migration files to version control. Never edit or delete migrations after creation; redo instead.
- Use DecimalField for money, not Float. Use indexes on frequently filtered or joined columns. Use auto_now_add and auto_now for timestamps to remove manual management.
- Test migrations against a staging database that mirrors production size and complexity. A migration that runs fast on 1000 rows may lock production with 100 million rows.






Comments
Loading comments…