The 60 Django questions interviewers actually ask, with direct answers, runnable code, and what the interviewer is listening for. Grouped for freshers, intermediate, and experienced rounds.
60 questions with answersKey Takeaways
Django is a high-level Python web framework created at the Lawrence Journal-World newspaper and released publicly in 2005. It follows the Model-View-Template (MVT) pattern and describes itself as batteries included: an object-relational mapper, an automatic admin interface, an authentication system, form handling, and a set of security defaults all ship in the box. The official Django documentation calls it a framework that lets developers build web applications quickly while encouraging clean, pragmatic design. In interviews, Django questions test whether you understand the request/response cycle, how the ORM turns Python into SQL, and when to reach for the framework's built-ins versus rolling your own. This page collects the 60 questions that come up most, each with a direct answer and runnable code. Increasingly the first backend round is a live AI coding interview, so pair this bank with our AI interview preparation guides for the format side.
Watch: Python Django Web Framework - Full Course for Beginners
Video: Python Django Web Framework - Full Course for Beginners (freeCodeCamp.org, YouTube)
Test yourself and earn a certificate
10 quick questions. Score 70%+ to download your Django certificate.
The fundamentals every entry-level round checks. If any answer here surprises you, that's your study list.
Django is a high-level Python web framework that ships with an ORM, an admin site, authentication, forms, and security defaults built in. It handles the plumbing every web app needs so you write features instead of boilerplate.
It solves the repetitive parts of web development: mapping URLs to code, talking to a database without hand-writing SQL, rendering HTML, validating input, and blocking common attacks. That's why a small team can ship a real product fast.
Key point: A one-line definition plus two concrete built-ins (the ORM and the admin) beats a memorized feature list. Interviewers open with this to hear how you organize an answer.
Watch a deeper explanation
Video: Python Django Explained In 8 Minutes (Dennis Ivy, YouTube)
MVT is Model, View, Template. The Model defines your data and talks to the database, the View holds the request-handling logic and decides what data to return, and the Template renders that data into HTML.
It's Django's take on MVC. The framework itself plays the controller role by routing incoming URLs to the right view, so you write models, views, and templates and let Django wire them together.
| Part | Responsibility | Lives in |
|---|---|---|
| Model | Data structure and database access | models.py |
| View | Request logic, choose and return data | views.py |
| Template | Present data as HTML | templates/ |
Key point: The common follow-up is 'how does MVT differ from MVC?'. Say Django is the controller and you're done.
A project is the whole website: settings, the root URL config, and the collection of apps that make it up. An app is a self-contained module that does one thing, like a blog, a payments feature, or user accounts.
One project holds many apps, and a well-written app can be reused across projects. Keeping features in separate apps is how Django codebases stay organized as they grow.
Key point: Mention reusability. Interviewers like hearing that apps are meant to be pluggable, not just folders.
The ORM (object-relational mapper) lets you work with the database using Python classes and objects instead of writing SQL. A model class maps to a table, an instance maps to a row, and methods like filter() and get() build the queries for you.
You use it because it's faster to write, portable across databases (SQLite, PostgreSQL, MySQL), and safe by default: queries are parameterized, so ordinary use won't expose you to SQL injection.
from django.db import models
class Book(models.Model):
title = models.CharField(max_length=200)
published = models.DateField()
# query with Python, not SQL
recent = Book.objects.filter(published__year=2026).order_by("title")Key point: Say the ORM is convenient but not free: you should still know what SQL it generates. That awareness is what separates juniors from mid-level.
Watch a deeper explanation
Video: Django Tutorial for Beginners | Full Course (Telusko, YouTube)
You subclass django.db.models.Model and declare each column as a class attribute using a field type: CharField for short text, TextField for long text, IntegerField, DateTimeField, ForeignKey for relations, and so on. Django reads those fields to build the table.
Each field takes options like max_length, null, blank, default, and unique. Once the model is defined, makemigrations and migrate create the matching table.
from django.db import models
class Author(models.Model):
name = models.CharField(max_length=120)
email = models.EmailField(unique=True)
joined = models.DateTimeField(auto_now_add=True)
def __str__(self):
return self.nameMigrations turn changes in your models into versioned, ordered database schema changes. They keep your Python models and the actual database structure in sync, and they're stored as files so the whole team applies the same changes.
makemigrations reads model changes and writes migration files; migrate applies pending migrations to the database. showmigrations lists what's applied, and sqlmigrate prints the SQL a migration will run.
python manage.py makemigrations # create migration files from model changes
python manage.py migrate # apply them to the database
python manage.py showmigrations # see what is appliedKey point: If asked what makemigrations does versus migrate, be precise: one writes the plan, the other executes it. Mixing them up is a common junior slip.
A view is the code that takes a web request and returns a web response. Function-based views (FBVs) are plain functions that receive the request and return an HttpResponse. Class-based views (CBVs) are classes that map HTTP methods to methods like get() and post() and offer reusable generic views.
FBVs are explicit and easy to read for simple logic. CBVs shine when you want the built-in generic views (ListView, DetailView, CreateView) to remove repetitive create/read/update/delete code.
from django.http import HttpResponse
from django.views import View
# function-based view
def hello(request):
return HttpResponse("Hello")
# class-based view
class HelloView(View):
def get(self, request):
return HttpResponse("Hello")Key point: Have a rule ready: FBV for one-off custom logic, CBV when a generic view already does most of the work. the question needs your judgment, not a preference.
Django matches an incoming path against a list of URL patterns defined with path() or re_path() in a urls.py. The first pattern that matches wins, and Django calls the view attached to it, passing any captured parameters.
Projects usually keep a root urls.py that includes each app's urls.py with include(), so routing stays modular. Naming a pattern lets you reverse it with reverse() or the {% url %} template tag instead of hardcoding paths.
from django.urls import path
from . import views
urlpatterns = [
path("books/", views.book_list, name="book-list"),
path("books/<int:pk>/", views.book_detail, name="book-detail"),
]The Django template language (DTL) renders HTML from context data using {{ variables }}, {% tags %} for logic like loops and conditionals, and filters like {{ name|upper }} to transform values. It's deliberately limited so presentation logic stays in templates and real logic stays in Python.
Templates support inheritance: a base template defines blocks, and child templates override them, which keeps shared layout in one place.
{% extends 'base.html' %}
{% block content %}
<ul>
{% for book in books %}
<li>{{ book.title|title }}</li>
{% endfor %}
</ul>
{% endblock %}The Django admin is an auto-generated web interface for managing your data. Register a model and you get create, read, update, and delete screens for it, with search, filters, and permissions, without writing any of that UI yourself.
It's genuinely useful for internal tools, content management, and early-stage debugging. You customize it with ModelAdmin classes to control which fields show, how they're filtered, and who can access them.
from django.contrib import admin
from .models import Book
@admin.register(Book)
class BookAdmin(admin.ModelAdmin):
list_display = ("title", "published")
search_fields = ("title",)
list_filter = ("published",)Key point: Say the admin is for staff, not end users. Candidates who suggest exposing it as a public UI raise a flag.
settings.py holds a project's configuration in one place: the database connection, INSTALLED_APPS, the MIDDLEWARE chain, template directories, static and media settings, the SECRET_KEY, the DEBUG flag, ALLOWED_HOSTS, and the time zone. Django reads it at startup to wire everything together.
In production you split or override settings per environment and pull secrets from environment variables, never hardcoding them. DEBUG must be False in production, and the SECRET_KEY must stay out of version control.
Key point: If you say DEBUG stays True in production, that's an instant red flag. Know that DEBUG leaks stack traces and settings.
all() returns every row as a queryset. filter() returns a queryset of rows matching the conditions, which may be zero, one, or many. get() returns exactly one object and raises DoesNotExist if none match or MultipleObjectsReturned if more than one does.
Use get() when you expect a single, unique result (usually by primary key), and filter() when you expect a set. Wrapping get() in a try/except, or using get_object_or_404, is the safe pattern.
Book.objects.all() # queryset of every book
Book.objects.filter(author__name="Asha") # queryset, could be empty
Book.objects.get(pk=7) # one object or raisesKey point: The trap is calling get() where results might not be unique. The two exceptions it can raise matters.
Static files are assets that ship with your code and don't change per user: CSS, JavaScript, images that are part of the design. Media files are user-uploaded content: profile pictures, document attachments, anything submitted at runtime.
Django serves them through different settings (STATIC_URL and STATICFILES for static, MEDIA_URL and MEDIA_ROOT for media). In production a web server or CDN serves static files, and media usually lives in object storage.
Building a queryset runs no SQL. Django waits until you actually need the data, then evaluates the query. So you can chain filter().exclude().order_by() and only one query fires, when the result is consumed.
Evaluation happens when you iterate the queryset, call list() or len(), slice to a single index, or test it in a boolean context. Understanding this explains why chaining is cheap and why a stray len() can trigger an unwanted query.
qs = Book.objects.filter(published__year=2026) # no SQL yet
qs = qs.order_by("title") # still no SQL
for book in qs: # SQL runs here
print(book.title)Key point: This laziness sets up the N+1 and caching questions at the next tier. Nail the four triggers of evaluation.
Cross-site request forgery tricks a logged-in user's browser into sending a state-changing request to your site from a malicious page. Django's CsrfViewMiddleware defends against it by requiring a secret token on POST, PUT, PATCH, and DELETE requests.
In templates you add {% csrf_token %} inside forms; Django checks the submitted token against the one tied to the session. Requests without a valid token are rejected with a 403.
<form method="post">
{% csrf_token %}
<input name="title">
<button type="submit">Save</button>
</form>A Form class declares fields with their types and validation rules. Django renders those fields to HTML, and on submission it validates the data: form.is_valid() runs the checks and populates form.cleaned_data with typed, safe values.
ModelForm builds a form directly from a model, so you don't repeat field definitions and can call form.save() to write to the database. Forms centralize validation and protect against bad input.
from django import forms
from .models import Book
class BookForm(forms.ModelForm):
class Meta:
model = Book
fields = ["title", "published"]
# in a view
form = BookForm(request.POST)
if form.is_valid():
form.save()null=True controls the database: it allows the column to store NULL. blank=True controls validation: it allows forms and the admin to accept an empty value. They live at different layers.
For text-based fields (CharField, TextField) the convention is to use blank=True but not null=True, because Django stores an empty string rather than NULL for missing text, which avoids two ways to represent no value.
| Option | Layer | Effect |
|---|---|---|
| null=True | Database | Column can be NULL |
| blank=True | Validation | Field can be left empty in forms |
Key point: The text-field convention (blank but not null) is a favorite follow-up. Explaining the empty-string reasoning shows real experience.
WSGI and ASGI are the interfaces between a Django app and the server that runs it. WSGI is the older synchronous standard; ASGI is the newer one that also supports asynchronous views, WebSockets, and long-lived connections.
Django generates a wsgi.py and an asgi.py in every project. You run the app behind a server like Gunicorn (WSGI) or Uvicorn/Daphne (ASGI) in production; the built-in runserver is for development only.
Key point: If you only know WSGI, at least name ASGI as the async path. Saying runserver is production-ready is a mistake to avoid.
The Django shell is an interactive Python session with your project's settings and models loaded. You start it with python manage.py shell, then import models and run queries, test ORM calls, or inspect data directly.
It's the fastest way to experiment with querysets, reproduce a bug, or check what SQL a query generates before you put it in code. shell_plus from django-extensions auto-imports your models to save typing.
python manage.py shell
>>> from books.models import Book
>>> Book.objects.filter(published__year=2026).count()
12ForeignKey models a many-to-one relation: many books belong to one author. ManyToManyField models a many-to-many relation: a book can have many tags and a tag many books, which Django backs with a join table. OneToOneField models a strict one-to-one link, often used to extend another model.
Pick the field that matches the real-world cardinality. A profile that extends a user is one-to-one; a book's author is many-to-one; a book's tags are many-to-many.
class Author(models.Model):
name = models.CharField(max_length=120)
class Tag(models.Model):
label = models.CharField(max_length=40)
class Book(models.Model):
author = models.ForeignKey(Author, on_delete=models.CASCADE) # many-to-one
tags = models.ManyToManyField(Tag) # many-to-manyKey point: The follow-up is 'what table does ManyToMany create?'. Knowing it builds a hidden join table shows you understand what the ORM does underneath.
render() combines a template with a context dictionary and returns an HttpResponse containing the rendered HTML in one step, so you don't build the response by hand. redirect() returns an HTTP redirect response that sends the browser to another URL, a view name, or a model's absolute URL.
The pattern to remember is redirect after a successful POST, so a page refresh doesn't resubmit the form. Use render() to show a page and redirect() to move the user after an action.
from django.shortcuts import render, redirect
def create_book(request):
if request.method == "POST":
form = BookForm(request.POST)
if form.is_valid():
form.save()
return redirect("book-list") # post-then-redirect
return render(request, "new.html", {"form": BookForm()})For candidates with working experience: ORM depth, middleware, signals, and the questions that separate users from understanders.
N+1 happens when you fetch a list of objects with one query, then trigger a separate query for each object's related data inside a loop. A page showing 100 books plus each author fires 1 query for the books and 100 for the authors: 101 queries where 1 or 2 would do.
The fix is to tell the ORM up front what you'll need: select_related for to-one relations, prefetch_related for to-many. Tools like Django Debug Toolbar or logging the queries reveal N+1 fast.
# N+1: one query for books, one per author
for book in Book.objects.all():
print(book.author.name)
# fixed: one JOINed query
for book in Book.objects.select_related("author"):
print(book.author.name)Key point: If you can The tool you'd use to detect it (Debug Toolbar, query logging), you sound like someone who has actually shipped Django.
Middleware is an ordered chain of components that wrap the view. On the way in, each middleware can inspect or modify the request; on the way out, each can inspect or modify the response. Django's own auth, session, CSRF, and security features are middleware.
You write custom middleware for cross-cutting concerns that apply to many views: request timing, adding headers, tenant resolution, or rate limiting. Order in the MIDDLEWARE list matters because it defines the wrapping sequence.
class TimingMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
import time
start = time.perf_counter()
response = self.get_response(request)
response['X-Elapsed'] = f"{time.perf_counter() - start:.3f}"
return responseSignals let one part of your code notify another when something happens, without the two being directly coupled. Built-in signals include pre_save, post_save, pre_delete, and post_delete on models, plus request-lifecycle signals.
Use them for genuinely decoupled side effects: creating a profile when a user is created, invalidating a cache when a record changes. The caution: signals hide control flow, so for logic tied to one place, an explicit call or a model save() override is clearer.
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.contrib.auth.models import User
from .models import Profile
@receiver(post_save, sender=User)
def create_profile(sender, instance, created, **kwargs):
if created:
Profile.objects.create(user=instance)Key point: Volunteer the downside. Saying signals can obscure flow shows you'd reach for them deliberately, not reflexively.
aggregate() collapses a whole queryset into a single dictionary of computed values: total count, average price, max date. annotate() adds a computed value to each row of the queryset, so each object carries its own aggregate.
Use aggregate() for one summary number over the set, and annotate() when you need a per-object figure, like each author with their book count. annotate() commonly pairs with values() and group-by behavior.
from django.db.models import Count, Avg
# one number for the whole set
Book.objects.aggregate(avg_pages=Avg("pages"))
# a count attached to each author
Author.objects.annotate(num_books=Count("book"))Key point: The one-line rule the question needs: aggregate collapses the set, annotate adds per-row. Give an example of each.
Q objects build complex query conditions with OR, AND, and NOT, which plain keyword filters can't express because keyword arguments only combine with AND. You wrap conditions in Q() and join them with | and &.
F expressions reference a model field's value inside a query or update, so the database does the arithmetic without loading the value into Python. That makes field-to-field comparisons and atomic increments possible.
from django.db.models import Q, F
# OR condition
Book.objects.filter(Q(title__icontains="django") | Q(title__icontains="python"))
# atomic increment at the database
Book.objects.filter(pk=1).update(views=F("views") + 1)Key point: The F-expression increment is the interviewer's favorite: it avoids a read-modify-write race. Mention that it's atomic.
By default each query autocommits. To group operations so they succeed or fail together, wrap them in transaction.atomic() as a context manager or decorator: if any operation inside raises, the whole block rolls back.
You can also set ATOMIC_REQUESTS to wrap each request in a transaction. For long operations, use select_for_update() to lock rows and avoid lost updates under concurrency.
from django.db import transaction
with transaction.atomic():
account.balance -= 100
account.save()
ledger.record(-100)
# if ledger.record raises, the balance change rolls back tooDjango stores a session ID in a cookie on the client and keeps the actual session data server-side, keyed by that ID. By default the data lives in the database, but you can back sessions with the cache, files, or signed cookies.
You read and write session data through request.session, which behaves like a dict. Sessions carry things like the logged-in user and short-lived flags across requests without exposing the data to the browser.
def add_to_cart(request, item_id):
cart = request.session.get('cart', [])
cart.append(item_id)
request.session['cart'] = cart
return redirect('cart')A manager is the interface through which models run queries; objects is the default one. A custom manager lets you add reusable query methods or change the default queryset, so common filters live in one place instead of being repeated across views.
A frequent use is a manager that returns only published or active records, so callers write Article.published.all() instead of remembering the filter every time.
class PublishedManager(models.Manager):
def get_queryset(self):
return super().get_queryset().filter(status="published")
class Article(models.Model):
status = models.CharField(max_length=20)
objects = models.Manager() # default
published = PublishedManager() # custom
Article.published.all()Django has a layered cache framework. The per-site and per-view caches store whole responses; the low-level cache API (cache.get, cache.set) stores arbitrary values; and template fragment caching stores parts of a page. Backends include Redis, Memcached, the database, and local memory.
The rule is cache the expensive and stable, and invalidate deliberately. Cache keys, timeouts, and a plan for busting stale entries matter more than the backend choice.
from django.core.cache import cache
def expensive_report():
result = cache.get("monthly_report")
if result is None:
result = build_report() # slow
cache.set("monthly_report", result, timeout=3600)
return resultKey point: The follow-up is always invalidation. Saying caching is easy but invalidation is the hard part shows you've run this in production.
Django REST Framework (DRF) is the standard toolkit for building web APIs on top of Django. It adds serializers, viewsets, routers, authentication classes, and a browsable API. A serializer converts model instances and querysets into JSON for responses and validates and converts incoming JSON back into Python objects.
ModelSerializer generates fields directly from a model, similar to how ModelForm works. It handles both directions: serialization out and validated deserialization in.
from rest_framework import serializers
from .models import Book
class BookSerializer(serializers.ModelSerializer):
class Meta:
model = Book
fields = ["id", "title", "published"]
# instance -> JSON, and validated JSON -> instanceKey point: If the role mentions APIs, this is where the interview lives. Know serializer validation and the difference between a Serializer and a ModelSerializer.
Watch a deeper explanation
Video: Build a Django REST API with the Django Rest Framework. Complete Tutorial. (CodingEntrepreneurs, YouTube)
A viewset groups the logic for a set of related endpoints (list, retrieve, create, update, destroy) into one class, so you don't write a separate view per action. ModelViewSet gives you all of CRUD from a queryset and a serializer.
A router then generates the URL patterns for a viewset automatically, wiring the standard REST routes. Together they cut a lot of boilerplate for resource-style APIs.
from rest_framework import viewsets, routers
class BookViewSet(viewsets.ModelViewSet):
queryset = Book.objects.all()
serializer_class = BookSerializer
router = routers.DefaultRouter()
router.register(r"books", BookViewSet)
urlpatterns = router.urlsA context processor is a function that injects variables into the context of every template automatically, so you don't pass the same data from each view. Django ships several: request, auth (which adds user), and messages.
You write your own for site-wide values like a company name, feature flags, or a cart count. It returns a dict that gets merged into every template's context. Keep it cheap, since it runs on every render.
def site_settings(request):
return {
"site_name": "Hyring",
"support_email": "help@example.com",
}
# add the dotted path to TEMPLATES['OPTIONS']['context_processors']The inner Meta class holds model-level options that aren't fields: ordering for a default sort, db_table to override the table name, unique_together or constraints for multi-column uniqueness, verbose_name for display, and indexes for database indexes.
It's how you tune behavior and performance without adding columns. Adding an index on a frequently filtered field, or a default ordering, are common Meta uses.
class Book(models.Model):
title = models.CharField(max_length=200)
published = models.DateField()
class Meta:
ordering = ["-published"]
indexes = [models.Index(fields=["published"])]
constraints = [
models.UniqueConstraint(fields=["title"], name="unique_title"),
]get_object_or_404 runs a get() and, instead of raising DoesNotExist, raises Http404 when nothing matches, so Django returns a clean 404 page. get_list_or_404 does the same for a filter that returns an empty list.
They remove the repetitive try/except around lookups in views and turn a missing record into the correct HTTP response automatically.
from django.shortcuts import get_object_or_404
def book_detail(request, pk):
book = get_object_or_404(Book, pk=pk)
return render(request, "detail.html", {"book": book})on_delete tells Django what to do to rows that reference a record when that record is deleted. CASCADE deletes them too, PROTECT blocks the delete and raises, SET_NULL sets the reference to NULL (requires null=True), SET_DEFAULT uses the field's default, and DO_NOTHING leaves it to the database.
The choice is a data-integrity decision. CASCADE for owned children, PROTECT for references you don't want silently destroyed, SET_NULL when the link is optional.
| Option | Effect on delete |
|---|---|
| CASCADE | Delete the referencing rows too |
| PROTECT | Block the delete, raise ProtectedError |
| SET_NULL | Set the FK to NULL (needs null=True) |
| SET_DEFAULT | Set the FK to the field default |
| DO_NOTHING | Take no action, defer to the database |
Key point: on_delete is required with no default, on purpose. Explaining CASCADE versus PROTECT as a data-safety call is the answer they want.
Django's test framework builds on unittest and adds a TestCase that wraps each test in a transaction rolled back afterward, plus a test client that simulates requests. You get a fresh test database, fixtures or factories for data, and assertions like assertContains and assertRedirects.
Many teams run tests with pytest and pytest-django for its fixtures and cleaner syntax. Test models, views, and API endpoints, and mock external services so tests stay fast and deterministic.
from django.test import TestCase
from django.urls import reverse
class BookViewTests(TestCase):
def test_list_returns_200(self):
response = self.client.get(reverse("book-list"))
self.assertEqual(response.status_code, 200)bulk_create() inserts many objects in one query instead of one INSERT per object, and update() on a queryset writes a change to every matching row in a single UPDATE. Both are far faster than looping and calling save() per instance.
The catch that trips people: neither calls the model's save() method or fires pre_save and post_save signals, because they bypass the per-instance path. Logic living in save() or a signal won't run, so side effects you rely on silently don't happen.
Book.objects.bulk_create([
Book(title="A"),
Book(title="B"),
]) # one INSERT, no save() or signals
Book.objects.filter(published__year=2025).update(archived=True) # one UPDATEKey point: The 'skips save() and signals' detail is exactly what this question is testing. It's a real production bug source, so the key signal is it.
advanced rounds probe internals, design judgment, and production scars. Expect every answer here to draw a follow-up.
A request hits the server (Gunicorn or Uvicorn), which calls Django through WSGI or ASGI. Django builds an HttpRequest, then runs it down the middleware chain. URL resolution matches the path to a view. The view runs, usually touching the ORM and rendering a template or serializing data, and returns an HttpResponse.
The response travels back up the middleware chain, where components can modify it, and Django hands it to the server to send. Knowing where middleware, URL resolution, and the view sit in that order is what this question checks.
Key point: Draw the chain out loud: server, middleware in, URL resolver, view, response, middleware out. Missing the two-way middleware pass is the common gap.
Watch a deeper explanation
Video: Python Django Crash Course (Traversy Media, YouTube)
Scale in layers. At the database, add indexes, fix N+1 queries, use read replicas, and add connection pooling. At the application, run multiple stateless worker processes behind a load balancer and keep sessions in a shared store. Add caching (Redis) for hot reads, and move slow work off the request into a task queue like Celery.
The order matters: profile to find the real bottleneck first, then apply the cheapest fix that moves it. Most Django scaling problems are database problems wearing an application costume.
Key point: The production signal is saying most scaling issues are database-bound and that you'd measure before adding servers. Reaching for more machines first indicates junior.
For work that shouldn't block the request, offload it to a task queue. Celery with a broker like Redis or RabbitMQ is the common choice: the view enqueues a task and returns immediately, and a separate worker process runs it. Use it for emails, report generation, image processing, and third-party API calls.
For scheduled jobs, Celery Beat or a cron-driven management command works. Django's own async views and ASGI help with concurrent I/O within a request, but they don't replace a queue for durable, retryable background work.
from celery import shared_task
@shared_task
def send_welcome_email(user_id):
user = User.objects.get(pk=user_id)
email.send(user.email, template="welcome")
# in a view, enqueue and return fast
send_welcome_email.delay(user.id)Set AUTH_USER_MODEL to your model and subclass AbstractUser (to extend the default) or AbstractBaseUser (to build from scratch, defining the auth fields yourself). Do it at the very start of a project, before the first migration.
Adding it late is painful because the initial migrations already reference the built-in User with foreign keys and permissions. Migrating an existing user table to a new model means data migrations and untangling those references, which is why the advice is always to set the custom user model on day one.
Key point: The whole point of this question is the 'set it early' lesson. Candidates who've felt the late-migration pain answer it with feeling.
When a query is complex enough that the ORM makes it awkward or inefficient: heavy window functions, recursive CTEs, database-specific features, or a hand-tuned query that outperforms what the ORM generates. Django gives you Manager.raw() for model-mapped raw queries and connection.cursor() for arbitrary SQL.
The discipline: still parameterize (pass params, never format strings) to stay safe from injection, and keep raw SQL rare and documented. Most of the time extra_related, annotate, and Subquery keep you inside the ORM.
from django.db import connection
with connection.cursor() as cursor:
cursor.execute(
"SELECT author_id, COUNT(*) FROM books_book WHERE published_id = %s GROUP BY author_id",
[year_id], # parameterized, not string-formatted
)
rows = cursor.fetchall()Key point: Say you still parameterize raw SQL. Concatenating user input into a raw query is the one way to reintroduce injection, and the key signal is that.
Out of the box Django parameterizes ORM queries (SQL injection), auto-escapes template output (XSS), requires CSRF tokens on unsafe methods, hashes passwords with a strong algorithm, and offers clickjacking protection via X-Frame-Options middleware.
What's on you: set DEBUG=False and a real ALLOWED_HOSTS in production, keep the SECRET_KEY out of the repo, run over HTTPS with SECURE_SSL_REDIRECT and HSTS, set secure and httponly cookie flags, and validate file uploads. The framework gives defaults; production hardening is your job.
Key point: List the built-in defenses, then pivot to what you configure. Naming DEBUG=False and HSTS is what separates 'I read the docs' from 'I've deployed this'.
Measure first: Django Debug Toolbar or query logging shows the SQL, counts, and timings for a page. Look for N+1 loops, missing indexes on filtered or ordered columns, queries pulling more columns or rows than needed, and duplicate queries that caching could remove.
Then fix in order of impact: add select_related/prefetch_related, add indexes, use only() or values() to trim columns, paginate large result sets, and cache expensive stable reads. Confirm each fix with the same measurement.
Key point: in ORM clothing. Observe, hypothesize, fix, confirm. Jumping to a fix without measuring first is the failure mode.
Override save() when the logic is intrinsic to that model and should run wherever it's saved: normalizing a field, computing a derived value. It's explicit and easy to find. Use a signal when the reacting code lives in a different app or shouldn't know about the sender, so the two stay decoupled.
The trade-off is clarity versus coupling. save() keeps behavior visible on the model; signals scatter it but let unrelated apps hook in. Note that bulk operations like bulk_create and queryset update() skip both save() and per-instance signals, which surprises people.
Key point: The bulk_create/update caveat is the senior detail here. these skip save() and.
Two main tools. select_for_update() locks the selected rows inside a transaction so another transaction can't modify them until yours commits, which prevents lost updates on read-modify-write patterns. F expressions push the update into the database as one atomic operation, avoiding the read entirely.
For uniqueness under concurrency, rely on a database unique constraint and handle IntegrityError rather than checking-then-inserting, because the check-then-act window is exactly where the race lives.
from django.db import transaction
with transaction.atomic():
account = Account.objects.select_for_update().get(pk=pk)
account.balance -= amount
account.save()
# other writers wait until this transaction commitsKey point: Naming both select_for_update and the F-expression atomic increment, and when each fits, is the mark of someone who has hit a real race in production.
Split settings into a base module plus per-environment overrides (base, dev, production), selected by DJANGO_SETTINGS_MODULE, or use one file that reads environment variables. Deployment-specific values (database URL, allowed hosts, debug) come from the environment, and secrets come from the platform's secret manager, never the repo.
The anti-patterns to name: a single settings.py with hardcoded credentials, DEBUG left True, and the SECRET_KEY committed to git. Reading config once at startup into validated values, rather than scattered os.environ reads, keeps it testable.
You define connections in the DATABASES setting and write a database router class that tells Django which connection to use for reads, writes, and migrations of a given model. A common setup routes writes to a primary and reads to replicas to spread load.
You can also force a specific database per query with using(). The catch is that cross-database relations and transactions don't span connections, so the router design has to respect where related data lives.
class PrimaryReplicaRouter:
def db_for_read(self, model, **hints):
return "replica"
def db_for_write(self, model, **hints):
return "default"
# force a connection for one query
Book.objects.using("replica").all()The contenttypes framework keeps a registry of every model in the project, so code can refer to a model generically at runtime. Generic relations build on it to let one model relate to any other model, using a ContentType foreign key plus an object id.
Use it for cross-cutting features that attach to many models: comments, tags, likes, or an audit log that can reference any record. The trade-off is that generic foreign keys can't be joined as cleanly as real foreign keys, so use them where the flexibility genuinely pays off.
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
class Comment(models.Model):
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
object_id = models.PositiveIntegerField()
target = GenericForeignKey("content_type", "object_id")Django supports asynchronous views, middleware, and an async ORM interface, running under ASGI. Async views help when a request waits on multiple slow I/O operations you can run concurrently, like several external API calls.
The limits worth stating: parts of the stack are still synchronous, mixing sync and async carries overhead, and a blocking call inside an async view stalls the event loop. For most CPU work or durable background jobs, a task queue still beats async views.
from django.http import JsonResponse
async def dashboard(request):
profile = await get_profile(request.user.id)
stats = await get_stats(request.user.id)
return JsonResponse({"profile": profile, "stats": stats})Key point: The honest answer includes the caveats: sync bits remain, and async is not a substitute for Celery. Overselling async is a common trap.
Treat schema changes as a deployment concern. Make them backward-compatible so old and new code can run together during a rolling deploy: add columns as nullable or with defaults first, deploy code that writes them, backfill data in a separate step, then enforce constraints in a later migration.
Watch for locks: adding an index or a NOT NULL column can lock a large table. Use techniques like creating indexes concurrently on PostgreSQL, run heavy data migrations in batches off the request path, and always have a rollback plan.
A safe zero-downtime schema change
Each step is deployable on its own, so a rolling deploy never has code and schema out of sync.
Middleware for behavior that applies to every request or a broad set of them: security headers, request logging, tenant resolution. A decorator for per-view opt-in behavior on function-based views: login_required, cache_page, rate limiting one endpoint. A mixin for shared behavior across class-based views, like a permission check baked into several views.
The choice is about scope and granularity. Middleware is global and ordered, decorators and mixins are targeted. Putting endpoint-specific logic in middleware, or global concerns in a decorator repeated everywhere, is the smell to avoid.
| Tool | Scope | Typical use |
|---|---|---|
| Middleware | Every request | Headers, logging, auth, sessions |
| Decorator | One function view | login_required, cache_page |
| Mixin | A set of class-based views | Shared permission or context logic |
A typical stack runs Django under an application server (Gunicorn for WSGI, Uvicorn or Daphne for ASGI) with several worker processes, behind a reverse proxy like Nginx that serves static files and terminates TLS. A CDN fronts static and media assets, PostgreSQL is the database, and Redis handles cache and the Celery broker.
The app runs statelessly so you can scale workers horizontally, with sessions and cache in shared stores. Configuration comes from environment variables, secrets from a manager, and the whole thing is usually packaged as a container image deployed behind a load balancer.
Key point: Naming Gunicorn/Uvicorn plus Nginx and why (runserver is dev-only, the proxy serves static and TLS) is the experienced answer. Vague answers stop at 'deploy it to a server'.
A handful recur. Mutable defaults and mutating querysets in place; queryset laziness causing a query to fire at an unexpected moment; N+1 loops hidden behind template attribute access; bulk_create and update() skipping save() and signals; changing AUTH_USER_MODEL after migrations; and DEBUG left True in production leaking data.
Also: comparing a queryset's truthiness runs a query, timezone-naive datetimes when USE_TZ is on, and relying on signal side effects that don't run for bulk operations. Knowing these is the difference between writing Django and debugging it at 2am.
Key point: This open-ended question rewards specifics. Two or three concrete gotchas with the reason each bites beats a generic 'be careful with the ORM'.
DRF separates authentication (who you are) from permissions (what you can do). Authentication classes identify the request: SessionAuthentication for browser clients, TokenAuthentication for a stored token, and JWT via a package like SimpleJWT for stateless tokens. Permission classes then gate access: IsAuthenticated, IsAdminUser, or a custom class checking object ownership.
You set defaults globally in REST_FRAMEWORK and override per view. For stateless mobile or single-page clients, JWT is common; for server-rendered sessions, session auth reuses Django's login. Object-level permissions handle 'you can edit only your own records'.
from rest_framework.permissions import IsAuthenticated
from rest_framework.viewsets import ModelViewSet
class BookViewSet(ModelViewSet):
queryset = Book.objects.all()
serializer_class = BookSerializer
permission_classes = [IsAuthenticated]
def get_queryset(self):
return self.queryset.filter(owner=self.request.user) # object scopingKey point: Separating authentication from permission classes, and naming object-level checks for ownership, is the answer that indicates real API experience.
Session auth stores state server-side and sends a session cookie, so you can revoke a session instantly by deleting it, and it pairs naturally with Django's CSRF protection for browser clients. JWT is stateless: the token carries claims and the server verifies a signature without a lookup, which scales across services but makes instant revocation harder.
Choose sessions for first-party web apps where the cookie and CSRF story is simplest, and JWT for mobile clients, third-party API consumers, or multi-service setups where stateless verification matters. The revocation trade-off is the deciding factor to name.
| Session auth | JWT | |
|---|---|---|
| State | Server-side | Stateless (in the token) |
| Revocation | Instant (delete session) | Harder (needs a blocklist) |
| Best fit | First-party web apps | Mobile, third-party, multi-service |
Key point: The revocation difference is the senior point. Saying JWT trades easy revocation for stateless scale shows you've weighed it, not just picked the trendy option.
Django wins when you want a full stack of decisions made for you: an ORM, migrations, an admin, auth, and security defaults that let a small team ship a real application in days. It trades flexibility for convention, so you accept its structure in return for speed and fewer choices to get wrong. Flask and FastAPI stay minimal and let you assemble your own pieces, which fits small services and API-only work. Express plays the same minimal role in the Node ecosystem. Knowing where each fits, and saying it out loud, is itself an interview signal: it shows you pick tools on merits, not habit.
| Framework | Language | Style | Best at |
|---|---|---|---|
| Django | Python | Batteries included, MVT | Full apps with admin, auth, ORM out of the box |
| Flask | Python | Minimal, you assemble parts | Small services, custom stacks |
| FastAPI | Python | Minimal, async-first, typed | High-throughput APIs with auto docs |
| Express | JavaScript | Minimal, middleware-based | Node APIs and full-stack JS teams |
Prepare in layers, and practice out loud. Most Django rounds move from concept questions to live coding to a design or debugging discussion, so rehearse each stage rather than only reading answers.
The typical Django interview flow
Earlier rounds increasingly run as AI coding interviews. The concepts on this page are what gets probed at every stage.
10 questions, about 6 minutes. Score 70% or higher to earn a shareable certificate.
Hyring builds the AI Coding Interviewer that runs live technical rounds in 20+ languages for 5,000+ hiring teams. These Django questions and scoring notes reflect what actually gets asked and evaluated inside the interviews we host.
See how the AI Coding Interviewer works