Django Intermediate

Django Models & ORM: Complete Guide

Master Django models, field types, relationships, querysets, and the ORM. Learn to design efficient database schemas for your Django projects.

KC Ramo · DjangoZen Team Mar 29, 2026 19 min read 703 views

The Django ORM (Object-Relational Mapper) is one of the framework's most powerful features. It lets you interact with your database using Python code instead of writing raw SQL. In this guide, you'll master models, querysets, relationships, and advanced ORM techniques.

What is the Django ORM?

The ORM translates Python classes into database tables and Python method calls into SQL queries. This means you can switch databases (SQLite, PostgreSQL, MySQL) without changing your application code.

# Instead of writing SQL like this:
SELECT * FROM products WHERE price > 50 ORDER BY name;

# You write Python like this:
Product.objects.filter(price__gt=50).order_by('name')

Defining Models

Every model is a Python class that inherits from models.Model. Each attribute represents a database column:

from django.db import models
from django.contrib.auth.models import User


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

    class Meta:
        verbose_name_plural = "categories"
        ordering = ['name']

    def __str__(self):
        return self.name


class Product(models.Model):
    STATUS_CHOICES = [
        ('draft', 'Draft'),
        ('published', 'Published'),
        ('archived', 'Archived'),
    ]

    title = models.CharField(max_length=200)
    slug = models.SlugField(unique=True)
    category = models.ForeignKey(Category, on_delete=models.CASCADE, related_name='products')
    author = models.ForeignKey(User, on_delete=models.CASCADE)
    description = models.TextField()
    price = models.DecimalField(max_digits=10, decimal_places=2)
    status = models.CharField(max_length=10, choices=STATUS_CHOICES, default='draft')
    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

Common Field Types

FieldPython TypeDatabase TypeUse Case
CharFieldstrVARCHARShort text (name, title)
TextFieldstrTEXTLong text (description, content)
IntegerFieldintINTEGERWhole numbers
DecimalFieldDecimalNUMERICMoney, precise numbers
BooleanFieldboolBOOLEANTrue/False flags
DateTimeFielddatetimeTIMESTAMPDate and time
SlugFieldstrVARCHARURL-friendly strings
EmailFieldstrVARCHAREmail addresses
URLFieldstrVARCHARURLs
FileFieldFieldFileVARCHARFile uploads
ImageFieldImageFieldFileVARCHARImage uploads
JSONFielddict/listJSONStructured JSON data
UUIDFieldUUIDUUID/CHARUnique identifiers

Relationships

Django supports three types of database relationships:

ForeignKey (Many-to-One)

Many products belong to one category:

class Product(models.Model):
    category = models.ForeignKey(
        Category,
        on_delete=models.CASCADE,    # Delete products when category is deleted
        related_name='products'      # Access from category: category.products.all()
    )

# Usage:
category = Category.objects.get(slug='electronics')
category.products.all()        # All products in this category
category.products.count()      # Number of products
on_delete options:
CASCADE — Delete related objects
PROTECT — Prevent deletion if related objects exist
SET_NULL — Set to NULL (requires null=True)
SET_DEFAULT — Set to default value
DO_NOTHING — Do nothing (can cause integrity errors)

ManyToManyField

Products can have multiple tags, and tags can belong to multiple products:

class Tag(models.Model):
    name = models.CharField(max_length=50)

class Product(models.Model):
    tags = models.ManyToManyField(Tag, blank=True)

# Usage:
product.tags.add(tag1, tag2)         # Add tags
product.tags.remove(tag1)            # Remove a tag
product.tags.all()                   # All tags for product
tag.product_set.all()                # All products with tag

OneToOneField

Each user has exactly one profile:

class UserProfile(models.Model):
    user = models.OneToOneField(User, on_delete=models.CASCADE)
    bio = models.TextField(blank=True)
    avatar = models.ImageField(upload_to='avatars/', blank=True)

# Usage:
user.userprofile.bio               # Access profile from user

QuerySet API — Retrieving Data

The QuerySet API is how you read data from the database. QuerySets are lazy — they don't hit the database until you actually need the data.

# Get all objects
Product.objects.all()

# Filter (WHERE clause)
Product.objects.filter(status='published')
Product.objects.filter(price__gte=10, price__lte=100)    # AND condition
Product.objects.filter(title__icontains='django')       # Case-insensitive search

# Exclude
Product.objects.exclude(status='archived')

# Get single object (raises DoesNotExist if not found)
Product.objects.get(pk=1)
Product.objects.get(slug='my-product')

