02. Models, views, routing — the developer's daily surface¶
~14 min read. The internals explain what each door does. Day-to-day, you write models, views, URLs, and templates. This chapter is the working developer's catalogue — what to choose, what to avoid, and why.
Builds on: 01-orm-request-cycle-internals.md.
The previous chapter showed the ORM in action. This chapter is the surface you touch every day: defining models with the right field types, writing views (function-based vs. class-based), routing URLs, processing forms, rendering templates. Each section is a short catalogue of the choices and their trade-offs.
1) Models — fields, relationships, and the catalogue¶
A Django model is a Python class mapping to a database table.
class Order(models.Model):
customer = models.ForeignKey('Customer', on_delete=models.PROTECT, related_name='orders')
status = models.CharField(max_length=20, choices=[
('pending', 'Pending'),
('paid', 'Paid'),
('shipped', 'Shipped'),
('cancelled', 'Cancelled'),
], default='pending', db_index=True)
amount = models.DecimalField(max_digits=12, decimal_places=2)
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
updated_at = models.DateTimeField(auto_now=True)
metadata = models.JSONField(default=dict, blank=True)
class Meta:
ordering = ['-created_at']
indexes = [
models.Index(fields=['customer', '-created_at'], name='orders_cust_created_idx'),
]
def __str__(self):
return f'Order #{self.id} ({self.status})'
The non-obvious choices:
on_delete=models.PROTECTvs.CASCADEvs.SET_NULL.PROTECTrefuses to delete a parent that has children — the safer default for orders.CASCADEdeletes children — useful for tightly-owned data (an Order'sOrderItemrows).SET_NULLrequiresnull=Trueon the field. Choose based on whether the child row is independently meaningful.db_index=Trueonstatusandcreated_at. The most-filtered columns deserve indexes. Composite indexes (inMeta.indexes) handle multi-column lookups.DecimalFieldfor money. NeverFloatField. Currency arithmetic in floating point produces incorrect totals at the rupee level.auto_now_addvs.auto_now. The first runs only on insert; the second runs on every save.created_atuses one;updated_atuses the other.JSONFieldfor flexible attributes. Useful for metadata that varies per row (e.g., shipping options, integration payloads). Cannot be efficiently filtered without GIN indexes; Postgres supports them; SQLite does not.
2) Migrations — the schema as code¶
Django migrations are Python files that evolve the schema.
# After changing a model:
python manage.py makemigrations
# Creates myapp/migrations/0042_order_metadata.py
# Apply:
python manage.py migrate
Migrations are committed to git. The database state is the sum of applied migrations. Three patterns matter in production:
Squash old migrations periodically. A long-lived project accumulates hundreds of migrations. python manage.py squashmigrations myapp 0001 0040 replaces 40 migrations with one. Speed up tests; reduce noise.
Schema migrations vs. data migrations. Schema migrations change the table (add column, drop column, add index). Data migrations change rows (RunPython). Data migrations should be idempotent (run twice without harm) because partial failures retry.
Backwards-incompatible migrations. A migration that renames a column while old code is still running breaks. Two-deploy pattern: deploy 1 adds the new column and writes to both; backfill old → new; deploy 2 reads from new and drops old. Never deploy a single migration that breaks the running version.
3) Views — function-based vs. class-based¶
A view is a callable that takes an HttpRequest and returns an HttpResponse.
Function-based view (FBV):
def order_detail(request, order_id):
order = get_object_or_404(Order, pk=order_id)
if request.method == 'POST':
form = OrderForm(request.POST, instance=order)
if form.is_valid():
form.save()
return redirect('order_detail', order_id=order.id)
else:
form = OrderForm(instance=order)
return render(request, 'orders/detail.html', {'order': order, 'form': form})
Class-based view (CBV):
class OrderDetailView(UpdateView):
model = Order
form_class = OrderForm
template_name = 'orders/detail.html'
success_url = reverse_lazy('order_list')
def get_object(self, queryset=None):
return get_object_or_404(Order, pk=self.kwargs['order_id'])
The CBV is shorter when the pattern fits (CRUD on a model). The FBV is clearer when the logic is custom (orchestration of multiple models, branching responses, side effects).
The mature pattern: use generic CBVs (ListView, DetailView, CreateView, UpdateView, DeleteView) for plain CRUD; use FBVs for anything else. Mixing both in one project is normal; choosing one religiously is not.
Avoid: deep inheritance chains over CBVs (MyMixinedAuthenticatedFilteredView(SomeMixin, AnotherMixin, ListView)). The mixin tower is hard to read; explicit FBVs win.
4) URLs — patterns, names, and reverse¶
URL configuration is a Python list mapping patterns to views.
# myapp/urls.py
from django.urls import path, include
urlpatterns = [
path('orders/', OrderListView.as_view(), name='order_list'),
path('orders/<int:order_id>/', OrderDetailView.as_view(), name='order_detail'),
path('orders/<int:order_id>/ship/', ship_order, name='order_ship'),
path('api/', include('myapp.api.urls')),
]
# project/urls.py
urlpatterns = [
path('admin/', admin.site.urls),
path('', include('myapp.urls')),
]
Three discipline points:
Always name URLs. name='order_detail' lets templates and views call {% url 'order_detail' order_id=42 %} or reverse('order_detail', kwargs={'order_id': 42}). Hardcoding /orders/42/ in templates ties you to the URL structure forever.
Use path converters, not regex. <int:order_id> is preferred over (?P<order_id>\d+). Same effect, cleaner read.
Namespace included URLConfs. path('api/', include('myapp.api.urls', namespace='api')) lets reverse('api:order_detail') distinguish from the HTML view.
5) Forms — validation and the round-trip¶
Forms validate incoming data and produce errors for re-rendering.
class OrderForm(forms.ModelForm):
class Meta:
model = Order
fields = ['customer', 'status', 'amount']
def clean_amount(self):
amount = self.cleaned_data['amount']
if amount < 0:
raise ValidationError("Amount must be non-negative.")
return amount
def clean(self):
cleaned = super().clean()
if cleaned.get('status') == 'shipped' and not cleaned.get('customer'):
raise ValidationError("Cannot ship an order with no customer.")
return cleaned
The view pattern is the POST/redirect/GET cycle:
def order_create(request):
if request.method == 'POST':
form = OrderForm(request.POST)
if form.is_valid():
order = form.save()
return redirect('order_detail', order_id=order.id)
else:
form = OrderForm()
return render(request, 'orders/create.html', {'form': form})
For API endpoints, Django REST Framework's Serializer replaces Form with the same shape (is_valid(), save(), errors as JSON).
6) Templates — the rendering layer¶
Django's template language is intentionally restricted: no arbitrary Python in templates, only declared template tags and filters.
{# orders/detail.html #}
{% extends 'base.html' %}
{% block content %}
<h1>Order #{{ order.id }}</h1>
<p>Status: <strong>{{ order.status|title }}</strong></p>
<p>Total: ₹{{ order.amount|floatformat:2 }}</p>
<h2>Items</h2>
<ul>
{% for item in order.items.all %}
<li>{{ item.product.name }} × {{ item.qty }}</li>
{% empty %}
<li>No items.</li>
{% endfor %}
</ul>
<form method="post">
{% csrf_token %}
{{ form.as_p }}
<button type="submit">Save</button>
</form>
{% endblock %}
Three things to remember:
{% csrf_token %}in every form. Without it, the CSRF middleware rejects the POST.order.items.allevaluates the queryset. Ifitemsis not prefetched on the view's queryset, this is N+1. The template makes the optimisation harder to see; the chapter-01 discipline still applies.- Custom template tags and filters live in
templatetags/. Keep template logic minimal; complex logic belongs in the view or a service function.
For modern frontends, the template layer often shrinks — Django becomes a JSON API (via DRF) and the front-end is React/Vue/HTMX. The template language is still useful for emails, server-rendered admin views, and small dashboards.
7) The admin — the auto-generated UI¶
# orders/admin.py
from django.contrib import admin
from .models import Order, OrderItem
class OrderItemInline(admin.TabularInline):
model = OrderItem
extra = 0
@admin.register(Order)
class OrderAdmin(admin.ModelAdmin):
list_display = ['id', 'customer', 'status', 'amount', 'created_at']
list_filter = ['status', 'created_at']
search_fields = ['id', 'customer__email']
inlines = [OrderItemInline]
autocomplete_fields = ['customer']
Three minutes of configuration produces a working CRUD UI for staff. The admin is unmatched for internal tools and never quite right for customer-facing UI.
The two patterns:
- Use the admin for staff. Internal back-office workflows where staff trust the data model. List filters, search, bulk actions, model history — all there.
- Do not use the admin for end users. Customisation past a point is more work than building a dedicated UI; the admin's mental model leaks (it shows the database schema, not a user task).
8) Permissions and authentication¶
class OrderListView(LoginRequiredMixin, ListView):
model = Order
def get_queryset(self):
# Multi-tenant scoping — every list query filters by the user's tenant.
return Order.objects.filter(customer__tenant=self.request.user.tenant)
Three layers:
- Authentication. Who is the user? Built-in
AuthenticationMiddlewaresetsrequest.userfrom the session. - Permissions. What can the user do? Built-in
Permissionmodel assignsadd_order,change_order,delete_order,view_orderper user/group. Customisable viahas_perm(). - Object-level permissions. Can the user see this order? Library:
django-guardian. Pattern: enforce inget_queryset()so the user only ever sees rows their tenant owns. Defence-in-depth: also check inget_object()for object detail views.
The most common production mistake is enforcing only at the view layer ("staff users see this list") without filtering the queryset. A user can craft /orders/<id>/ for an ID they should not see; the view's permission check passes (they are staff); the object is returned. Always scope the queryset.
9) Settings — the production-vs-dev split¶
A settings.py file is one of the trickiest production surfaces.
# settings/base.py — shared
INSTALLED_APPS = [...]
MIDDLEWARE = [...]
# settings/dev.py
from .base import *
DEBUG = True
DATABASES = {'default': sqlite3 ...}
# settings/production.py
from .base import *
DEBUG = False
ALLOWED_HOSTS = ['.example.com']
DATABASES = {'default': postgres ...}
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
Sensitive values (SECRET_KEY, database password) come from environment variables, never committed.
import os
SECRET_KEY = os.environ['DJANGO_SECRET_KEY']
DATABASES['default']['PASSWORD'] = os.environ['DB_PASSWORD']
Libraries to consider: django-environ for env-var parsing, pydantic for typed settings. Avoid: local_settings.py patterns that get out of sync; secrets in committed files.
10) Project layout — apps and the team's third year¶
A new Django project starts with one app. By year three, the project has 20 apps, and the apps' boundaries are the codebase's most-debated topic.
Three patterns that survive:
- One app per bounded context.
orders/,payments/,inventory/,accounts/. Each app's models, views, URLs, templates live together. Apps depend on each other through clear interfaces (service functions, signals, public APIs), not through tangled imports. services.pyfor cross-cutting logic. Each app has aservices.py(orservices/) module — pure-Python functions that encapsulate business logic. Views call services; services call models; models stay thin. This is the pattern that lets you test business logic without HTTP.api/subpackage for REST.orders/api/views.py,orders/api/serializers.py,orders/api/urls.py. Keeps HTML views and JSON views distinct; both share models.
What collapses by year three:
- Fat models. Putting all logic on the model class produces an unmaintainable thousand-line
models.py. Push logic to services. - God views. A view that does ORM queries, validates forms, sends emails, calls third-party APIs, renders HTML. Push everything but the HTTP shape to services.
- Implicit cross-app dependencies via signals. "When an Order saves, the inventory app's signal handler updates stock." Mature teams replace this with explicit service calls; signals are kept for cross-cutting concerns (audit log) only.
Operational signals¶
Healthy. Apps have clear ownership; service functions are tested; admin reflects the data model; permissions enforced both at the view and the queryset.
First degrading metric. A models.py over 500 lines, or a view function over 100 lines. The boundaries have eroded.
Misleading metric. Number of apps. Many small apps with circular dependencies is worse than few well-bounded apps.
Expert graph. Per-app dependency graph (which apps import from which); test coverage per service module; admin usage analytics.
Where this appears in production¶
- Instagram (early Django) — separated business logic into services; the pattern is canonical now.
- Pinterest — uses Django's admin for ops; their patterns around
autocomplete_fieldsandlist_filterare widely copied. - Dropbox (parts of admin tooling) — Django for internal tooling;
services.pyper app pattern. - Eventbrite — multi-tenant
get_querysetscoping; well-documented patterns. - Mozilla — large codebase with strict app boundaries; their PR review focuses on cross-app coupling.
- The Washington Post — Django CMS; CBV vs. FBV split decided per feature.
- Disney+ Hotstar (backend tooling) — Django for some internal portals;
django-guardianfor per-object permissions. - Goa-based real-estate SaaS — multi-tenant Django; queryset scoping enforced via custom middleware.
Recall / checkpoint¶
- What is the difference between
on_delete=CASCADEandPROTECT? - Why use
DecimalFieldfor money, notFloatField? - When do you choose a class-based view over a function-based view?
- What is the POST/redirect/GET cycle?
- Why is filtering at the queryset level (not just the view) load-bearing for security?
- What is a service module and what does it solve?
- How do migrations evolve safely across deploys?
Interview Q&A¶
Q1. A multi-tenant Django app: a tenant reports they can see another tenant's order detail. Walk through the diagnosis and fix.
The diagnosis is permission enforcement only at the view layer, not the queryset. The view checked "user is staff" and returned the object. The fix is to filter the queryset in get_queryset() to the user's tenant; then get_object() (which uses the queryset) returns 404 for cross-tenant IDs. Add object-level permission checks as defence-in-depth. The principle is: scope the queryset; everything downstream inherits the scope. Common wrong answer to avoid: "add a permission check in the view" — necessary but not sufficient; the queryset is the structural defence.
Q2. The team's models.py is 1,800 lines. What is the structural problem and what is the fix?
Fat models are the problem: business logic on the model class produces an unmaintainable file. The fix is services.py per app — pure-Python functions encapsulating business logic, called from views, callable from management commands and tests. Models stay thin (fields, relationships, basic validation). The migration is gradual: extract one service per sprint, keep the model methods that delegate to services as deprecated. Common wrong answer to avoid: "split into more models" — the symptom, not the cause.
Q3. A migration adds a non-nullable column to a 50M-row table. Walk through the deploy plan.
Single migration that adds NOT NULL will block the table during the backfill — potentially hours of downtime. The pattern: (1) deploy a migration that adds the column as nullable with a default, (2) backfill in a data migration or background job that processes in batches, (3) deploy a second migration that flips the column to non-nullable after the backfill completes. Each step is reversible. Tools: django-migration-linter flags risky migrations in CI. Common wrong answer to avoid: "just run it during a maintenance window" — a window for 50M rows can be 8+ hours; better to roll out incrementally.
Q4. The team uses signals for sending emails on Order creation. A new bulk import path doesn't trigger emails. Walk through the response.
The diagnosis is correct: bulk_create() skips signals. The deeper question is whether signals should own this logic at all. Mature teams move side effects to explicit service functions called from both the save() path and the bulk path; signals are reserved for cross-cutting concerns (audit log) and integration points (allauth listening for user creation). Refactor: extract send_order_email(order) service; call from Order.save() (or a service function create_order()) and from the bulk path. Remove the signal. Common wrong answer to avoid: "use signals on the bulk path" — signals don't fire there by design.
Q5. The admin is being used as the customer-facing UI for a small SaaS. What is the trade-off and the threshold to migrate? Trade-off: speed-to-build vs. user experience. The admin shows the database schema, not a user task; permissions and customisation past a point cost more than building a dedicated UI. Threshold: when the admin's "list of orders" no longer matches what a user wants to do (they want a dashboard, not a CRUD table), it is time to move. The migration is gradual: build the dashboard alongside the admin; route customers to the dashboard; keep the admin for staff only. Common wrong answer to avoid: "the admin is enough forever" — it eventually constrains the product.
Q6. How do you structure URLs for a versioned REST API in Django?
/api/v1/ namespace via include('myapp.api.v1.urls', namespace='api-v1'). Each version gets its own URLconf, its own serializers, its own views. Shared models and services live in the main app. The version namespace lets you reverse('api-v1:order_detail') and keep v2 alongside without conflict. URL parameters (path or query) are part of the versioned contract. Common wrong answer to avoid: "version in headers only" — works but harder to debug; URL versioning is more discoverable.
Operational memory¶
This chapter explained the daily Django surface: model design, migrations, views, URLs, forms, templates, admin, permissions, settings, project layout. The important idea is that Django's defaults are good for fast starts, but production maturity requires explicit boundaries — services per app, queryset scoping for security, services replacing fat models and god views.
You learned to choose field types, structure URLs, write views (FBV or CBV per fit), enforce permissions at the queryset, and lay out a project that survives the team's third year. That solves the opening question of "how do I actually write Django?" because every surface has a default and a known evolution.
Carry this diagnostic forward: when a Django codebase smells like cargo cult, ask which of these surfaces has degraded — fat models, god views, ungated querysets, signal abuse, or admin overuse. Each has a known fix.
Remember:
on_delete=PROTECTfor independently meaningful children;CASCADEfor tightly-owned.DecimalFieldfor money; neverFloatField.- Use CBV for plain CRUD, FBV for everything else.
- Scope at the queryset; defence-in-depth at the view.
services.pyper app keeps models and views thin.- Migrations evolve in steps; never a single deploy that breaks the running version.
Bridge. The development surface is set. Production has its own surface: gunicorn workers, static files, database pooling, async, caching, the long tail of operational gotchas. The next chapter is that surface. → 03-deployment-scaling-prod-gotchas.md