Skip to main content

πŸ—ƒοΈ Django Model Definition

A Django model is a single Python class that becomes a database table, a validation layer, an admin screen, and a query API all at once. Master model definitions and you have mastered the foundation of every Django app you will ever build.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Define a Django 5 model class and map its fields to database columns
  • Choose the right field type and options (null, blank, choices, default, validators) for each piece of data
  • Model the three relationships β€” one-to-many, many-to-many, and one-to-one β€” and pick the correct on_delete behavior
  • Use abstract base classes, Meta options, model methods, and constraints to keep models clean and correct
  • Apply model best practices for readable, maintainable data layers

Estimated Time: 45–60 minutes  β€’  Difficulty: Intermediate

Hands-on: Design and write the full set of models for a small blog application.

In This Lesson

What a Model Really Is

In Django, a model is a Python class that subclasses django.db.models.Model. Each model maps to one database table, and each class attribute you declare as a Field maps to one column. You describe your data in Python; Django generates the SQL, builds an object-oriented query API, wires up the admin, and produces forms β€” all from that single definition.

πŸ’‘ A useful analogy: A model is like an architect's blueprint. The blueprint isn't the building β€” it's the authoritative description that everyone works from. Change the blueprint and the plumbing, wiring, and inspectors all follow. Change a Django model and its table, admin, forms, and validation all follow too.

This is the payoff of the DRY (Don't Repeat Yourself) principle: you define the shape of your data once, and Django derives everything else from it.

One model definition powers five things A central Django model box feeds five outputs: the database schema, data validation, a Python query API, the admin interface, and auto-generated forms. models.py one class Database schema Validation Query API Admin interface Forms
Figure 1 β€” Define a model once and Django derives the table, validation, query API, admin, and forms from it.

Defining Your First Model

Models live in an app's models.py. Here is a small, complete, modern example. Note the TextChoices enum (the current, readable way to declare choices in Django) and the __str__ method that gives each row a human-readable label.

from django.db import models
from django.conf import settings
from django.utils import timezone


class Article(models.Model):
    class Status(models.TextChoices):
        DRAFT = "draft", "Draft"
        PUBLISHED = "published", "Published"
        ARCHIVED = "archived", "Archived"

    title = models.CharField(max_length=200)
    slug = models.SlugField(max_length=200, unique=True)
    content = models.TextField()
    status = models.CharField(
        max_length=10,
        choices=Status.choices,
        default=Status.DRAFT,
    )
    published_at = models.DateTimeField(default=timezone.now)
    author = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.CASCADE,
        related_name="articles",
    )
    is_featured = models.BooleanField(default=False)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    def __str__(self):
        return self.title

πŸ“– Key Terms

Field: a class attribute (an instance of a Field subclass) that becomes one database column.

Primary key: the unique row identifier. If you don't declare one, Django adds an id field automatically (a BigAutoField by default in modern Django).

settings.AUTH_USER_MODEL: the recommended way to reference the user model, so your code keeps working even with a custom user model.

⚠️ auto_now vs auto_now_add

auto_now_add=True stamps the time once, when the row is first created (great for created_at). auto_now=True re-stamps on every save (great for updated_at). Both make the field non-editable in forms.

Field Types & Options

Each field type maps to a database column type and carries its own validation. Pick the most specific field that fits your data β€” a specific field gives you free validation and a better admin widget.

CategoryCommon fieldsUse for
TextCharField, TextField, SlugField, EmailField, URLFieldShort strings, long prose, URL slugs, validated emails/URLs
NumbersIntegerField, PositiveIntegerField, DecimalField, FloatFieldCounts, money (Decimal!), measurements
Date/timeDateField, TimeField, DateTimeField, DurationFieldTimestamps, schedules, elapsed time
BooleanBooleanFieldTrue/false flags (use null=True for a nullable boolean)
FilesFileField, ImageFieldUploaded documents and images
SpecialJSONField, UUIDField, GenericIPAddressFieldStructured blobs, UUID keys, IP addresses