# Order by
Product.objects.order_by('price')        # Ascending
Product.objects.order_by('-created_at')  # Descending

# Slicing (LIMIT/OFFSET)
Product.objects.all()[:5]                # First 5
Product.objects.all()[5:10]              # Items 6-10

# Chaining (all querysets are chainable)
Product.objects.filter(status='published').order_by('-price')[:10]

# Values (get dictionaries instead of objects)
Product.objects.values('title', 'price')

# Count, exists
Product.objects.count()
Product.objects.filter(is_featured=True).exists()

Field Lookups

Django provides powerful field lookups using double-underscore syntax:

LookupSQL EquivalentExample
exact=name__exact='Django'
iexactUPPER(col) = UPPER(value)name__iexact='django'
containsLIKE '%x%'title__contains='API'
icontainsUPPER(col) LIKE UPPER('%x%')title__icontains='api'
gt / gte> / >=price__gte=10
lt / lte< / <=price__lt=100
inINstatus__in=['draft','published']
startswithLIKE 'x%'title__startswith='Django'
isnullIS NULLdue_date__isnull=True
rangeBETWEENprice__range=(10, 50)
year/month/dayEXTRACTcreated_at__year=2025

Creating, Updating, and Deleting

from decimal import Decimal

# Create
product = Product.objects.create(
    title='My Product',
    price=Decimal("29.99"),
    category=category,
    # ...plus any other required fields on your model
)

# Or create in two steps
product = Product(title='My Product', price=Decimal("29.99"))
product.save()

# Update single object
product.price = 39.99
product.save()

# Bulk update (efficient — single SQL query)
Product.objects.filter(status='draft').update(status='published')

# Delete
product.delete()
Product.objects.filter(status='archived').delete()

Performance: select_related & prefetch_related

Avoid the N+1 query problem by preloading related data:

# BAD: N+1 queries (1 query + 1 per product for category)
products = Product.objects.all()
for p in products:
    print(p.category.name)    # Hits database each time!

# GOOD: select_related for ForeignKey (SQL JOIN)
products = Product.objects.select_related('category', 'author').all()

# GOOD: prefetch_related for ManyToMany (separate query)
products = Product.objects.prefetch_related('tags').all()
Performance Rule: Always use select_related() for ForeignKey/OneToOne fields and prefetch_related() for ManyToMany fields when you know you'll access the related objects. This can reduce hundreds of queries to just 1-2.

Aggregation & Annotation

from django.db.models import Avg, Count, Sum, Max, Min

# Aggregate (returns a dictionary)
Product.objects.aggregate(
    avg_price=Avg('price'),
    total=Count('id'),
    max_price=Max('price')
)
# {'avg_price': Decimal('45.50'), 'total': 120, 'max_price': Decimal('299.99')}

# Annotate (adds computed field to each object)
categories = Category.objects.annotate(
    product_count=Count('products'),
    avg_price=Avg('products__price')
)
for cat in categories:
    print(cat.name, cat.product_count, cat.avg_price)

Model Methods & Properties

Add business logic directly to your models:

from decimal import Decimal

class Product(models.Model):
    # ... fields, including a nullable sale_price DecimalField ...

    @property
    def is_on_sale(self):
        return self.sale_price is not None

    def get_absolute_url(self):
        from django.urls import reverse
        return reverse('product_detail', kwargs={'slug': self.slug})

    def apply_discount(self, percent):
        self.price = self.price * (1 - Decimal(percent) / 100)
        self.save(update_fields=['price'])
Best Practice: Use save(update_fields=['field_name']) when updating specific fields. It generates a more efficient SQL query, but it does not prevent lost updates from a read-modify-write; use an F() expression or select_for_update() for that.

Migrations Workflow

# After changing models:
python manage.py makemigrations          # Generate migration files
python manage.py migrate                  # Apply to database

# See generated SQL without running it:
python manage.py sqlmigrate app_name 0001

# Check for issues:
python manage.py check

Models as the heart of a Django app

In Django, models are where your application's data and much of its logic live, and getting them right shapes everything else. A model defines a table, its fields define columns, and the relationships between models define how your data connects. Because so much of an application flows through its models — every query, every form, every view that touches data — thoughtful model design pays off throughout the codebase, while poor model design creates friction everywhere. Treating models as the foundational layer they are, and investing in designing them well, is one of the highest-leverage things you can do early in a Django project.

Relationships and how to choose them

