Part 2: The Data Layer — Schema, Models, and Seed
Part 1 gave us the infrastructure. The containers are running. PostgreSQL is healthy. Redis is idle. Django is serving pages but talking to nothing real. Today we fix that — and we build the database that the rest of this series will cache.
If you’re jumping in here, start with Part 1: The Bulletproof Baseline — it sets up the entire infrastructure this post builds on. If you’re continuing from Part 1, you already have everything you need. Let’s get into it.
In Part 1, we left two items unfinished on purpose. Django was still defaulting to SQLite. The cache was using LocMemCache instead of Re…
Part 2: The Data Layer — Schema, Models, and Seed
Part 1 gave us the infrastructure. The containers are running. PostgreSQL is healthy. Redis is idle. Django is serving pages but talking to nothing real. Today we fix that — and we build the database that the rest of this series will cache.
If you’re jumping in here, start with Part 1: The Bulletproof Baseline — it sets up the entire infrastructure this post builds on. If you’re continuing from Part 1, you already have everything you need. Let’s get into it.
In Part 1, we left two items unfinished on purpose. Django was still defaulting to SQLite. The cache was using LocMemCache instead of Redis. Both of those get fixed in this post — but that’s the warm-up. The real work today is the data layer: designing the schema, translating it into Django models, seeding it with thousands of rows, and proving that everything is connected.
We are also going to hit a real bug. A subtle one — the kind that looks like a Django problem but is actually a Python naming collision. We will break it, understand exactly why it broke, and fix it the right way. That’s more valuable than a clean run.
Part A: The Database Design — Before We Write a Single Model
The temptation when starting a new Django project is to open models.py immediately and start typing. Resist it. The schema is a decision that’s expensive to change later — especially once you have data in it and cached queries depending on its shape. We design it first, on paper (or in a tool), before it becomes code.
Why DBML? Why Not Just Write Django Models Directly?
DBML (Database Markup Language) is a simple, human-readable syntax for defining database schemas. You write it, paste it into dbdiagram.io, and get a visual ER diagram instantly. No UML tool. No Visio. No guessing about relationships.
But the visual diagram is a side benefit. The real reason we use DBML here is separation of concerns. DBML is database-agnostic — it doesn’t know about Django, it doesn’t know about Python. It describes the data structure. Django models describe how Python talks to that structure. Keeping them separate means:
The schema can be reviewed and debated before anyone writes application code. A designer, a product manager, or another developer can look at the DBML and understand the data model without reading a single line of Python. And when we translate DBML into Django models, we make conscious decisions about things DBML can’t express — like whether to add db_index=False on purpose, or why a field is nullable. Those decisions belong in the code, with comments that explain the reasoning.
The Schema We’re Building — And Why It’s Intentionally Naive
Here is the DBML for our housing portal. Read it carefully — there are things missing that you’d expect to see in a production schema. That’s deliberate.
Table locations {
id integer [primary key, increment]
city varchar [not null]
state varchar(2)
zip_code varchar(10)
country varchar [default: 'US']
// NO indexes. City-based searches will full-table-scan.
// We add the index in a later part and show the query plan difference.
}
Table offices {
id integer [primary key, increment]
name varchar [not null]
address varchar
city varchar
phone varchar
// Second hop in the N+1 chain: Property → Agent → Office
}
Table agents {
id integer [primary key, increment]
name varchar [not null]
email varchar
phone varchar
office_id integer [not null]
// First hop in the N+1 chain: Property → Agent
}
Ref: agents.office_id > offices.id
Table properties {
id integer [primary key, increment]
title varchar [not null]
description text
property_type varchar [not null]
price decimal(12,2) [not null]
bedrooms integer [default: 1]
bathrooms integer [default: 1]
location_id integer [not null]
agent_id integer [not null]
status varchar [default: 'available']
view_count integer [default: 0]
is_published boolean [default: true]
created_at timestamp [default: `now()`]
updated_at timestamp [default: `now()`]
// NO indexes beyond PK. Every filter the frontend uses
// (price, type, location, status) will scan the entire table.
// That's the slow path. Caching fixes it later.
}
Ref: properties.location_id > locations.id
Ref: properties.agent_id > agents.id
Table property_images {
id integer [primary key, increment]
listing_id integer [not null]
original_url varchar [not null]
thumbnail_url varchar
cdn_url varchar
display_order integer [default: 0]
alt_text varchar
created_at timestamp [default: `now()`]
// No index on listing_id. Image fetches per property = full table scan.
// cdn_url and thumbnail_url are nullable — they don't exist until
// something generates them. That's lazy processing. We cache it later.
}
Ref: property_images.listing_id > properties.id
Paste this into dbdiagram.io to generate the ER diagram. Here is where that diagram goes:
Here is why each table exists and what caching problem it sets up for later:
locations is the geographic anchor. Properties belong to locations. No indexes means every "show me listings in Seattle" query scans the whole table. That’s the slow path we’ll fix.
offices and agents are the N+1 trap. A property has an agent. An agent has an office. When the listing page renders 20 properties and shows each agent’s office name, Django will make 41 separate queries if we’re not careful — one for the properties, one per agent, one per office. We will not be careful. Not yet. That’s the setup for the query caching lesson.
properties is the core. It has view_count — a field that gets read on every page view but updated rarely. That’s the cache-aside pattern target. It has status with transitions (available → pending → sold). When status changes, cached listing pages go stale. That’s the cache invalidation target. Both of these are deliberately slow right now.
property_images is the image caching surface. Three URL fields: original_url (the raw file), thumbnail_url (generated lazily), cdn_url (pushed to a CDN like Cloudinary or ImageKit). The nullable fields model a pipeline: upload happens first, optimization happens on demand. Caching lives between those two steps.
Part B: Wiring Django — Settings, Apps, and Middleware
Before models.py means anything, Django needs to know about every package we installed in Part 1. We installed djangorestframework, corsheaders, and now dj-database-url and django-redis. None of them do anything until they’re registered.
Step 1: Install the New Packages
We need two new packages that Part 1 didn’t include:
cd backend
source venv/bin/activate # Windows: venv\Scripts\activate
pip install dj-database-url django-redis Faker
pip freeze > requirements.txt
dj-database-url — parses a DATABASE_URL string like postgres://user:password@db:5432/housing_db into the dictionary format Django’s DATABASES setting expects. Without it, you’d have to manually split the URL into host, port, user, password, and database name. Nobody does that.
django-redis — the Redis backend for Django’s cache framework. Django 4.0+ has a built-in RedisCache backend, but django-redis adds connection pooling, retry logic, and a more battle-tested client underneath it.
Faker — generates realistic fake data. We use it for the seed command in Part D. Property titles, agent names, addresses — all generated, all realistic enough to feel like a real dataset.
Step 2: Update docker-compose.yml
The backend needs to know where Redis is. We add REDIS_URL to its environment, and add redis to its depends_on list:
backend:
build: ./backend
volumes:
- ./backend:/app
ports:
- "8000:8000"
environment:
- DATABASE_URL=postgres://user:password@db:5432/housing_db
- REDIS_URL=redis://redis:6379 # ← New. Points Django at the Redis container.
depends_on:
- db
- redis # ← New. Backend now needs Redis too.
Step 3: The Complete settings.py
This is the file that wires everything together. We are replacing it entirely — not patching it. The default settings.py from startproject has too many assumptions baked in (SQLite, no CORS, no cache backend). Replacing it cleanly is less error-prone than trying to surgically edit the generated file.
Here is the file, section by section, with the reasoning for each block:
"""
Django settings for core project.
Modified for: PostgreSQL, Redis cache, DRF, CORS, and the housing app.
"""
from pathlib import Path
import os
import dj_database_url
BASE_DIR = Path(__file__).resolve().parent.parent
Standard Django boilerplate. BASE_DIR is the backend/ directory. Everything that needs a file path references this.
SECRET_KEY = 'django-insecure-change-this-in-production'
DEBUG = True
ALLOWED_HOSTS = ['*']
Development-only values. SECRET_KEY is a placeholder — in a production part of this series, it becomes an environment variable. ALLOWED_HOSTS = ['*'] means "accept requests from any hostname." Fine in Docker. Never in production.
INSTALLED_APPS = [
# Django internals
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
# Third-party — installed via pip, registered here
'rest_framework',
'corsheaders',
# Our apps
'housing',
]
This is the registration desk. Three things we added that weren’t in the default: rest_framework, corsheaders, and housing. If any of these is missing, the corresponding features silently don’t work. Models won’t migrate. Middleware won’t fire. The API layer won’t exist.
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'corsheaders.middleware.CorsMiddleware', # ← Must be above CommonMiddleware
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
Middleware is a stack. Requests go down the list. Responses come back up. CorsMiddleware must sit above CommonMiddleware — if it’s below, the CORS headers get added after CommonMiddleware has already decided whether to reject the request. The browser never sees them. The frontend gets a silent failure.
CORS_ALLOWED_ORIGINS = [
'http://localhost:3000',
]
Only localhost:3000 (Next.js) is allowed to make cross-origin requests to this API. In production, this becomes your actual domain.
DATABASES = {
'default': dj_database_url.config(
default='sqlite:///db.sqlite3',
conn_max_age=600,
)
}
This is the line that finally uses the DATABASE_URL from docker-compose.yml. dj_database_url.config() reads the environment variable, parses the postgres:// URL, and returns the dictionary Django needs. The default fallback exists so Django doesn’t crash if someone runs the file outside Docker — it falls back to SQLite. Inside Docker, DATABASE_URL is always set, so the fallback never triggers. conn_max_age=600 keeps connections alive for 10 minutes instead of opening a new one per request.
CACHES = {
'default': {
'BACKEND': 'django.core.cache.backends.redis.RedisCache',
'LOCATION': os.environ.get('REDIS_URL', 'redis://redis:6379'),
'OPTIONS': {
'CLIENT_CLASS': 'django_redis.client.DefaultClient',
}
}
}
This replaces Django’s default LocMemCache with Redis. The LOCATION reads REDIS_URL from the environment. The fallback (redis://redis:6379) is the Docker service name — if the env variable is missing, it assumes Docker and tries the service name directly. CLIENT_CLASS points to django-redis’s client, which adds connection pooling on top of the raw connection.
The rest of the file (templates, static files, auth validators, REST_FRAMEWORK pagination) follows standard Django conventions. The full file is in the repo.
Step 4: Verify housing/apps.py
startapp generated this file automatically. It should look like this:
from django.apps import AppConfig
class HousingConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'housing'
The name = 'housing' must match exactly what we put in INSTALLED_APPS. If they don’t match, Django won’t find the app. No error message — it just silently ignores it.
Part C: The Models — Translating DBML into Django Code
Now we write models.py. This is where the DBML becomes real — and where we make the conscious decisions that DBML can’t express.
The Models
from django.db import models
class Location(models.Model):
city = models.CharField(max_length=100, db_index=False)
state = models.CharField(max_length=2, blank=True)
zip_code = models.CharField(max_length=10, blank=True)
country = models.CharField(max_length=50, default='US')
class Meta:
ordering = ['city']
def __str__(self):
return f"{self.city}, {self.state}"
class Office(models.Model):
name = models.CharField(max_length=150)
address = models.CharField(max_length=255, blank=True)
city = models.CharField(max_length=100, blank=True)
phone = models.CharField(max_length=20, blank=True)
def __str__(self):
return self.name
class Agent(models.Model):
name = models.CharField(max_length=150)
email = models.EmailField(blank=True)
phone = models.CharField(max_length=20, blank=True)
office = models.ForeignKey(
Office,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='agents',
)
class Meta:
ordering = ['name']
def __str__(self):
return self.name
class Property(models.Model):
STATUS_CHOICES = [
('available', 'Available'),
('pending', 'Pending'),
('sold', 'Sold'),
]
PROPERTY_TYPE_CHOICES = [
('apartment', 'Apartment'),
('house', 'House'),
('villa', 'Villa'),
('studio', 'Studio'),
('condo', 'Condo'),
]
title = models.CharField(max_length=255)
description = models.TextField(blank=True)
property_type = models.CharField(max_length=20, choices=PROPERTY_TYPE_CHOICES, db_index=False)
price = models.DecimalField(max_digits=12, decimal_places=2, db_index=False)
bedrooms = models.IntegerField(default=1)
bathrooms = models.IntegerField(default=1)
location = models.ForeignKey(Location, on_delete=models.CASCADE, related_name='properties', db_index=False)
agent = models.ForeignKey(Agent, on_delete=models.SET_NULL, null=True, blank=True, related_name='properties', db_index=False)
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='available', db_index=False)
view_count = models.IntegerField(default=0)
is_published = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ['-created_at']
def __str__(self):
return f"{self.title} — ${self.price:,.2f}"
class PropertyImage(models.Model):
listing = models.ForeignKey( # ← Note: "listing", not "property". See the bug section below.
Property,
on_delete=models.CASCADE,
related_name='images',
db_index=False,
)
original_url = models.URLField(max_length=1024)
thumbnail_url = models.URLField(max_length=1024, null=True, blank=True)
cdn_url = models.URLField(max_length=1024, null=True, blank=True)
display_order = models.IntegerField(default=0)
alt_text = models.CharField(max_length=255, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['display_order']
def __str__(self):
return f"Image {self.display_order} for {self.listing.title}"
@property
def best_url(self):
"""
Returns the best available URL for this image.
Priority: cdn_url (fastest) → thumbnail_url → original_url (fallback).
"""
return self.cdn_url or self.thumbnail_url or self.original_url
Every db_index=False is deliberate. Django automatically adds an index on ForeignKey fields — we override that on location and agent in the Property model to keep the table truly unindexed for the caching demo. The choices on property_type and status are validation at the Django level only — they don’t create database constraints. The database column is just a varchar. That’s fine for now.
The Bug We Hit — And Why It Matters
Before we move on, we need to talk about a bug. If you named the ForeignKey field on PropertyImage as property instead of listing, you would have hit this error:
File "/app/housing/models.py", line 220, in PropertyImage
@property
^^^^^^^^
TypeError: 'ForeignKey' object is not callable
This is not a Django bug. It’s a Python scoping collision — and understanding it makes you a better developer.
Here is what happened. Inside the PropertyImage class body, we defined a field:
property = models.ForeignKey(Property, ...)
This binds the name property to a ForeignKey object within the class scope. Then, a few lines later, we wrote:
@property
def best_url(self):
...
@property is a Python built-in decorator. It turns a method into a read-only attribute. But Python resolves names by looking in the current scope first. Inside this class, property is no longer the built-in — it’s the ForeignKey object we defined above. So Python tries to use the ForeignKey as a decorator:
# What Python actually does:
best_url = ForeignKey(...)(best_url) # ForeignKey is not callable this way
Hence the error. The ForeignKey is not callable as a decorator.
Three ways to fix it, in order of preference:
The best fix is to rename the field. listing is a better name anyway — it’s unambiguous, it doesn’t shadow a Python built-in, and it reads more naturally in the context of a housing portal:
listing = models.ForeignKey(Property, ...) # ← Clean. No collision possible.
The second option is to drop the @property decorator entirely and make best_url a regular method. You’d call it as image.best_url() instead of image.best_url. Slightly less elegant, but no naming conflict:
def best_url(self): # ← No decorator. Called with parentheses.
return self.cdn_url or self.thumbnail_url or self.original_url
The third option — the nuclear option — is to explicitly reference the built-in:
import builtins
@builtins.property # ← Works. But signals something is wrong.
def best_url(self):
...
This works, but anyone reading the code will immediately ask "why?" The answer is a naming problem that should have been fixed at the source. We went with the first option. The field is called listing. [Currently keeping the 3rd option, but will update the field to listing and it will need makemigrations and migrate command.]
Rule of thumb: Never name a model field after a Python built-in.
property,type,id,input,list,dict— all of these shadow something. Django’s ownidfield is an exception because Django creates it implicitly, but anything you name yourself should avoid the built-in namespace.
Part D: Migrations — Turning Models into Tables
The models exist in Python. The tables don’t exist in PostgreSQL yet. Migrations are the bridge.
Running Migrations — Two Ways
You have two options for where to run manage.py commands. Both work. The choice depends on what you’re doing and where you are.
Option 1: Inside the Docker container (recommended for this series)
This is the production-like way. The command runs inside the same environment that your application runs in — same Python version, same installed packages, same network. No surprises.
# Generate the migration file
docker compose exec backend python manage.py makemigrations
# Apply it to the database
docker compose exec backend python manage.py migrate
Option 2: Locally, using the virtual environment
This works too, but only for makemigrations (which just generates a Python file). migrate won’t work locally because your machine can’t reach db:5432 — that hostname only resolves inside Docker’s network.
cd backend
source venv/bin/activate
python manage.py makemigrations # ✅ Works locally — just writes a file
python manage.py migrate # ❌ Fails locally — can't reach "db" hostname
For this series, we use Option 1 for everything. It’s consistent and it eliminates an entire class of "works locally, fails in Docker" bugs.
What makemigrations Actually Does
It reads your models.py, compares it to the last migration file in housing/migrations/, and generates a new migration that describes the difference. On first run, there is no previous migration — so it generates 0001_initial.py, which creates all five tables from scratch.
What migrate Actually Does
It reads all migration files in order, checks which ones have already been applied (Django tracks this in a django_migrations table), and runs the ones that haven’t been applied yet. On first run, it runs 0001_initial.py and creates the tables.
Verify the Tables Exist
docker compose exec db psql -U user -d housing_db -c "\dt"
You should see:
List of relations
Schema | Name | Type | Owner
--------+------------------------+-------+-------
public | django_migrations | table | user
public | housing_agent | table | user
public | housing_location | table | user
public | housing_office | table | user
public | housing_property | table | user
public | housing_propertyimage | table | user
...
Five housing_* tables plus Django’s internal tables. If any of the five is missing, the migration didn’t run or the model has an error.
Part E: The Superuser — Access to the Admin Panel
We verified the admin panel exists in Part 1. Now we need a user who can log into it.
docker compose exec -it backend python manage.py createsuperuser
The -it flag is important here — it keeps the terminal interactive, which createsuperuser needs because it prompts you for input. Without -it, the command hangs or fails silently.
You’ll be prompted for:
Username: admin
Email address: admin@housing.local
Password: (you type it, it doesn't echo)
Password (again): (confirm)
Use anything you want. This is a local development database. The superuser is just for poking around in the admin panel.
Once created, go to http://localhost:8000/admin/, log in with the credentials you just set, and you should see the Housing app with all five models listed.
Part F: Seeding the Database — Making It Slow on Purpose
An empty database is fast. A database with 10 rows is fast. We need thousands of rows to make the performance problems visible — and we need them generated consistently so every developer in the series is working with the same data.
The Django way to do this is a management command — a script that lives inside your app and runs via manage.py. It’s reusable, it’s repeatable, and it’s the standard pattern for seeding.
The Seed Command
Create the directory structure first:
# These __init__.py files are required. Without them, Python doesn't treat
# the directories as packages, and Django can't find the command.
touch backend/housing/management/__init__.py
touch backend/housing/management/commands/__init__.py
Then create the seed script at backend/housing/management/commands/seed_data.py:
"""
management/commands/seed_data.py
Generates realistic test data for the housing portal.
Targets: ~50 offices, ~200 agents, ~3000 properties, ~6000 images.
Run it:
docker compose exec backend python manage.py seed_data
Run it with a fresh start (clears existing data first):
docker compose exec backend python manage.py seed_data --flush
"""
import random
from django.core.management.base import BaseCommand
from faker import Faker
from housing.models import Location, Office, Agent, Property, PropertyImage
fake = Faker()
class Command(BaseCommand):
help = 'Seed the database with realistic housing data'
def add_arguments(self, parser):
parser.add_argument(
'--flush',
action='store_true',
help='Delete all existing data before seeding',
)
def handle(self, *args, **options):
if options['flush']:
self.stdout.write('Flushing existing data...')
PropertyImage.objects.all().delete()
Property.objects.all().delete()
Agent.objects.all().delete()
Office.objects.all().delete()
Location.objects.all().delete()
# --- Locations ---
# A fixed set of realistic cities. Properties will be distributed across these.
cities = [
('Seattle', 'WA', '98101'), ('Portland', 'OR', '97201'),
('San Francisco', 'CA', '94102'), ('Los Angeles', 'CA', '90001'),
('Austin', 'TX', '78701'), ('Denver', 'CO', '80201'),
('Chicago', 'IL', '60601'), ('Miami', 'FL', '33101'),
('New York', 'NY', '10001'), ('Boston', 'MA', '02101'),
]
locations = Location.objects.bulk_create([
Location(city=city, state=state, zip_code=zip_code)
for city, state, zip_code in cities
])
self.stdout.write(f'Created {len(locations)} locations.')
# --- Offices ---
offices = Office.objects.bulk_create([
Office(
name=f"{fake.last_name()} Realty",
address=fake.street_address(),
city=random.choice(cities)[0],
phone=fake.phone_number(),
)
for _ in range(50)
])
self.stdout.write(f'Created {len(offices)} offices.')
# --- Agents ---
agents = Agent.objects.bulk_create([
Agent(
name=fake.name(),
email=fake.email(),
phone=fake.phone_number(),
office=random.choice(offices),
)
for _ in range(200)
])
self.stdout.write(f'Created {len(agents)} agents.')
# --- Properties ---
# bulk_create sends a single INSERT statement instead of 3000 individual ones.
# Without it, this loop would take minutes. With it, seconds.
properties = Property.objects.bulk_create([
Property(
title=f"{random.choice(['Cozy', 'Modern', 'Luxury', 'Spacious', 'Charming', 'Stunning'])} "
f"{random.choice(['Apartment', 'House', 'Villa', 'Studio', 'Condo'])} "
f"in {random.choice(locations).city}",
description=fake.paragraph(nb_sentences=3),
property_type=random.choice(['apartment', 'house', 'villa', 'studio', 'condo']),
price=round(random.uniform(100000, 2500000), 2),
bedrooms=random.randint(1, 6),
bathrooms=random.randint(1, 4),
location=random.choice(locations),
agent=random.choice(agents),
status=random.choices(
['available', 'pending', 'sold'],
weights=[70, 20, 10], # 70% available, 20% pending, 10% sold
k=1,
)[0],
view_count=random.randint(0, 500),
is_published=random.choices([True, False], weights=[90, 10], k=1)[0],
)
for _ in range(3000)
])
self.stdout.write(f'Created {len(properties)} properties.')
# --- Property Images ---
# Each property gets 1-4 images. The first one (display_order=0) is the cover.
# cdn_url and thumbnail_url are left null — they represent the "not yet processed"
# state. The image caching section of this series fills them in.
images = []
for prop in properties:
num_images = random.randint(1, 4)
for order in range(num_images):
images.append(PropertyImage(
listing=prop,
original_url=f"https://picsum.photos/seed/{prop.id}-{order}/800/600",
thumbnail_url=None, # Lazy — generated on first request (later)
cdn_url=None, # Not pushed to CDN yet (later)
display_order=order,
alt_text=f"{prop.property_type} listing image {order + 1}",
))
PropertyImage.objects.bulk_create(images, batch_size=1000)
self.stdout.write(f'Created {len(images)} property images.')
self.stdout.write(self.style.SUCCESS('Seeding complete.'))
Run It
docker compose exec backend python manage.py seed_data
Verify the Data
# Row counts for each table
docker compose exec db psql -U user -d housing_db -c "
SELECT
relname AS table_name,
n_live_tup AS row_count
FROM pg_stat_user_tables
WHERE schemaname = 'public'
ORDER BY relname;
"
You should see something close to: 200 agents, 10 locations, 50 offices, 3000 properties, and 5000-6000 property images. The exact image count varies because each property gets a random number between 1 and 4.
If you need to re-seed (wrong data, want to start fresh):
docker compose exec backend python manage.py seed_data --flush
The --flush flag deletes everything first, then re-seeds. Clean slate.
Part G: The Admin Panel — Seeing the Data
We created a superuser in Part E. Now let’s make the admin panel actually useful for browsing the data we just seeded.
Update housing/admin.py
The default admin.py from startapp is empty — just an admin import. We register all five models with sensible list_display columns so you can eyeball the data without clicking into every row:
from django.contrib import admin
from .models import Location, Office, Agent, Property, PropertyImage
@admin.register(Location)
class LocationAdmin(admin.ModelAdmin):
list_display = ['city', 'state', 'zip_code', 'country']
search_fields = ['city', 'state']
ordering = ['city']
@admin.register(Office)
class OfficeAdmin(admin.ModelAdmin):
list_display = ['name', 'city', 'phone']
search_fields = ['name', 'city']
@admin.register(Agent)
class AgentAdmin(admin.ModelAdmin):
list_display = ['name', 'email', 'phone', 'office']
search_fields = ['name', 'email']
list_select_related = ['office']
@admin.register(Property)
class PropertyAdmin(admin.ModelAdmin):
list_display = [
'title', 'property_type', 'price', 'bedrooms',
'bathrooms', 'status', 'location', 'agent',
'is_published', 'view_count', 'created_at',
]
list_filter = ['property_type', 'status', 'is_published']
search_fields = ['title', 'description']
ordering = ['-created_at']
@admin.register(PropertyImage)
class PropertyImageAdmin(admin.ModelAdmin):
list_display = ['listing', 'display_order', 'original_url', 'cdn_url', 'thumbnail_url']
list_select_related = ['listing']
ordering = ['listing', 'display_order']
list_select_related on AgentAdmin and PropertyImageAdmin prevents N+1 queries in the admin panel itself. We’re not trying to demonstrate the N+1 problem in the admin — that’s what the API is for. The admin is just a data browser.
Go to http://localhost:8000/admin/ and log in. You should see all five models under the Housing section, each populated with the seeded data.
Part H: Making PostgreSQL Data Persistent
This is a question that comes up early and gets answered late in most tutorials. So we address it here.
By default, a Docker container’s filesystem is ephemeral. When the container stops, the data is gone. For most containers in our stack — the backend, the frontend — that’s fine. Their code lives on the host via volume mounts. But PostgreSQL stores its data inside the container. If the container stops, the database disappears.
We already handled this in Part 1’s docker-compose.yml with the pgdata named volume:
db:
image: postgres:15
volumes:
- pgdata:/var/lib/postgresql/data # ← This line. Named volume.
volumes:
pgdata: # ← Defined here.
A named volume is managed by Docker. It lives outside any container, on the host’s filesystem (usually somewhere in /var/lib/docker/volumes/). PostgreSQL writes its data there. When the container stops and restarts, the volume is still there. The data survives.
To verify your data survived a restart:
docker compose down # Stop and remove containers
docker compose up -d # Restart them
# Data should still be there:
docker compose exec db psql -U user -d housing_db -c "SELECT COUNT(*) FROM housing_property;"
If you see 3000 (or whatever your seed produced), the persistence is working.
To deliberately wipe the data (useful during development when you want a clean slate):
docker compose down -v # The -v flag removes volumes too
docker compose up --build -d
docker compose exec backend python manage.py migrate
docker compose exec backend python manage.py seed_data
-v is destructive. It removes the pgdata volume entirely. The database is gone. You’ll need to migrate and re-seed.
Part I: Troubleshooting — The Real Problems
"psql: command not found" inside the backend container
docker compose exec backend python manage.py dbshell
CommandError: You appear not to have the 'psql' program installed or on your path.
dbshell runs inside the backend container (python:3.11-slim). That image has no PostgreSQL client tools — only the Python driver (psycopg2). Two options:
Option A — Install psql into the backend image. Add this to backend/Dockerfile after the pip install line:
RUN apt-get update && apt-get install -y --no-install-recommends postgresql-client \
&& rm -rf /var/lib/apt/lists/*
Rebuild with docker compose build backend. Now dbshell works.
Option B — Use the db container directly. This is cleaner. The db container already has psql:
docker compose exec db psql -U user -d housing_db
This is what we use for the rest of this series.
"role does not exist" or "database does not exist"
psql: FATAL: role "postgres" does not exist
psql: FATAL: database "houshing_db" does not exist
The first error: we never created a postgres role. Our docker-compose.yml defines POSTGRES_USER: user. That’s the only role that exists. Use -U user, not -U postgres.
The second error: houshing_db is a typo. The database is housing_db. PostgreSQL does zero guessing on names — it’s an exact match or it fails. Always copy the database name from docker-compose.yml rather than typing it.
"No module named ‘dj_database_url’" or "No module named ‘django_redis’"
ModuleNotFoundError: No module named 'dj_database_url'
Two possible causes. Either pip install didn’t run, or it ran in the wrong place.
If you installed the packages in your local venv but didn’t rebuild the Docker image, the container doesn’t have them. The container has its own Python environment, built from requirements.txt at image build time. The fix:
pip freeze > requirements.txt # Make sure the new packages are in here
docker compose build backend # Rebuild the image with the updated requirements
docker compose up -d # Restart
"django.db.utils.OperationalError: connection refused"
The database container isn’t ready yet. This is the race condition from Part 1. The fix is the same:
docker compose restart backend
Or wait 5 seconds and let it retry. PostgreSQL needs time to initialize on first boot.
"relation housing_property does not exist"
You’re querying a table that doesn’t exist yet. Migrations haven’t run, or they ran against the wrong database. Check:
docker compose exec backend python manage.py showmigrations housing
If you see [ ] (unchecked boxes), the migrations exist but haven’t been applied. Run:
docker compose exec backend python manage.py migrate
If you see no migrations at all, run makemigrations first:
docker compose exec backend python manage.py makemigrations
docker compose exec backend python manage.py migrate
The @property / ForeignKey name collision
Covered in detail in Part C. The short version: never name a Django model field property. It shadows Python’s built-in @property decorator and causes a TypeError that looks like a Django bug but is a Python scoping issue. Rename the field. Problem disappears.
Part J: Verification — Prove Everything Works
Run these in order. Every one should pass before you move to the next.
# 1. Tables exist
docker compose exec db psql -U user -d housing_db -c "\dt"
# 2. Data is there
docker compose exec db psql -U user -d housing_db -c "SELECT COUNT(*) FROM housing_property;"
# 3. Django can talk to PostgreSQL (not SQLite)
docker compose exec backend python manage.py dbshell
# Should open a psql> prompt. Type \q to exit.
# 4. Django can talk to Redis
docker compose exec backend python manage.py shell
Inside the shell, run:
from django.core.cache import cache
cache.set('part2_test', 'redis is alive', timeout=60)
print(cache.get('part2_test')) # Should print: redis is alive
exit()
Now prove it actually hit Redis and not LocMemCache:
# In a separate terminal, check Redis directly
docker compose exec redis redis-cli keys "*"
# Should show: "part2_test"
docker compose exec redis redis-cli get "part2_test"
# Should show: "redis is alive"
If redis-cli keys * shows part2_test, the cache is going to Redis. Not local memory. The wiring is complete.
# 5. Admin panel has data
# Open http://localhost:8000/admin/ in your browser
# Log in with the superuser credentials from Part E
# Click Housing → Properties. You should see 3000 rows.
Part K: Git — Checkpoint the Work
cd housing-caching-demo
git add .
git commit -m "feat: add database schema, models, migrations, and seed data"
git checkout -b part-2-data
git push origin part-2-data
The branch is called part-2-data. Same pattern as Part 1. You can diff between any two parts at any time:
git diff part-1-setup..part-2-data
What We Have Now — And What’s Next
We started Part 2 with a running infrastructure and a Django app that was talking to nothing real. We ended it with five tables, 3000 properties, 6000 images, and a Django application that is genuinely connected to both PostgreSQL and Redis.
But nothing is cached yet. The database is doing all the work. Every listing page request hits the database directly. Every image fetch scans the property_images table with no index. Every property page increments view_count with a direct DB write. The system works. It’s just slow — and it’s going to get slower as the data grows.
That’s exactly where we want to be. Part 3 is where we make the slowness visible — we’ll measure it, we’ll see it in query plans, and then we’ll fix it. Layer by layer. Cache by cache.
Stay tuned.