⚠️ Money = DecimalField, never FloatField

Floating-point math introduces rounding errors (0.1 + 0.2 != 0.3). For prices and any currency, always use DecimalField(max_digits=10, decimal_places=2).

Field options

Options are keyword arguments that tune a field's behavior. The most important ones:

from django.core.validators import MinValueValidator, MaxValueValidator


class Product(models.Model):
    name = models.CharField(max_length=120, db_index=True)
    sku = models.CharField(max_length=32, unique=True)
    description = models.TextField(blank=True)                 # blank OK in forms
    price = models.DecimalField(max_digits=10, decimal_places=2)
    discount = models.DecimalField(
        max_digits=5, decimal_places=2, null=True, blank=True,  # optional in DB and forms
    )
    rating = models.IntegerField(
        default=0,
        validators=[MinValueValidator(0), MaxValueValidator(5)],
        help_text="A score from 0 to 5.",
    )

πŸ’‘ null vs blank β€” a classic gotcha

null is about the database (can this column store NULL?). blank is about form validation (can this field be left empty?). They are independent. For text fields, prefer blank=True alone and let "empty" be an empty string "" rather than NULL β€” that way there is only one representation of "no value." Add null=True only for non-text fields that genuinely have no value.

Model Relationships

Real data is connected. Django models three kinds of connection with three field types.

One-to-many β€” ForeignKey

One author writes many books; each book has one author. The ForeignKey lives on the "many" side.

class Author(models.Model):
    name = models.CharField(max_length=100)

    def __str__(self):
        return self.name


class Book(models.Model):
    title = models.CharField(max_length=200)
    author = models.ForeignKey(
        Author,
        on_delete=models.CASCADE,
        related_name="books",   # author.books.all()
    )

    def __str__(self):
        return self.title

πŸ“– on_delete β€” what happens to the children?

Required on every ForeignKey. It decides what happens to Book rows when their Author is deleted:

  • CASCADE β€” delete the books too
  • PROTECT β€” block the delete if any book references this author
  • SET_NULL β€” set author to NULL (requires null=True)
  • SET_DEFAULT β€” set it to the field's default
  • RESTRICT β€” like PROTECT, but allows deletion when another cascading path also removes the row