Django models connect through three relationship types, and choosing correctly matters. A foreign key expresses a many-to-one relationship — many orders belong to one customer. A many-to-many expresses that records on both sides can relate to many of the other — students and courses. A one-to-one ties two records together exclusively. Picking the relationship that accurately models your domain keeps queries natural and data consistent, while a wrong choice forces awkward workarounds later. Understanding what each relationship means and selecting the one that matches reality is fundamental to a clean data model that serves the application well.

Migrations as version control for your schema

Migrations are how Django evolves your database schema over time, and they function as version control for your data structure. When you change a model, you generate a migration capturing that change, and applying it updates the database. This gives you a versioned, repeatable history of how your schema evolved, lets every environment stay in sync, and makes schema changes reviewable like code. Understanding migrations — generating them, applying them, and resolving the occasional conflict — is essential to working with Django models in any real project, because the schema is not static and migrations are the disciplined way it changes.

Querying efficiently through the ORM

The ORM lets you query the database in Python, but how you write those queries determines performance. Fetching related data efficiently, filtering and aggregating in the database rather than in Python, and avoiding the common trap of firing one query per object in a loop are what keep data access fast as your application grows. The ORM makes it easy to write queries that work but scale poorly, so understanding how your model queries translate to database work — and using the tools Django provides to make them efficient — is the difference between a data layer that stays fast and one that quietly degrades under real data volumes.

Adding behavior to models

Models are not just data definitions; they are a natural place to put behavior related to that data. Adding methods to a model lets you keep logic that operates on a record close to the record itself, so the behavior travels with the data and is reusable wherever the model is used. This keeps related logic organized and avoids scattering it across views. Understanding that models can hold methods expressing behavior — not only fields defining structure — encourages a clean design where data and the operations on it live together, which is a hallmark of well-organized Django code and makes your models a meaningful part of the application's logic rather than passive data containers.

Worked Example: A Sales Report End to End

To see how the pieces fit together, let's build a monthly sales report for a shop. For each customer, show how many orders they placed this month, how much they spent, and their most recent order date; only include customers who spent more than 100; and use a fixed number of queries no matter how many customers exist.

Step 1: The models

from decimal import Decimal

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


class Order(models.Model):
    class Status(models.TextChoices):
        PENDING = 'pending', 'Pending'
        PAID = 'paid', 'Paid'
        REFUNDED = 'refunded', 'Refunded'

    customer = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.PROTECT,
        related_name='orders',
    )
    status = models.CharField(max_length=10, choices=Status.choices, default=Status.PENDING)
    placed_at = models.DateTimeField(db_index=True)

    class Meta:
        ordering = ['-placed_at']


class OrderItem(models.Model):
    order = models.ForeignKey(Order, on_delete=models.CASCADE, related_name='items')
    product = models.ForeignKey('shop.Product', on_delete=models.PROTECT)
    quantity = models.PositiveIntegerField(default=1)
    unit_price = models.DecimalField(max_digits=10, decimal_places=2)

    @property
    def line_total(self):
        return self.unit_price * self.quantity

A few deliberate choices: TextChoices gives named constants (Order.Status.PAID) instead of bare strings; settings.AUTH_USER_MODEL keeps the model working with a custom user model; PROTECT stops financial records vanishing when a user or product is deleted; and unit_price is copied onto the line item because the product's price will change later but the invoice must not.

Step 2: The query

from datetime import timedelta

from django.contrib.auth import get_user_model
from django.db.models import Count, DecimalField, ExpressionWrapper, F, Max, Q, Sum
from django.utils import timezone

User = get_user_model()


def monthly_sales_report(min_spent=Decimal('100.00')):
    since = timezone.now() - timedelta(days=30)
    paid_this_month = Q(orders__status=Order.Status.PAID, orders__placed_at__gte=since)

    line_total = ExpressionWrapper(
        F('orders__items__unit_price') * F('orders__items__quantity'),
        output_field=DecimalField(max_digits=12, decimal_places=2),
    )

    return (
        User.objects
        .annotate(
            order_count=Count('orders', filter=paid_this_month, distinct=True),
            total_spent=Sum(line_total, filter=paid_this_month),
            last_order=Max('orders__placed_at', filter=paid_this_month),
        )
        .filter(total_spent__gt=min_spent)
        .order_by('-total_spent')
        .values('id', 'username', 'order_count', 'total_spent', 'last_order')
    )