erDiagram AUTHOR ||--o{ BOOK : "writes" AUTHOR { string name } BOOK { string title int author_id }

Many-to-many β€” ManyToManyField

An article can have many tags, and a tag can label many articles. Django creates a hidden join table for you.

class Tag(models.Model):
    name = models.CharField(max_length=50)
    slug = models.SlugField(unique=True)

    def __str__(self):
        return self.name


class Article(models.Model):
    title = models.CharField(max_length=200)
    tags = models.ManyToManyField(Tag, related_name="articles", blank=True)

Put the ManyToManyField on either model β€” Django exposes the relationship from both sides. Use blank=True (not null=True) to make a many-to-many optional; a join table with zero rows already means "no relations."

One-to-one β€” OneToOneField

A user has exactly one profile. This is effectively a ForeignKey with unique=True and is the standard way to extend a model without touching it.

class Profile(models.Model):
    user = models.OneToOneField(
        settings.AUTH_USER_MODEL,
        on_delete=models.CASCADE,
        related_name="profile",
    )
    bio = models.TextField(blank=True)
    birth_date = models.DateField(null=True, blank=True)

    def __str__(self):
        return f"Profile for {self.user}"

βœ… Choosing a relationship

Ask "how many of B can one A have, and vice versa?" Oneβ†’many = ForeignKey. Many↔many = ManyToManyField. Exactly one↔one = OneToOneField.

Model Inheritance

Django offers three inheritance styles. By far the most common β€” and usually the one you want β€” is the abstract base class for sharing fields without creating an extra table.

Abstract base classes (share fields, no table)

class TimeStampedModel(models.Model):
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        abstract = True          # no table is created for this model


class Article(TimeStampedModel):
    title = models.CharField(max_length=200)


class Comment(TimeStampedModel):
    body = models.TextField()

Both Article and Comment now have created_at/updated_at columns, defined in one place.

Multi-table inheritance (each model gets a table)

Here a child model links to its parent with an automatic one-to-one. Both tables exist. Useful, but adds a JOIN on every access β€” reach for it deliberately.

class Place(models.Model):
    name = models.CharField(max_length=100)
    address = models.CharField(max_length=200)


class Restaurant(Place):          # gets its own table + link to Place
    serves_pizza = models.BooleanField(default=False)

Proxy models (same table, new behavior)

A proxy changes Python behavior (default ordering, extra methods) without changing the schema.

class OrderedArticle(Article):
    class Meta:
        proxy = True
        ordering = ["title"]

πŸ’‘ Default to abstract

When in doubt, use abstract base classes. They give you code reuse with zero extra queries. Reserve multi-table inheritance for genuine "is-a" specializations you must query independently.

Meta Options & Constraints

An inner class Meta holds table-level configuration β€” ordering, verbose names, indexes, and database constraints.

from django.db import models


class Article(models.Model):
    title = models.CharField(max_length=200)
    slug = models.SlugField()
    published_at = models.DateTimeField()

    class Meta:
        ordering = ["-published_at"]           # newest first by default
        verbose_name = "article"
        verbose_name_plural = "articles"
        indexes = [
            models.Index(fields=["-published_at"]),
            models.Index(fields=["slug"]),
        ]
        constraints = [
            models.UniqueConstraint(
                fields=["slug"], name="unique_article_slug"
            ),
        ]

βœ… Prefer constraints and indexes over field kwargs

Modern Django favors the explicit Meta.constraints list (e.g. UniqueConstraint, CheckConstraint) over older per-field options like unique_together. Constraints are enforced by the database itself, so they hold even if a bug bypasses your Python validation.

A CheckConstraint enforces a business rule at the database level:

    class Meta:
        constraints = [
            models.CheckConstraint(
                check=models.Q(price__gte=0),
                name="price_non_negative",
            ),
        ]

Model Methods

Fat models, thin views: put data-related logic on the model where it belongs. This keeps business rules in one place and out of your views and templates.

from django.urls import reverse
from django.utils import timezone


class Article(models.Model):
    title = models.CharField(max_length=200)
    slug = models.SlugField(unique=True)
    content = models.TextField()
    published_at = models.DateTimeField(default=timezone.now)

    def __str__(self):
        return self.title

    def get_absolute_url(self):
        """Canonical URL for this object β€” used by templates and the admin."""
        return reverse("blog:article_detail", kwargs={"slug": self.slug})

    @property
    def is_recent(self):
        """True if published within the last 7 days."""
        return timezone.now() - self.published_at <= timezone.timedelta(days=7)

    @property
    def word_count(self):
        return len(self.content.split())

πŸ“– get_absolute_url()

A conventionally named method returning the object's canonical URL. Define it and the admin's "View on site" button, plus {{ article.get_absolute_url }} in templates, just work.

A note on managers

The objects attribute on every model is a manager β€” the entry point for queries (Article.objects.all()). You can add custom managers to encapsulate common queries. Managers and their QuerySets are the focus of the next lesson, so we will only preview them here:

class PublishedManager(models.Manager):
    def get_queryset(self):
        return super().get_queryset().filter(status="published")


class Article(models.Model):
    # ... fields ...
    objects = models.Manager()        # the default manager
    published = PublishedManager()    # Article.published.all()

Hands-on Exercise

πŸ‹οΈ Build the models for a blog

Objective: Design the data layer for a small blog, applying fields, relationships, an abstract base class, and Meta options.

Requirements:

  1. A TimeStampedModel abstract base with created_at and updated_at.
  2. A Category with name and a unique slug.
  3. A Post with a title, unique slug, body, a status choice (draft/published), a ForeignKey to Category, a ForeignKey to the user model as author, and a ManyToManyField of tags.
  4. A Comment with a ForeignKey to Post (related_name="comments"), an author name, and a body.
  5. Give every model a __str__, and order Post newest-first in Meta.
πŸ’‘ Hint

Inherit both Post and Comment from TimeStampedModel. Use models.TextChoices for the status. Reference the user with settings.AUTH_USER_MODEL. A Tag model needs only a name and slug.

βœ… Sample solution
from django.conf import settings
from django.db import models


class TimeStampedModel(models.Model):
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        abstract = True


class Category(models.Model):
    name = models.CharField(max_length=100)
    slug = models.SlugField(unique=True)

    class Meta:
        verbose_name_plural = "categories"

    def __str__(self):
        return self.name


class Tag(models.Model):
    name = models.CharField(max_length=50)
    slug = models.SlugField(unique=True)

    def __str__(self):
        return self.name


class Post(TimeStampedModel):
    class Status(models.TextChoices):
        DRAFT = "draft", "Draft"
        PUBLISHED = "published", "Published"

    title = models.CharField(max_length=200)
    slug = models.SlugField(max_length=200, unique=True)
    body = models.TextField()
    status = models.CharField(
        max_length=10, choices=Status.choices, default=Status.DRAFT
    )
    category = models.ForeignKey(
        Category, on_delete=models.SET_NULL, null=True,
        related_name="posts",
    )
    author = models.ForeignKey(
        settings.AUTH_USER_MODEL, on_delete=models.CASCADE,
        related_name="posts",
    )
    tags = models.ManyToManyField(Tag, related_name="posts", blank=True)

    class Meta:
        ordering = ["-created_at"]

    def __str__(self):
        return self.title


class Comment(TimeStampedModel):
    post = models.ForeignKey(
        Post, on_delete=models.CASCADE, related_name="comments"
    )
    author_name = models.CharField(max_length=100)
    body = models.TextField()

    def __str__(self):
        return f"Comment by {self.author_name} on {self.post}"

🎯 Quick Quiz

Question 1: You have middle_name = models.CharField(max_length=50, ...) and want it to be optional. What is the recommended option?

Question 2: Which on_delete value prevents deleting an author while any of their books still reference them?

Question 3: You want two models to share created_at/updated_at fields with no extra table and no extra JOINs. Which approach fits best?

Best Practices

βœ… Do

  • Always define __str__ for a readable admin and shell.
  • Reference the user with settings.AUTH_USER_MODEL, never User directly.
  • Use DecimalField for money and TextChoices for enumerations.
  • Add related_name to relationships so reverse lookups read clearly.
  • Enforce invariants with database constraints, and index fields you filter or order by.
  • Push data logic into model methods and properties (fat models, thin views).

⚠️ Don't

  • Don't set null=True on text fields β€” use blank=True instead.
  • Don't store prices in a FloatField.
  • Don't reach for multi-table inheritance when an abstract base class will do.
  • Don't forget on_delete β€” it is required and its choice has real data consequences.

Summary & Quiz

πŸŽ‰ Key Takeaways

  • A model is one class that becomes a table plus validation, a query API, admin, and forms.
  • Pick the most specific field type; tune it with options like blank, choices, default, and validators.
  • Relationships: ForeignKey (one-to-many), ManyToManyField (many-to-many), OneToOneField (one-to-one) β€” and on_delete is mandatory.
  • Favor abstract base classes for shared fields, and enforce rules with Meta constraints and indexes.
  • Put behavior on the model with methods and properties.

πŸ“š Further Reading

πŸš€ What's Next?

Your models describe the schema you want. In the next lesson, Database Migrations, you'll learn how Django turns those model definitions into real database tables β€” and how to evolve the schema safely as your models change.

πŸŽ‰ Well done!

You can now design a Django data layer from scratch. Time to make it real in the database.