A Q object stores a reusable condition (combinable with |, & and ~); an F object refers to a column, so price times quantity is computed in SQL. Everything runs as one statement: a join from users to orders to items, a GROUP BY on the user, and conditional aggregates. The filter= argument on each aggregate becomes a FILTER (WHERE ...) clause on PostgreSQL, so only paid orders from the last 30 days count. Filtering on total_spent after the annotation becomes a HAVING clause. ExpressionWrapper declares the result type of decimal times integer, avoiding a "mixed types" error.

Note the distinct=True on Count. Because the query joins to order items, a single order with three items appears three times in the joined rows. Without distinct=True the order count would silently be the number of line items.

Step 3: Locking in the query count with a test

Query counts regress silently when someone touches a new relation in a template. assertNumQueries turns that into a failing test.

from decimal import Decimal
from django.contrib.auth import get_user_model
from django.test import TestCase
from django.utils import timezone

from shop.models import Category, Order, OrderItem, Product
from shop.reports import monthly_sales_report


class MonthlySalesReportTests(TestCase):
    @classmethod
    def setUpTestData(cls):
        User = get_user_model()
        cls.alice = User.objects.create_user('alice')
        cls.bob = User.objects.create_user('bob')
        category = Category.objects.create(name='Books', slug='books')
        cls.book = Product.objects.create(
            title='ORM Book', slug='orm-book', category=category,
            author=cls.alice, description='', price=Decimal('60.00'),
        )
        order = Order.objects.create(
            customer=cls.alice, status=Order.Status.PAID, placed_at=timezone.now(),
        )
        OrderItem.objects.create(order=order, product=cls.book, quantity=2,
                                 unit_price=Decimal('60.00'))
        OrderItem.objects.create(order=order, product=cls.book, quantity=1,
                                 unit_price=Decimal('60.00'))
        # Bob only has a pending order, so he must not appear
        Order.objects.create(customer=cls.bob, placed_at=timezone.now())

    def test_totals_and_single_query(self):
        with self.assertNumQueries(1):
            rows = list(monthly_sales_report())
        self.assertEqual(len(rows), 1)
        self.assertEqual(rows[0]['username'], 'alice')
        self.assertEqual(rows[0]['order_count'], 1)       # not 2: distinct=True works
        self.assertEqual(rows[0]['total_spent'], Decimal('180.00'))

The test data deliberately gives one order two line items, which is exactly the case that exposes a missing distinct=True.

Common Mistakes and How to Fix Them

These ORM errors appear again and again in code review. Most only surface with production data volumes.

MistakeSymptomFix
Passing floats to a DecimalField, or multiplying a Decimal by a floatRounding surprises, or TypeError: unsupported operand type(s)Use Decimal('29.99') and Decimal(percent) / 100; never mix float and Decimal
if queryset: or len(queryset) just to check existence or sizeLoads every row into memoryUse .exists() and .count() when you do not need the rows themselves
Expecting update() to behave like save()Custom save() logic, signals and auto_now fields are skippedSet updated_at=timezone.now() explicitly in the update() call, or loop and save() when logic must run
Aggregating across two multi-valued joinsCounts and sums inflated by a multiplication factorCount(..., distinct=True), or compute each aggregate in a Subquery

Exercises

Use the models from this guide, and write an assertNumQueries test for each answer to prove it is efficient, not just correct.

  • Exercise 1 — Search. Return published products matching an optional term (in title or description), optional tag names and an optional maximum price. Hint: build a Q incrementally, and remember .distinct() when filtering on tags.
  • Exercise 2 — Category dashboard. In one query, list every category with its published-product count and average published price; empty categories must show 0. Hint: conditional aggregation with filter=Q(...) keeps empty categories because it does not add a WHERE clause.
  • Exercise 3 — Safe discount. Rewrite apply_discount with Decimal arithmetic so it is safe when two requests discount the same product at once, then add a database rule forbidding negative prices and test that it raises IntegrityError. Hint: Product.objects.filter(pk=...).update(price=F('price') * factor) does the arithmetic in SQL, and models.CheckConstraint(condition=Q(price__gte=0), name=...) in Meta.constraints enforces the rule (Django 5.1+ uses condition=; the older check= keyword is deprecated).

Summary

The Django ORM is a powerful abstraction that lets you work with databases using Pythonic code. Key takeaways:

  • Models define your schema; migrations keep it in sync
  • Use filter(), exclude(), and field lookups to query precisely
  • Understand relationship types: ForeignKey, ManyToMany, OneToOne
  • Always use select_related/prefetch_related to avoid N+1 queries
  • Use aggregation for statistics and annotation for computed fields